A finished download marks the Episode row with is_downloaded=True and
populates file_path (commit 0ba2587). The intent was to preserve
download history, but the row stayed in the DB forever because two
sibling sync methods refused to delete it:
- AnimeService._update_series_in_db: `downloaded_set` guard skipped
deletion if the row was marked downloaded.
- SerieScanner._sync_episodes_to_db: `if ep.is_downloaded: continue`
kept downloaded rows.
The user-visible bug: the missing-list UI is correct (it filters by
is_downloaded and the broadcast rebuilds from the DB), but a finished
download left a stale entry in the DB that the user could see when
querying the database directly. Worse, this stale row accumulated
indefinitely across rescans.
Fix: drop the is_downloaded preservation guards. Once the scanner
confirms the file is on disk and the episode is no longer in the
missing set, the Episode row has no further purpose and is deleted to
keep the DB in sync with the filesystem. The UI derives "missing"
from row presence, so deleting the row is the correct way to make the
episode stop appearing as missing in *all* views (UI and DB queries).
Tests:
- test_update_series_deletes_downloaded_episodes_when_no_longer_missing
(RED): regression for _update_series_in_db.
- test_update_series_keeps_still_missing_episodes: sanity sibling
to ensure the fix does not over-reach and delete still-missing
episodes.
- test_deletes_downloaded_episodes_when_no_longer_missing
(RED): regression for SerieScanner._sync_episodes_to_db.
- Replaced test_preserves_downloaded_episodes (which asserted the
old buggy behavior) with the deletion-asserting variant.
868 lines
31 KiB
Python
868 lines
31 KiB
Python
"""
|
|
SerieScanner - Scans directories for anime series and missing episodes.
|
|
|
|
This module provides functionality to scan anime directories, identify
|
|
missing episodes, and report progress through callback interfaces.
|
|
|
|
Note:
|
|
This module is pure domain logic. Database operations are handled
|
|
by the service layer (AnimeService).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import re
|
|
import traceback
|
|
import uuid
|
|
from typing import Callable, Iterable, Iterator, Optional
|
|
|
|
from events import Events
|
|
|
|
from src.config.settings import settings
|
|
from src.server.database.models import AnimeSeries
|
|
from src.server.exceptions.exceptions.Exceptions import MatchNotFoundError
|
|
from src.server.providers.base_provider import Loader
|
|
from src.server.database.connection import get_sync_session
|
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
error_logger = logging.getLogger("error")
|
|
no_key_found_logger = logging.getLogger("series.nokey")
|
|
|
|
|
|
class SerieScanner:
|
|
"""
|
|
Scans directories for anime series and identifies missing episodes.
|
|
|
|
Supports progress callbacks for real-time scanning updates.
|
|
|
|
Note:
|
|
This class is pure domain logic. Database operations are handled
|
|
by the service layer (AnimeService). Scan results are stored
|
|
in keyDict and can be retrieved after scanning.
|
|
|
|
Example:
|
|
# Synchronous context (CLI):
|
|
scanner = SerieScanner("/path/to/anime", loader)
|
|
scanner.scan() # asyncio.run() used internally when no event loop
|
|
|
|
# Asynchronous context (server/scheduler):
|
|
# scan() detects running event loop and uses create_task()
|
|
# internally, so no special handling needed by caller.
|
|
# Results are in scanner.keyDict
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
basePath: str,
|
|
loader: Loader,
|
|
) -> None:
|
|
"""
|
|
Initialize the SerieScanner.
|
|
|
|
Args:
|
|
basePath: Base directory containing anime series
|
|
loader: Loader instance for fetching series information
|
|
|
|
Raises:
|
|
ValueError: If basePath is invalid or doesn't exist
|
|
"""
|
|
# Validate basePath to prevent directory traversal attacks
|
|
if not basePath or not basePath.strip():
|
|
raise ValueError("Base path cannot be empty")
|
|
|
|
# Resolve to absolute path and validate it exists
|
|
abs_path = os.path.abspath(basePath)
|
|
if not os.path.exists(abs_path):
|
|
raise ValueError(f"Base path does not exist: {abs_path}")
|
|
if not os.path.isdir(abs_path):
|
|
raise ValueError(f"Base path is not a directory: {abs_path}")
|
|
|
|
self.directory: str = abs_path
|
|
self.keyDict: dict[str, AnimeSeries] = {}
|
|
self.loader: Loader = loader
|
|
self._current_operation_id: Optional[str] = None
|
|
self.events = Events()
|
|
|
|
self.events.on_progress = []
|
|
self.events.on_error = []
|
|
self.events.on_warning = []
|
|
self.events.on_completion = []
|
|
|
|
logger.info("Initialized SerieScanner with base path: %s", abs_path)
|
|
|
|
def _safe_call_event(self, event_handler, data: dict) -> None:
|
|
"""Safely call an event handler if it exists.
|
|
|
|
Args:
|
|
event_handler: Event handler attribute (e.g., self.events.on_progress)
|
|
data: Data dictionary to pass to the event handler
|
|
"""
|
|
if event_handler:
|
|
try:
|
|
# Event handlers are stored as lists, iterate over them
|
|
for handler in event_handler:
|
|
handler(data)
|
|
except Exception as e:
|
|
logger.error("Error calling event handler: %s", e, exc_info=True)
|
|
|
|
def subscribe_on_progress(self, handler):
|
|
"""
|
|
Subscribe a handler to an event.
|
|
Args:
|
|
handler: Callable to handle the event
|
|
"""
|
|
if handler not in self.events.on_progress:
|
|
self.events.on_progress.append(handler)
|
|
|
|
def unsubscribe_on_progress(self, handler):
|
|
"""
|
|
Unsubscribe a handler from an event.
|
|
Args:
|
|
handler: Callable to remove
|
|
"""
|
|
if handler in self.events.on_progress:
|
|
self.events.on_progress.remove(handler)
|
|
|
|
def _extract_year_from_folder_name(self, folder_name: str) -> int | None:
|
|
"""Extract year from folder name if present.
|
|
|
|
Looks for year in format "(YYYY)" at the end of folder name.
|
|
|
|
Args:
|
|
folder_name: The folder name to check
|
|
|
|
Returns:
|
|
int or None: Year if found, None otherwise
|
|
|
|
Example:
|
|
>>> _extract_year_from_folder_name("Dororo (2025)")
|
|
2025
|
|
>>> _extract_year_from_folder_name("Dororo")
|
|
None
|
|
"""
|
|
if not folder_name:
|
|
return None
|
|
|
|
# Look for year in format (YYYY) - typically at end of name
|
|
match = re.search(r'\((\d{4})\)', folder_name)
|
|
if match:
|
|
try:
|
|
year = int(match.group(1))
|
|
# Validate year is reasonable (between 1900 and 2100)
|
|
if 1900 <= year <= 2100:
|
|
logger.debug(
|
|
"Extracted year from folder name: %s -> %d",
|
|
folder_name,
|
|
year
|
|
)
|
|
return year
|
|
except ValueError:
|
|
pass
|
|
|
|
return None
|
|
|
|
def subscribe_on_error(self, handler):
|
|
"""
|
|
Subscribe a handler to an event.
|
|
Args:
|
|
handler: Callable to handle the event
|
|
"""
|
|
if handler not in self.events.on_error:
|
|
self.events.on_error.append(handler)
|
|
|
|
def unsubscribe_on_error(self, handler):
|
|
"""
|
|
Unsubscribe a handler from an event.
|
|
Args:
|
|
handler: Callable to remove
|
|
"""
|
|
if handler in self.events.on_error:
|
|
self.events.on_error.remove(handler)
|
|
|
|
def subscribe_on_warning(self, handler):
|
|
"""
|
|
Subscribe a handler to an event.
|
|
Args:
|
|
handler: Callable to handle the event
|
|
"""
|
|
if handler not in self.events.on_warning:
|
|
self.events.on_warning.append(handler)
|
|
|
|
def unsubscribe_on_warning(self, handler):
|
|
"""
|
|
Unsubscribe a handler from an event.
|
|
Args:
|
|
handler: Callable to remove
|
|
"""
|
|
if handler in self.events.on_warning:
|
|
self.events.on_warning.remove(handler)
|
|
|
|
def subscribe_on_completion(self, handler):
|
|
"""
|
|
Subscribe a handler to an event.
|
|
Args:
|
|
handler: Callable to handle the event
|
|
"""
|
|
if handler not in self.events.on_completion:
|
|
self.events.on_completion.append(handler)
|
|
|
|
def unsubscribe_on_completion(self, handler):
|
|
"""
|
|
Unsubscribe a handler from an event.
|
|
Args:
|
|
handler: Callable to remove
|
|
"""
|
|
if handler in self.events.on_completion:
|
|
self.events.on_completion.remove(handler)
|
|
|
|
def reinit(self) -> None:
|
|
"""Reinitialize the series dictionary (keyed by anime.key)."""
|
|
self.keyDict: dict[str, AnimeSeries] = {}
|
|
|
|
async def _persist_serie_to_db(self, anime: AnimeSeries) -> None:
|
|
"""Persist anime to database (create or update).
|
|
|
|
Args:
|
|
anime: AnimeSeries model to persist
|
|
"""
|
|
try:
|
|
from src.server.database.connection import get_async_session_factory
|
|
|
|
db = get_async_session_factory()
|
|
try:
|
|
existing = await AnimeSeriesService.get_by_key(db, anime.key)
|
|
if existing:
|
|
await AnimeSeriesService.update(
|
|
db, existing.id,
|
|
name=anime.name,
|
|
folder=anime.folder,
|
|
year=anime.year
|
|
)
|
|
await self._sync_episodes_to_db(db, existing.id, anime.episodeDict)
|
|
else:
|
|
db_anime = await AnimeSeriesService.create(
|
|
db=db,
|
|
key=anime.key,
|
|
name=anime.name,
|
|
site=anime.site,
|
|
folder=anime.folder,
|
|
year=anime.year
|
|
)
|
|
for ep in anime.episodes:
|
|
await EpisodeService.create(
|
|
db=db,
|
|
series_id=db_anime.id,
|
|
season=ep.season,
|
|
episode_number=ep.episode_number
|
|
)
|
|
await db.commit()
|
|
logger.debug(
|
|
"Persisted anime '%s' (key=%s) to database",
|
|
anime.name, anime.key
|
|
)
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(
|
|
"Failed to persist anime '%s' to DB: %s",
|
|
anime.key, e, exc_info=True
|
|
)
|
|
raise
|
|
finally:
|
|
await db.close()
|
|
except Exception as e:
|
|
logger.error(
|
|
"Could not persist anime '%s' to DB (DB unavailable?): %s",
|
|
anime.key, e
|
|
)
|
|
|
|
async def _sync_episodes_to_db(
|
|
self, db, series_id: int, episode_dict: dict[int, list[int]]
|
|
) -> None:
|
|
"""Sync episodes to database.
|
|
|
|
Adds missing episodes, removes episodes no longer missing
|
|
(including those that were previously marked as downloaded:
|
|
once the scanner confirms the file is on disk, the row has
|
|
no further purpose and is deleted to keep the DB in sync
|
|
with the filesystem).
|
|
|
|
Args:
|
|
db: Async database session
|
|
series_id: Database ID of the series
|
|
episode_dict: Dict mapping season -> list of episode numbers
|
|
"""
|
|
existing_episodes = await EpisodeService.get_by_series(db, series_id)
|
|
existing_map = {
|
|
(ep.season, ep.episode_number): ep for ep in existing_episodes
|
|
}
|
|
new_keys = set()
|
|
for season, eps in episode_dict.items():
|
|
for ep_num in eps:
|
|
new_keys.add((season, ep_num))
|
|
for (season, ep_num), ep in existing_map.items():
|
|
if (season, ep_num) not in new_keys:
|
|
await EpisodeService.delete_by_series(
|
|
db, series_id, season, ep_num
|
|
)
|
|
for season, eps in episode_dict.items():
|
|
for ep_num in eps:
|
|
if (season, ep_num) not in existing_map:
|
|
await EpisodeService.create(
|
|
db=db,
|
|
series_id=series_id,
|
|
season=season,
|
|
episode_number=ep_num
|
|
)
|
|
|
|
def get_total_to_scan(self) -> int:
|
|
"""Get the total number of folders to scan.
|
|
|
|
Returns:
|
|
Total count of folders with MP4 files
|
|
"""
|
|
result = self.__find_mp4_files()
|
|
return sum(1 for _ in result)
|
|
|
|
def scan(self) -> None:
|
|
"""
|
|
Scan directories for anime series and missing episodes.
|
|
|
|
Results are stored in self.keyDict and can be retrieved after
|
|
scanning. Data files are also saved to disk for persistence.
|
|
|
|
Raises:
|
|
Exception: If scan fails critically
|
|
"""
|
|
# Generate unique operation ID
|
|
self._current_operation_id = str(uuid.uuid4())
|
|
|
|
logger.info("Starting scan for missing episodes")
|
|
|
|
# Notify scan starting
|
|
self._safe_call_event(
|
|
self.events.on_progress,
|
|
{
|
|
"operation_id": self._current_operation_id,
|
|
"phase": "STARTING",
|
|
"current": 0,
|
|
"total": 0,
|
|
"percentage": 0.0,
|
|
"message": "Initializing scan"
|
|
}
|
|
)
|
|
|
|
try:
|
|
# Get total items to process
|
|
total_to_scan = self.get_total_to_scan()
|
|
logger.info("Total folders to scan: %d", total_to_scan)
|
|
|
|
# The scanner enumerates folders with mp4 files, loads existing
|
|
# metadata, calculates the missing episodes via the provider, and
|
|
# persists the refreshed metadata while emitting progress events.
|
|
result = self.__find_mp4_files()
|
|
counter = 0
|
|
|
|
for folder, mp4_files in result:
|
|
try:
|
|
counter += 1
|
|
|
|
# Calculate progress
|
|
if total_to_scan > 0:
|
|
percentage = (counter / total_to_scan) * 100
|
|
else:
|
|
percentage = 0.0
|
|
|
|
# Notify progress
|
|
self._safe_call_event(
|
|
self.events.on_progress,
|
|
{
|
|
"operation_id": self._current_operation_id,
|
|
"phase": "IN_PROGRESS",
|
|
"current": counter,
|
|
"total": total_to_scan,
|
|
"percentage": percentage,
|
|
"message": f"Scanning: {folder}",
|
|
"details": f"Found {len(mp4_files)} episodes"
|
|
}
|
|
)
|
|
|
|
serie = self.__read_data_from_file(folder)
|
|
if serie is None or not serie.key or not serie.key.strip():
|
|
logger.warning(
|
|
"No series found in DB for folder '%s', skipping",
|
|
folder,
|
|
)
|
|
continue
|
|
if (
|
|
serie is not None
|
|
and serie.key
|
|
and serie.key.strip()
|
|
):
|
|
# Delegate the provider to compare local files with
|
|
# remote metadata, yielding missing episodes per
|
|
# season. Results are saved back to disk so that both
|
|
# CLI and API consumers see consistent state.
|
|
missing_episodes, _site = (
|
|
self.__get_missing_episodes_and_season(
|
|
serie.key, mp4_files
|
|
)
|
|
)
|
|
serie.episodeDict = missing_episodes
|
|
serie.folder = folder
|
|
|
|
# Persist to database (async)
|
|
try:
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
# No running loop — safe to use asyncio.run()
|
|
asyncio.run(self._persist_serie_to_db(serie))
|
|
else:
|
|
# Already in async context — schedule as task
|
|
asyncio.create_task(self._persist_serie_to_db(serie))
|
|
except Exception as e:
|
|
logger.warning(
|
|
"DB persistence failed for '%s', "
|
|
"continuing without DB: %s",
|
|
serie.key, e
|
|
)
|
|
|
|
# Store by key (primary identifier), not folder
|
|
if serie.key in self.keyDict:
|
|
existing = self.keyDict[serie.key]
|
|
logger.warning(
|
|
"Duplicate series found with key '%s': "
|
|
"folder '%s' maps to same key as existing folder '%s'. "
|
|
"Skipping duplicate folder.",
|
|
serie.key,
|
|
folder,
|
|
existing.folder
|
|
)
|
|
self._safe_call_event(
|
|
self.events.on_warning,
|
|
{
|
|
"operation_id": self._current_operation_id,
|
|
"warning": "duplicate_key",
|
|
"message": f"Duplicate series skipped: '{folder}' maps to key '{serie.key}' already used by '{existing.folder}'",
|
|
"metadata": {
|
|
"key": serie.key,
|
|
"duplicate_folder": folder,
|
|
"existing_folder": existing.folder,
|
|
}
|
|
}
|
|
)
|
|
else:
|
|
self.keyDict[serie.key] = serie
|
|
logger.debug(
|
|
"Stored series with key '%s' (folder: '%s')",
|
|
serie.key,
|
|
folder
|
|
)
|
|
no_key_found_logger.info(
|
|
"Saved Serie: '%s'", str(serie)
|
|
)
|
|
|
|
except Exception as e:
|
|
# Log error and notify via callback
|
|
error_msg = (
|
|
f"Folder: '{folder}' - "
|
|
f"Unexpected error: {e}"
|
|
)
|
|
error_logger.error(
|
|
"%s\n%s",
|
|
error_msg,
|
|
traceback.format_exc()
|
|
)
|
|
|
|
self._safe_call_event(
|
|
self.events.on_error,
|
|
{
|
|
"operation_id": self._current_operation_id,
|
|
"error": e,
|
|
"message": error_msg,
|
|
"recoverable": True,
|
|
"metadata": {"folder": folder, "key": None}
|
|
}
|
|
)
|
|
continue
|
|
|
|
# Notify scan completion
|
|
self._safe_call_event(
|
|
self.events.on_completion,
|
|
{
|
|
"operation_id": self._current_operation_id,
|
|
"success": True,
|
|
"message": f"Scan completed. Processed {counter} folders.",
|
|
"statistics": {
|
|
"total_folders": counter,
|
|
"series_found": len(self.keyDict)
|
|
}
|
|
}
|
|
)
|
|
|
|
logger.info(
|
|
"Scan completed. Processed %d folders, found %d series",
|
|
counter,
|
|
len(self.keyDict)
|
|
)
|
|
|
|
except Exception as e:
|
|
# Critical error - notify and re-raise
|
|
error_msg = f"Critical scan error: {e}"
|
|
logger.error("%s\n%s", error_msg, traceback.format_exc())
|
|
|
|
self._safe_call_event(
|
|
self.events.on_error,
|
|
{
|
|
"operation_id": self._current_operation_id,
|
|
"error": e,
|
|
"message": error_msg,
|
|
"recoverable": False
|
|
}
|
|
)
|
|
|
|
self._safe_call_event(
|
|
self.events.on_completion,
|
|
{
|
|
"operation_id": self._current_operation_id,
|
|
"success": False,
|
|
"message": error_msg
|
|
}
|
|
)
|
|
|
|
raise
|
|
|
|
def __find_mp4_files(self) -> Iterator[tuple[str, list[str]]]:
|
|
"""Find all .mp4 files in the directory structure."""
|
|
logger.info("Scanning for .mp4 files")
|
|
for anime_name in os.listdir(self.directory):
|
|
anime_path = os.path.join(self.directory, anime_name)
|
|
if os.path.isdir(anime_path):
|
|
if settings.should_ignore_folder(anime_name):
|
|
logger.debug("Skipping ignored folder: %s", anime_name)
|
|
continue
|
|
mp4_files: list[str] = []
|
|
has_files = False
|
|
for root, _, files in os.walk(anime_path):
|
|
for file in files:
|
|
if file.endswith(".mp4"):
|
|
mp4_files.append(os.path.join(root, file))
|
|
has_files = True
|
|
yield anime_name, mp4_files if has_files else []
|
|
|
|
def __read_data_from_file(self, folder_name: str) -> Optional[AnimeSeries]:
|
|
"""Load or discover an AnimeSeries for the given folder.
|
|
|
|
Strategy:
|
|
1. Query DB by folder name
|
|
2. If not found in DB, return None (no file fallback)
|
|
|
|
Args:
|
|
folder_name: Filesystem folder name
|
|
|
|
Returns:
|
|
AnimeSeries object if found in DB, None otherwise
|
|
"""
|
|
# Step 1: Try DB lookup by folder name
|
|
try:
|
|
session = get_sync_session()
|
|
try:
|
|
anime_series = AnimeSeriesService.get_by_folder_sync(session, folder_name)
|
|
return anime_series
|
|
finally:
|
|
session.close()
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"DB lookup failed for folder '%s': %s",
|
|
folder_name,
|
|
exc
|
|
)
|
|
|
|
return None
|
|
|
|
def __get_episode_and_season(self, filename: str) -> tuple[int, int]:
|
|
"""Extract season and episode numbers from filename.
|
|
|
|
Args:
|
|
filename: Filename to parse
|
|
|
|
Returns:
|
|
Tuple of (season, episode) as integers
|
|
|
|
Raises:
|
|
MatchNotFoundError: If pattern not found
|
|
"""
|
|
pattern = r'S(\d+)E(\d+)'
|
|
match = re.search(pattern, filename)
|
|
if match:
|
|
season = match.group(1)
|
|
episode = match.group(2)
|
|
logger.debug(
|
|
"Extracted season %s, episode %s from '%s'",
|
|
season,
|
|
episode,
|
|
filename
|
|
)
|
|
return int(season), int(episode)
|
|
else:
|
|
logger.error(
|
|
"Failed to find season/episode pattern in '%s'",
|
|
filename
|
|
)
|
|
raise MatchNotFoundError(
|
|
"Season and episode pattern not found in the filename."
|
|
)
|
|
|
|
def __get_episodes_and_seasons(
|
|
self,
|
|
mp4_files: Iterable[str]
|
|
) -> dict[int, list[int]]:
|
|
"""Get episodes grouped by season from mp4 files.
|
|
|
|
Args:
|
|
mp4_files: List of MP4 filenames
|
|
|
|
Returns:
|
|
Dictionary mapping season to list of episode numbers
|
|
"""
|
|
episodes_dict: dict[int, list[int]] = {}
|
|
|
|
for file in mp4_files:
|
|
season, episode = self.__get_episode_and_season(file)
|
|
|
|
if season in episodes_dict:
|
|
episodes_dict[season].append(episode)
|
|
else:
|
|
episodes_dict[season] = [episode]
|
|
return episodes_dict
|
|
|
|
def __get_missing_episodes_and_season(
|
|
self,
|
|
key: str,
|
|
mp4_files: Iterable[str]
|
|
) -> tuple[dict[int, list[int]], str]:
|
|
"""Get missing episodes for a serie.
|
|
|
|
Args:
|
|
key: Series key
|
|
mp4_files: List of MP4 filenames
|
|
|
|
Returns:
|
|
Tuple of (episodes_dict, site_name)
|
|
"""
|
|
# key season , value count of episodes
|
|
expected_dict = self.loader.get_season_episode_count(key)
|
|
filedict = self.__get_episodes_and_seasons(mp4_files)
|
|
episodes_dict: dict[int, list[int]] = {}
|
|
for season, expected_count in expected_dict.items():
|
|
existing_episodes = filedict.get(season, [])
|
|
missing_episodes = [
|
|
ep for ep in range(1, expected_count + 1)
|
|
if ep not in existing_episodes
|
|
and self.loader.is_language(season, ep, key)
|
|
]
|
|
|
|
if missing_episodes:
|
|
episodes_dict[season] = missing_episodes
|
|
|
|
return episodes_dict, "aniworld.to"
|
|
|
|
def scan_single_series(
|
|
self,
|
|
key: str,
|
|
folder: str,
|
|
) -> dict[int, list[int]]:
|
|
"""
|
|
Scan a single series for missing episodes.
|
|
|
|
This method performs a targeted scan for only the specified series,
|
|
without triggering a full library rescan. It fetches available
|
|
episodes from the provider and compares with local files.
|
|
|
|
Args:
|
|
key: The unique provider key for the series
|
|
folder: The filesystem folder name where the series is stored
|
|
|
|
Returns:
|
|
dict[int, list[int]]: Dictionary mapping season numbers to lists
|
|
of missing episode numbers. Empty dict if no missing episodes.
|
|
|
|
Raises:
|
|
ValueError: If key or folder is empty
|
|
|
|
Example:
|
|
>>> scanner = SerieScanner("/path/to/anime", loader)
|
|
>>> missing = scanner.scan_single_series(
|
|
... "attack-on-titan",
|
|
... "Attack on Titan"
|
|
... )
|
|
>>> print(missing)
|
|
{1: [5, 6, 7], 2: [1, 2]}
|
|
"""
|
|
if not key or not key.strip():
|
|
raise ValueError("Series key cannot be empty")
|
|
if not folder or not folder.strip():
|
|
raise ValueError("Series folder cannot be empty")
|
|
|
|
logger.info(
|
|
"Starting targeted scan for series: %s (folder: %s)",
|
|
key,
|
|
folder
|
|
)
|
|
|
|
# Generate unique operation ID for this targeted scan
|
|
operation_id = str(uuid.uuid4())
|
|
# Notify scan starting
|
|
self._safe_call_event(
|
|
self.events.on_progress,
|
|
{
|
|
"operation_id": operation_id,
|
|
"phase": "STARTING",
|
|
"current": 0,
|
|
"total": 1,
|
|
"percentage": 0.0,
|
|
"message": f"Scanning series: {folder}",
|
|
"details": f"Key: {key}"
|
|
}
|
|
)
|
|
|
|
try:
|
|
# Get the folder path
|
|
folder_path = os.path.join(self.directory, folder)
|
|
|
|
# Check if folder exists
|
|
if not os.path.isdir(folder_path):
|
|
logger.info(
|
|
"Series folder does not exist yet: %s - "
|
|
"will scan for available episodes from provider",
|
|
folder_path
|
|
)
|
|
mp4_files: list[str] = []
|
|
else:
|
|
# Find existing MP4 files in the folder
|
|
mp4_files = []
|
|
for root, _, files in os.walk(folder_path):
|
|
for file in files:
|
|
if file.endswith(".mp4"):
|
|
mp4_files.append(os.path.join(root, file))
|
|
|
|
logger.debug(
|
|
"Found %d existing MP4 files in folder %s",
|
|
len(mp4_files),
|
|
folder
|
|
)
|
|
|
|
# Get missing episodes from provider
|
|
missing_episodes, site = self.__get_missing_episodes_and_season(
|
|
key, mp4_files
|
|
)
|
|
|
|
# Update progress
|
|
self._safe_call_event(
|
|
self.events.on_progress,
|
|
{
|
|
"operation_id": operation_id,
|
|
"phase": "IN_PROGRESS",
|
|
"current": 1,
|
|
"total": 1,
|
|
"percentage": 100.0,
|
|
"message": f"Scanned: {folder}",
|
|
"details": f"Found {sum(len(eps) for eps in missing_episodes.values())} missing episodes"
|
|
}
|
|
)
|
|
|
|
# Create or update AnimeSeries in keyDict
|
|
if key in self.keyDict:
|
|
# Update existing anime - rebuild episodeDict from episodes
|
|
existing = self.keyDict[key]
|
|
existing_ep_dict = existing.episodeDict
|
|
# Merge missing episodes
|
|
for season, eps in missing_episodes.items():
|
|
if season not in existing_ep_dict:
|
|
existing_ep_dict[season] = []
|
|
existing_ep_dict[season].extend(eps)
|
|
logger.debug(
|
|
"Updated existing series %s with %d missing episodes",
|
|
key,
|
|
sum(len(eps) for eps in missing_episodes.values())
|
|
)
|
|
else:
|
|
# Extract year from folder name if present, otherwise leave as None
|
|
year = self._extract_year_from_folder_name(folder)
|
|
|
|
# Create new AnimeSeries entry (minimal, fields populated later)
|
|
from src.server.database.models import AnimeSeries
|
|
anime_series = AnimeSeries(
|
|
key=key,
|
|
name=folder, # Use folder as fallback name since we don't have actual name
|
|
site=site,
|
|
folder=folder,
|
|
year=year
|
|
)
|
|
# Set episodeDict cache directly since AnimeSeries doesn't persist missing episodes
|
|
# (they get synced to DB via _persist_serie_to_db later)
|
|
anime_series._episode_dict_cache = missing_episodes.copy()
|
|
self.keyDict[key] = anime_series
|
|
logger.debug(
|
|
"Created new series entry for %s with %d missing episodes (year=%s)",
|
|
key,
|
|
sum(len(eps) for eps in missing_episodes.values()),
|
|
year
|
|
)
|
|
|
|
# Notify completion
|
|
self._safe_call_event(
|
|
self.events.on_completion,
|
|
{
|
|
"operation_id": operation_id,
|
|
"success": True,
|
|
"message": f"Scan completed for {folder}",
|
|
"statistics": {
|
|
"missing_episodes": sum(
|
|
len(eps) for eps in missing_episodes.values()
|
|
),
|
|
"seasons_with_missing": len(missing_episodes)
|
|
}
|
|
}
|
|
)
|
|
|
|
logger.info(
|
|
"Targeted scan completed for %s: %d missing episodes across %d seasons",
|
|
key,
|
|
sum(len(eps) for eps in missing_episodes.values()),
|
|
len(missing_episodes)
|
|
)
|
|
|
|
return missing_episodes
|
|
|
|
except Exception as e:
|
|
error_msg = f"Failed to scan series {key}: {e}"
|
|
logger.error(error_msg, exc_info=True)
|
|
|
|
# Notify error
|
|
self._safe_call_event(
|
|
self.events.on_error,
|
|
{
|
|
"operation_id": operation_id,
|
|
"error": e,
|
|
"message": error_msg,
|
|
"recoverable": True,
|
|
"metadata": {"key": key, "folder": folder}
|
|
}
|
|
)
|
|
# Notify completion with failure
|
|
self._safe_call_event(
|
|
self.events.on_completion,
|
|
{
|
|
"operation_id": operation_id,
|
|
"success": False,
|
|
"message": error_msg
|
|
}
|
|
)
|
|
# Return empty dict on error (scan failed but not critical)
|
|
return {}
|
|
|