From 9c3f03d6105151789b3178ac2a120a8f8dd1bc30 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 3 Jun 2026 20:58:30 +0200 Subject: [PATCH] refactor(scheduler): separate scheduler logic from scan/rescan logic - Extract rescan logic into new RescanService (src/server/services/rescan_service.py) - SchedulerService now only handles APScheduler cron scheduling - Move scheduler sub-services (folder_rename, folder_scan, key_resolution) to scheduler/ folder - Keep RescanOrchestrator as backward-compatible alias - Update all imports across api/, server/, and test files --- src/server/api/anime.py | 4 +- src/server/api/auth.py | 2 +- src/server/api/config.py | 2 +- src/server/api/health.py | 4 +- src/server/api/scheduler.py | 2 +- src/server/fastapi_app.py | 8 +- src/server/services/rescan_service.py | 291 ++++++++++ src/server/services/scheduler/__init__.py | 45 ++ .../{ => scheduler}/folder_rename_service.py | 523 +++++++++--------- .../{ => scheduler}/folder_scan_service.py | 2 +- .../{ => scheduler}/key_resolution_service.py | 0 .../services/scheduler/rescan_orchestrator.py | 293 ++++++++++ .../{ => scheduler}/scheduler_service.py | 223 +------- tests/api/test_config_endpoints.py | 4 +- .../integration/test_folder_rename_startup.py | 20 +- tests/integration/test_nfo_repair_startup.py | 16 +- .../integration/test_poster_check_startup.py | 46 +- tests/integration/test_scheduler_workflow.py | 7 +- tests/unit/test_concurrent_scans.py | 4 +- tests/unit/test_ffmpeg_health_check.py | 4 +- tests/unit/test_folder_rename_service.py | 62 +-- tests/unit/test_folder_scan_service.py | 40 +- tests/unit/test_initialization_service.py | 12 +- tests/unit/test_key_resolution_service.py | 20 +- tests/unit/test_scheduler_service.py | 24 +- 25 files changed, 1080 insertions(+), 578 deletions(-) create mode 100644 src/server/services/rescan_service.py create mode 100644 src/server/services/scheduler/__init__.py rename src/server/services/{ => scheduler}/folder_rename_service.py (59%) rename src/server/services/{ => scheduler}/folder_scan_service.py (99%) rename src/server/services/{ => scheduler}/key_resolution_service.py (100%) create mode 100644 src/server/services/scheduler/rescan_orchestrator.py rename src/server/services/{ => scheduler}/scheduler_service.py (63%) diff --git a/src/server/api/anime.py b/src/server/api/anime.py index e4dd71f..e5e026b 100644 --- a/src/server/api/anime.py +++ b/src/server/api/anime.py @@ -20,7 +20,9 @@ from src.server.exceptions import ( from src.server.models.anime import AnimeMetadataUpdate from src.server.services.anime_service import AnimeService, AnimeServiceError from src.server.services.background_loader_service import BackgroundLoaderService -from src.server.services.folder_rename_service import _scan_for_pre_existing_duplicates +from src.server.services.scheduler.folder_rename_service import ( + _scan_for_pre_existing_duplicates, +) from src.server.utils.dependencies import ( get_anime_service, get_background_loader_service, diff --git a/src/server/api/auth.py b/src/server/api/auth.py index 8409311..ce0c26b 100644 --- a/src/server/api/auth.py +++ b/src/server/api/auth.py @@ -165,7 +165,7 @@ async def setup_auth(req: SetupRequest): # Start scheduler if anime_directory is now set try: - from src.server.services.scheduler_service import ( + from src.server.services.scheduler.scheduler_service import ( get_scheduler_service, ) diff --git a/src/server/api/config.py b/src/server/api/config.py index 558f38d..22f16ff 100644 --- a/src/server/api/config.py +++ b/src/server/api/config.py @@ -57,7 +57,7 @@ async def update_config( # Start scheduler if anime_directory was just configured if anime_dir_changed: try: - from src.server.services.scheduler_service import ( + from src.server.services.scheduler.scheduler_service import ( get_scheduler_service, ) diff --git a/src/server/api/health.py b/src/server/api/health.py index aad7626..e2d86d9 100644 --- a/src/server/api/health.py +++ b/src/server/api/health.py @@ -195,7 +195,9 @@ async def basic_health_check(request: Request) -> HealthStatus: # Get scheduler status for health monitoring scheduler_status: dict = {} try: - from src.server.services.scheduler_service import get_scheduler_service + from src.server.services.scheduler.scheduler_service import ( + get_scheduler_service, + ) scheduler_status = get_scheduler_service().get_status() except Exception: pass diff --git a/src/server/api/scheduler.py b/src/server/api/scheduler.py index 5824942..09cafaf 100644 --- a/src/server/api/scheduler.py +++ b/src/server/api/scheduler.py @@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from src.server.models.config import SchedulerConfig from src.server.services.config_service import ConfigServiceError, get_config_service -from src.server.services.scheduler_service import get_scheduler_service +from src.server.services.scheduler.scheduler_service import get_scheduler_service from src.server.utils.dependencies import require_auth logger = logging.getLogger(__name__) diff --git a/src/server/fastapi_app.py b/src/server/fastapi_app.py index 9cb5827..8c8ba9d 100644 --- a/src/server/fastapi_app.py +++ b/src/server/fastapi_app.py @@ -411,7 +411,9 @@ async def lifespan(_application: FastAPI): # anime_directory may be configured there even if the env var is empty. try: logger.info("Initializing scheduler service...") - from src.server.services.scheduler_service import get_scheduler_service + from src.server.services.scheduler.scheduler_service import ( + get_scheduler_service, + ) scheduler_service = get_scheduler_service() logger.info("Scheduler service instance obtained, starting...") await scheduler_service.start() @@ -496,7 +498,9 @@ async def lifespan(_application: FastAPI): # 1. Stop scheduler service (only if initialized) if initialized['scheduler']: try: - from src.server.services.scheduler_service import get_scheduler_service + from src.server.services.scheduler.scheduler_service import ( + get_scheduler_service, + ) scheduler_service = get_scheduler_service() logger.info("Stopping scheduler service...") await asyncio.wait_for( diff --git a/src/server/services/rescan_service.py b/src/server/services/rescan_service.py new file mode 100644 index 0000000..19a62d1 --- /dev/null +++ b/src/server/services/rescan_service.py @@ -0,0 +1,291 @@ +"""Rescan service — orchestrates library rescans. + +This service handles the actual scan/rescan logic: + +- Library rescan via anime_service +- Auto-download of missing episodes (if enabled) +- Folder maintenance scan (if enabled) +- Orphaned folder key resolution + +SchedulerService only calls RescanService.execute() — it does not +know about the internal steps. +""" +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone +from typing import List, Optional + +from src.server.models.config import SchedulerConfig + +logger = logging.getLogger(__name__) + +_AUTO_DOWNLOAD_COOLDOWN_SECONDS = 300 # 5 minutes + + +class RescanService: + """Orchestrates all rescan-related operations. + + Encapsulates the full post-rescan workflow so SchedulerService + only needs to call a single execute() method. + """ + + def __init__(self, config: Optional[SchedulerConfig] = None) -> None: + """Initialize the rescan service. + + Args: + config: Optional scheduler config. If None, operations that depend + on config flags (auto_download, folder_scan) will be skipped. + """ + self._config = config + self._last_scan_time: Optional[datetime] = None + self._last_auto_download_time: Optional[datetime] = None + + @property + def last_scan_time(self) -> Optional[datetime]: + return self._last_scan_time + + # ------------------------------------------------------------------ + # Public entry point + # ------------------------------------------------------------------ + + async def execute(self) -> dict: + """Execute the full rescan workflow. + + Runs in order: + 1. anime_service.rescan() + 2. auto-download (if enabled) + 3. folder scan (if enabled) + 4. key resolution scan (always, if anime_directory configured) + + Returns: + Dict with duration and counts for each step. + """ + from src.server.services.websocket_service import get_websocket_service + + scan_start = datetime.now(timezone.utc) + results = { + "started_at": scan_start.isoformat(), + "duration_seconds": 0.0, + "rescan_completed": False, + "auto_download_queued": 0, + "folder_scan_completed": False, + "key_resolution": {"resolved": 0, "skipped": 0, "errors": 0}, + } + + await self._broadcast("scheduled_rescan_started", {"timestamp": scan_start.isoformat()}) + + try: + # 1. Main library rescan + await self._run_rescan() + results["rescan_completed"] = True + + # 2. Auto-download + if self._config and self._config.auto_download_after_rescan: + try: + queued = await self._run_auto_download() + results["auto_download_queued"] = queued + await self._broadcast("auto_download_started", {"queued_count": queued}) + except Exception as exc: + logger.error("Auto-download failed: %s", exc, exc_info=True) + await self._broadcast("auto_download_error", {"error": str(exc)}) + + # 3. Folder scan + if self._config and self._config.folder_scan_enabled: + try: + await self._run_folder_scan() + results["folder_scan_completed"] = True + except Exception as exc: + logger.error("Folder scan failed: %s", exc, exc_info=True) + await self._broadcast("folder_scan_error", {"error": str(exc)}) + + # 4. Key resolution scan + try: + key_stats = await self._run_key_resolution() + results["key_resolution"] = key_stats + except Exception as exc: + logger.error("Key resolution scan failed: %s", exc, exc_info=True) + + self._last_scan_time = datetime.now(timezone.utc) + results["duration_seconds"] = (self._last_scan_time - scan_start).total_seconds() + + await self._broadcast( + "scheduled_rescan_completed", + { + "timestamp": self._last_scan_time.isoformat(), + "duration_seconds": results["duration_seconds"], + }, + ) + + logger.info( + "Scheduled library rescan completed: duration=%.2fs", + results["duration_seconds"], + ) + + except Exception as exc: + logger.error("Scheduled rescan failed: %s", exc, exc_info=True) + await self._broadcast( + "scheduled_rescan_error", + {"error": str(exc), "timestamp": datetime.now(timezone.utc).isoformat()}, + ) + raise + + return results + + # ------------------------------------------------------------------ + # Step 1: Library rescan + # ------------------------------------------------------------------ + + async def _run_rescan(self) -> None: + """Run the anime service rescan.""" + from src.server.utils.dependencies import get_anime_service + + anime_service = get_anime_service() + logger.info("Anime service obtained, calling anime_service.rescan()...") + await anime_service.rescan() + logger.info("anime_service.rescan() completed") + + # ------------------------------------------------------------------ + # Step 2: Auto-download + # ------------------------------------------------------------------ + + async def _run_auto_download(self) -> int: + """Queue and start downloads for all series with missing episodes. + + Returns: + Number of episodes queued. + """ + from src.server.models.download import EpisodeIdentifier + from src.server.utils.dependencies import ( + get_anime_service, + get_download_service, + ) + + # Cooldown check to prevent rapid re-triggers + now = datetime.now(timezone.utc) + if self._last_auto_download_time is not None: + elapsed = now - self._last_auto_download_time + if elapsed < timedelta(seconds=_AUTO_DOWNLOAD_COOLDOWN_SECONDS): + logger.debug( + "Auto-download skipped: cooldown active (elapsed=%.1fs cooldown=%ds)", + elapsed.total_seconds(), + _AUTO_DOWNLOAD_COOLDOWN_SECONDS, + ) + return 0 + + anime_service = get_anime_service() + download_service = get_download_service() + + series_list = anime_service._cached_list_missing() + queued_count = 0 + + for series in series_list: + episode_dict: dict = series.get("episodeDict") or {} + if not episode_dict: + continue + + episodes: List[EpisodeIdentifier] = [] + for season_str, ep_numbers in episode_dict.items(): + for ep_num in ep_numbers: + episodes.append( + EpisodeIdentifier(season=int(season_str), episode=int(ep_num)) + ) + + if not episodes: + continue + + await download_service.add_to_queue( + serie_id=series.get("key", ""), + serie_folder=series.get("folder", series.get("name", "")), + serie_name=series.get("name", ""), + episodes=episodes, + ) + queued_count += len(episodes) + logger.info( + "Auto-download queued episodes for series=%s count=%d", + series.get("key"), + len(episodes), + ) + + if queued_count: + await download_service.start_queue_processing() + logger.info("Auto-download queue processing started: queued=%d", queued_count) + + self._last_auto_download_time = datetime.now(timezone.utc) + logger.info("Auto-download completed: queued_count=%d", queued_count) + return queued_count + + # ------------------------------------------------------------------ + # Step 3: Folder scan + # ------------------------------------------------------------------ + + async def _run_folder_scan(self) -> None: + """Run the folder scan maintenance task.""" + from src.server.services.scheduler.folder_scan_service import FolderScanService + + folder_scan_service = FolderScanService() + await folder_scan_service.run_folder_scan() + logger.info("Folder scan completed successfully") + + # ------------------------------------------------------------------ + # Step 4: Key resolution + # ------------------------------------------------------------------ + + async def _run_key_resolution(self) -> dict: + """Run the orphaned folder key resolution scan. + + Returns: + Dict with resolved/skipped/errors counts. + """ + from src.server.services.scheduler.key_resolution_service import ( + perform_key_resolution_scan, + ) + + key_stats = await perform_key_resolution_scan() + logger.info( + "Key resolution scan completed: resolved=%d, skipped=%d, errors=%d", + key_stats["resolved"], + key_stats["skipped"], + key_stats["errors"], + ) + return key_stats + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + async def _broadcast(self, event_type: str, data: dict) -> None: + """Broadcast a WebSocket event to all connected clients.""" + try: + from src.server.services.websocket_service import get_websocket_service + + ws_service = get_websocket_service() + await ws_service.manager.broadcast({"type": event_type, "data": data}) + except Exception as exc: + logger.warning( + "WebSocket broadcast failed: event=%s error=%s", event_type, exc + ) + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + +_rescan_service: Optional[RescanService] = None + + +def get_rescan_service(config: Optional[SchedulerConfig] = None) -> RescanService: + """Return a RescanService singleton (or create with optional config).""" + global _rescan_service + if _rescan_service is None or config is not None: + _rescan_service = RescanService(config=config) + logger.debug("Created new RescanService singleton") + else: + logger.debug("Returning existing RescanService singleton") + return _rescan_service + + +def reset_rescan_service() -> None: + """Reset the singleton (used in tests).""" + global _rescan_service + _rescan_service = None \ No newline at end of file diff --git a/src/server/services/scheduler/__init__.py b/src/server/services/scheduler/__init__.py new file mode 100644 index 0000000..8dc5380 --- /dev/null +++ b/src/server/services/scheduler/__init__.py @@ -0,0 +1,45 @@ +"""Scheduler services package. + +Contains scheduler orchestration and rescan coordination: + +- scheduler_service: Cron-based scheduler using APScheduler +- rescan_orchestrator: Legacy alias for RescanService (for backward compatibility) +""" +from __future__ import annotations + +from src.server.services.rescan_service import ( + RescanService, + get_rescan_service, + reset_rescan_service, +) + +# Backward compatibility alias +from src.server.services.scheduler.rescan_orchestrator import ( + RescanOrchestrator, + get_rescan_orchestrator, + reset_rescan_orchestrator, +) +from src.server.services.scheduler.scheduler_service import ( + SchedulerService, + SchedulerServiceError, + get_scheduler_service, + reset_scheduler_service, +) + +__all__ = [ + # RescanService (new location) + "RescanService", + "get_rescan_service", + "reset_rescan_service", + # Scheduler + "SchedulerService", + "SchedulerServiceError", + "get_scheduler_service", + "reset_scheduler_service", + # Backward compatibility + "RescanOrchestrator", + "get_rescan_orchestrator", + "reset_rescan_orchestrator", + # Sub-services (still in scheduler folder) + "folder_rename_service", +] \ No newline at end of file diff --git a/src/server/services/folder_rename_service.py b/src/server/services/scheduler/folder_rename_service.py similarity index 59% rename from src/server/services/folder_rename_service.py rename to src/server/services/scheduler/folder_rename_service.py index c2f5a7c..aaf9a4f 100644 --- a/src/server/services/folder_rename_service.py +++ b/src/server/services/scheduler/folder_rename_service.py @@ -13,9 +13,12 @@ reflect the new paths. from __future__ import annotations import logging +import re +import shutil from collections import defaultdict +from dataclasses import dataclass from pathlib import Path -from typing import Dict, List, Optional, Set, Tuple +from typing import Optional from lxml import etree @@ -31,10 +34,11 @@ from src.server.utils.filesystem import sanitize_folder_name logger = logging.getLogger(__name__) -# Characters that are invalid in filesystem paths across platforms -INVALID_PATH_CHARS = '<>:"/\\|?*\x00' +# Pre-compiled pattern for stripping existing year suffixes +_YEAR_SUFFIX_PATTERN = re.compile(r'(\s*\(\d{4}\))+\s*$') +@dataclass class DuplicateGroup: """Represents a group of duplicate folders for the same series. @@ -44,10 +48,9 @@ class DuplicateGroup: nfo_paths: List of corresponding NFO file paths. """ - def __init__(self, key: str, folders: List[str], nfo_paths: List[Path]): - self.key = key - self.folders = folders - self.nfo_paths = nfo_paths + key: str + folders: list[str] + nfo_paths: list[Path] @property def count(self) -> int: @@ -57,7 +60,20 @@ class DuplicateGroup: return f"DuplicateGroup(key={self.key!r}, folders={self.folders})" -def _scan_for_pre_existing_duplicates(anime_dir: Path) -> List[DuplicateGroup]: +@dataclass +class RenameStats: + """Statistics from a folder rename operation.""" + + scanned: int = 0 + renamed: int = 0 + skipped: int = 0 + errors: int = 0 + + def to_dict(self) -> dict[str, int]: + return {"scanned": self.scanned, "renamed": self.renamed, "skipped": self.skipped, "errors": self.errors} + + +def _scan_for_pre_existing_duplicates(anime_dir: Path) -> list[DuplicateGroup]: """Scan anime directory for pre-existing duplicate folders. Groups folders by the series key extracted from their NFO files. @@ -69,8 +85,7 @@ def _scan_for_pre_existing_duplicates(anime_dir: Path) -> List[DuplicateGroup]: Returns: List of DuplicateGroup objects, one per series with duplicate folders. """ - # Group folders by their expected name (title+year from NFO) - groups: Dict[str, List[Tuple[str, Path]]] = defaultdict(list) + groups: dict[str, list[tuple[str, Path]]] = defaultdict(list) for series_dir in anime_dir.iterdir(): if not series_dir.is_dir(): @@ -84,7 +99,6 @@ def _scan_for_pre_existing_duplicates(anime_dir: Path) -> List[DuplicateGroup]: expected_name = _compute_expected_folder_name(title, year) groups[expected_name].append((series_dir.name, nfo_path)) - # Filter to only groups with more than one folder duplicates = [] for key, items in groups.items(): if len(items) > 1: @@ -111,16 +125,14 @@ def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) -> if len(group.folders) < 2: return True - # Keep first folder as canonical, mark others for removal canonical = group.folders[0] to_remove = group.folders[1:] for folder in to_remove: - folder_path = group.nfo_paths[0].parent.parent / folder # same parent dir + folder_path = group.nfo_paths[0].parent.parent / folder if not folder_path.exists(): continue - # Check if folder is empty or only has symlinks try: contents = list(folder_path.iterdir()) except PermissionError: @@ -130,7 +142,6 @@ def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) -> return False if not contents: - # Empty folder - safe to remove if dry_run: logger.info("[DRY-RUN] Would delete empty duplicate folder: %s", folder_path) else: @@ -141,9 +152,9 @@ def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) -> return False continue - # Check if all contents are symlinks pointing to canonical + canonical_path = folder_path.parent / canonical all_symlinks = all( - item.is_symlink() and item.resolve() == (folder_path.parent / canonical).resolve() + item.is_symlink() and item.resolve() == canonical_path.resolve() for item in contents ) if all_symlinks: @@ -159,7 +170,6 @@ def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) -> return False continue - # Cannot auto-merge - requires manual intervention logger.warning( "Cannot auto-merge duplicate folders for '%s': %s (manual merge required)", group.key, @@ -170,7 +180,7 @@ def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) -> return True -def _parse_nfo_title_and_year(nfo_path: Path) -> Tuple[Optional[str], Optional[str]]: +def _parse_nfo_title_and_year(nfo_path: Path) -> tuple[Optional[str], Optional[str]]: """Parse a tvshow.nfo and return (title, year) text values. Args: @@ -194,7 +204,7 @@ def _parse_nfo_title_and_year(nfo_path: Path) -> Tuple[Optional[str], Optional[s except etree.XMLSyntaxError as exc: logger.warning("Malformed XML in %s: %s", nfo_path, exc) return None, None - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: logger.warning("Unexpected error parsing %s: %s", nfo_path, exc) return None, None @@ -212,13 +222,7 @@ def _compute_expected_folder_name(title: str, year: str) -> str: Returns: Sanitised folder name in the format ``"{title} ({year})"``. """ - import re - - # Remove all trailing year suffixes to prevent duplication. - # This handles cases where the title already contains one or more years. - # Regex pattern: matches one or more " (YYYY)" at the end of the string - clean_title = re.sub(r'(\s*\(\d{4}\))+\s*$', '', title).strip() - + clean_title = _YEAR_SUFFIX_PATTERN.sub('', title).strip() year_suffix = f" ({year})" raw_name = f"{clean_title}{year_suffix}" return sanitize_folder_name(raw_name) @@ -236,42 +240,55 @@ def _is_series_being_downloaded(series_folder: str) -> bool: """ try: download_service = get_download_service() - active = download_service._active_download # pylint: disable=protected-access + active = download_service._active_download if active and active.serie_folder == series_folder: return True - for item in download_service._pending_queue: # pylint: disable=protected-access + for item in download_service._pending_queue: if item.serie_folder == series_folder: return True return False - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: logger.warning( "Could not check download status for %s: %s", series_folder, exc ) - # Safer to skip renaming if we can't verify download status. return True -def _cleanup_stale_files_after_rename(new_path: Path, new_name: str) -> None: - """Remove legacy 'key' file after successful folder rename. - - Also checks for orphaned folders with the same key that may have been - left behind from previous rename operations. +def _remove_key_file(path: Path) -> None: + """Remove legacy 'key' file from a series folder. Args: - new_path: The new folder path after rename. - new_name: The new folder name. + path: Path to the series folder. """ - key_file = new_path / "key" + key_file = path / "key" if key_file.exists(): try: key_file.unlink() - logger.info( - "Removed legacy 'key' file after rename: %s", key_file - ) + logger.info("Removed legacy 'key' file after rename: %s", key_file) except OSError as exc: - logger.warning( - "Could not remove legacy 'key' file %s: %s", key_file, exc - ) + logger.warning("Could not remove legacy 'key' file %s: %s", key_file, exc) + + +def _move_file(item: Path, dest: Path) -> bool: + """Move a single file or directory to destination. + + Args: + item: Source path to move. + dest: Destination path. + + Returns: + True if move succeeded, False otherwise. + """ + try: + item.rename(dest) + logger.debug("Moved %s → %s", item, dest) + return True + except PermissionError as exc: + logger.warning("Permission denied moving %s: %s", item, exc) + return False + except OSError as exc: + logger.warning("OS error moving %s: %s", item, exc) + return False def _cleanup_orphaned_folder(old_path: Path, new_path: Path, dry_run: bool = False) -> bool: @@ -291,53 +308,36 @@ def _cleanup_orphaned_folder(old_path: Path, new_path: Path, dry_run: bool = Fal False if old folder does not exist or cleanup failed. """ if not old_path.exists(): - logger.debug( - "Old folder does not exist, no cleanup needed: %s", old_path - ) + logger.debug("Old folder does not exist, no cleanup needed: %s", old_path) return False - # Check if folder is empty try: contents = list(old_path.iterdir()) except PermissionError as exc: - logger.warning( - "Permission denied accessing old folder %s: %s", old_path, exc - ) + logger.warning("Permission denied accessing old folder %s: %s", old_path, exc) return False except OSError as exc: - logger.warning( - "OS error accessing old folder %s: %s", old_path, exc - ) + logger.warning("OS error accessing old folder %s: %s", old_path, exc) return False if not contents: - # Empty folder — delete it if dry_run: - logger.info( - "[DRY-RUN] Would delete empty orphaned folder: %s", old_path - ) + logger.info("[DRY-RUN] Would delete empty orphaned folder: %s", old_path) return True try: old_path.rmdir() logger.info("Deleted empty orphaned folder: %s", old_path) return True except PermissionError as exc: - logger.warning( - "Permission denied deleting folder %s: %s", old_path, exc - ) + logger.warning("Permission denied deleting folder %s: %s", old_path, exc) return False except OSError as exc: - logger.warning( - "OS error deleting folder %s: %s", old_path, exc - ) + logger.warning("OS error deleting folder %s: %s", old_path, exc) return False - # Folder has contents — move files to new_path then delete if dry_run: - logger.info( - "[DRY-RUN] Would move %d files from orphaned folder %s to %s", - len(contents), old_path, new_path - ) + logger.info("[DRY-RUN] Would move %d files from orphaned folder %s to %s", + len(contents), old_path, new_path) for item in contents: logger.info("[DRY-RUN] Would move: %s → %s", item, new_path / item.name) logger.info("[DRY-RUN] Would then delete orphaned folder: %s", old_path) @@ -346,41 +346,86 @@ def _cleanup_orphaned_folder(old_path: Path, new_path: Path, dry_run: bool = Fal files_moved = 0 errors = 0 for item in contents: - try: - dest = new_path / item.name - item.rename(dest) - logger.debug("Moved %s → %s", item, dest) + if not _move_file(item, new_path / item.name): + errors += 1 + else: files_moved += 1 - except PermissionError as exc: - logger.warning( - "Permission denied moving %s: %s", item, exc - ) - errors += 1 - except OSError as exc: - logger.warning( - "OS error moving %s: %s", item, exc - ) - errors += 1 if files_moved > 0: - logger.info( - "Moved %d files from orphaned folder to %s", - files_moved, new_path - ) + logger.info("Moved %d files from orphaned folder to %s", files_moved, new_path) - # Delete the now-empty old folder try: old_path.rmdir() logger.info("Deleted orphaned folder after moving contents: %s", old_path) return errors == 0 except OSError as exc: - logger.warning( - "Could not delete orphaned folder %s (may not be empty): %s", - old_path, exc - ) + logger.warning("Could not delete orphaned folder %s (may not be empty): %s", old_path, exc) return False +def _update_series_folder(db, series, new_folder: str) -> None: + """Update AnimeSeries.folder in the database. + + Args: + db: Database session. + series: The AnimeSeries instance to update. + new_folder: New folder name. + """ + if series is None: + return + + AnimeSeriesService.update(db, series.id, folder=new_folder) + logger.info("Updated AnimeSeries.folder: %s (id=%s)", new_folder, series.id) + + +def _update_episode_paths(episodes, old_series_path: Path, new_series_path: Path) -> None: + """Update Episode.file_path for all episodes of a series. + + Args: + episodes: List of Episode instances. + old_series_path: Path to the old series folder. + new_series_path: Path to the new series folder. + """ + for episode in episodes: + if not episode.file_path: + continue + old_file_path = Path(episode.file_path) + try: + old_file_path.relative_to(old_series_path) + new_file_path = new_series_path / old_file_path.relative_to(old_series_path) + episode.file_path = str(new_file_path) + logger.debug("Updated Episode.file_path: %s → %s", old_file_path, new_file_path) + except ValueError: + pass + + +def _update_queue_destinations( + queue_items, + series_id, + old_series_path: Path, + new_series_path: Path, +) -> None: + """Update DownloadQueueItem.file_destination for pending items. + + Args: + queue_items: List of DownloadQueueItem instances. + series_id: ID of the series to filter by. + old_series_path: Path to the old series folder. + new_series_path: Path to the new series folder. + """ + for item in queue_items: + if item.series_id != series_id or not item.file_destination: + continue + old_dest = Path(item.file_destination) + try: + old_dest.relative_to(old_series_path) + new_dest = new_series_path / old_dest.relative_to(old_series_path) + item.file_destination = str(new_dest) + logger.debug("Updated DownloadQueueItem.file_destination: %s → %s", old_dest, new_dest) + except ValueError: + pass + + async def _update_database_paths( old_folder: str, new_folder: str, @@ -402,82 +447,138 @@ async def _update_database_paths( new_series_path = anime_dir / new_folder async with get_db_session() as db: - # 1. Update AnimeSeries.folder series = await AnimeSeriesService.get_by_folder(db, old_folder) if series is None: - # Fallback: try to find by folder name all_series = await AnimeSeriesService.get_all(db) for s in all_series: if s.folder == old_folder: series = s break + _update_series_folder(db, series, new_folder) + if series is None: - logger.warning( - "No database record found for folder '%s', skipping DB update", - old_folder, - ) return - await AnimeSeriesService.update(db, series.id, folder=new_folder) - logger.info( - "Updated AnimeSeries.folder: %s → %s (id=%s)", - old_folder, - new_folder, - series.id, - ) - - # 2. Update Episode.file_path for all episodes of this series episodes = await EpisodeService.get_by_series(db, series.id) - for episode in episodes: - if episode.file_path: - old_file_path = Path(episode.file_path) - # Only update if the path is under the old series folder - try: - old_file_path.relative_to(old_series_path) - new_file_path = new_series_path / old_file_path.relative_to( - old_series_path - ) - episode.file_path = str(new_file_path) - logger.debug( - "Updated Episode.file_path: %s → %s", - old_file_path, - new_file_path, - ) - except ValueError: - # Path is not under old_series_path, skip - pass + _update_episode_paths(episodes, old_series_path, new_series_path) await db.flush() - # 3. Update DownloadQueueItem.file_destination for pending items queue_items = await DownloadQueueService.get_all(db, with_series=True) - for item in queue_items: - if item.series_id == series.id and item.file_destination: - old_dest = Path(item.file_destination) - try: - old_dest.relative_to(old_series_path) - new_dest = new_series_path / old_dest.relative_to( - old_series_path - ) - item.file_destination = str(new_dest) - logger.debug( - "Updated DownloadQueueItem.file_destination: %s → %s", - old_dest, - new_dest, - ) - except ValueError: - pass + _update_queue_destinations(queue_items, series.id, old_series_path, new_series_path) await db.flush() + logger.info("Database paths updated for series '%s' → '%s'", old_folder, new_folder) + + +def _remove_duplicate_target_folder( + series_dir: Path, + current_name: str, + expected_name: str, + expected_path: Path, +) -> bool: + """Handle the case where the target folder already exists. + + Removes the source folder and its DB record to avoid orphaning + episodes/downloads. + + Args: + series_dir: Path to the series directory being processed. + current_name: Current folder name. + expected_name: Expected folder name. + expected_path: Path to the expected (target) folder. + + Returns: + True if folder was removed successfully, False otherwise. + """ + logger.warning( + "Cannot rename '%s' → '%s' — target already exists", + current_name, + expected_name, + ) + try: + try: + contents = list(series_dir.iterdir()) + logger.warning( + "REMOVING folder '%s' with %d items — target '%s' already exists", + current_name, + len(contents), + expected_name, + ) + for item in contents: + logger.warning(" Would remove: %s", item) + except OSError as exc: + logger.warning( + "Could not list contents of folder '%s' before removal: %s", + current_name, + exc, + ) + + shutil.rmtree(series_dir) logger.info( - "Database paths updated for series '%s' → '%s'", - old_folder, - new_folder, + "Removed source folder '%s' — series already exists at target", + current_name, + ) + + # Delete source DB record using synchronous helper + _delete_series_db_record(current_name, expected_name) + + return True + except OSError as exc: + logger.error("Failed to remove source folder '%s': %s", current_name, exc) + return False + + +def _delete_series_db_record(current_name: str, expected_name: str) -> None: + """Delete the series DB record for a folder that was removed. + + Args: + current_name: The folder name to look up in the DB. + expected_name: The target folder name (for logging). + """ + try: + import asyncio + asyncio.run(_delete_series_db_record_async(current_name, expected_name)) + except Exception as exc: + logger.warning( + "Could not delete DB record for '%s': %s", + current_name, + exc, ) -async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str, int]: +async def _delete_series_db_record_async(current_name: str, expected_name: str) -> None: + """Async helper to delete series DB record. + + Args: + current_name: The folder name to look up. + expected_name: The target folder name (for logging). + """ + async with get_db_session() as db: + source_series = await AnimeSeriesService.get_by_folder(db, current_name) + if source_series is None: + all_series = await AnimeSeriesService.get_all(db) + for s in all_series: + if s.folder == current_name: + source_series = s + break + if source_series is not None: + await AnimeSeriesService.delete(db, source_series.id) + logger.info( + "Deleted source DB record for '%s' (id=%s) — target folder '%s' retains DB record", + current_name, + source_series.id, + expected_name, + ) + else: + logger.info( + "No DB record found for source folder '%s' — folder removed only", + current_name, + ) + + +async def validate_and_rename_series_folders(dry_run: bool = False) -> dict[str, int]: """Validate and rename series folders to match NFO metadata. Iterates over every subfolder in ``settings.anime_directory`` that @@ -505,25 +606,21 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str, """ if not settings.anime_directory: logger.warning("Folder rename skipped — anime directory not configured") - return {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0} + return RenameStats().to_dict() anime_dir = Path(settings.anime_directory) if not anime_dir.is_dir(): - logger.warning( - "Folder rename skipped — anime directory not found: %s", anime_dir - ) - return {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0} + logger.warning("Folder rename skipped — anime directory not found: %s", anime_dir) + return RenameStats().to_dict() if dry_run: logger.info("Running in DRY-RUN mode — no changes will be made") - stats = {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0} - - # Detect pre-existing duplicates before rename loop - pre_existing_duplicates: Set[str] = set() + stats = RenameStats() + pre_existing_duplicates: set[str] = set() duplicates = _scan_for_pre_existing_duplicates(anime_dir) + for dup_group in duplicates: - # Try automatic merge first if _try_merge_duplicate_group(dup_group, dry_run=dry_run): logger.info( "Auto-merged duplicate group for '%s' (%d folders)", @@ -531,7 +628,6 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str, dup_group.count, ) else: - # Flag all folders in this group as pre-existing duplicates for folder in dup_group.folders: pre_existing_duplicates.add(folder) logger.warning( @@ -549,7 +645,7 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str, if not nfo_path.exists(): continue - stats["scanned"] += 1 + stats.scanned += 1 title, year = _parse_nfo_title_and_year(nfo_path) if not title or not year: @@ -557,130 +653,63 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str, "Skipping rename for '%s' — missing title or year in NFO", series_dir.name, ) - stats["skipped"] += 1 + stats.skipped += 1 continue expected_name = _compute_expected_folder_name(title, year) current_name = series_dir.name if expected_name == current_name: - logger.debug( - "Folder name already correct: '%s'", current_name - ) + logger.debug("Folder name already correct: '%s'", current_name) continue - # Check for active downloads if _is_series_being_downloaded(current_name): logger.info( "Skipping rename for '%s' — series has active or pending downloads", current_name, ) - stats["skipped"] += 1 + stats.skipped += 1 continue expected_path = anime_dir / expected_name - # Check for pre-existing duplicate if current_name in pre_existing_duplicates: logger.warning( "Skipping rename for '%s' — pre-existing duplicate folder detected", current_name, ) - stats["errors"] += 1 + stats.errors += 1 continue - # Check for duplicate target if expected_path.exists(): - logger.warning( - "Cannot rename '%s' → '%s' — target already exists", - current_name, - expected_name, - ) - # Target folder exists — remove source folder and delete its DB record - # (target folder's DB record survives, source folder's record must be removed - # to avoid orphaning episodes/downloads) - try: - import shutil - - logger.warning( - "Removing source duplicate folder '%s' — target '%s' already exists", - current_name, - expected_name, - ) - shutil.rmtree(series_dir) - logger.info( - "Removed source folder '%s' — series already exists at target", - current_name, - ) - - # Delete source DB record (cascades to episodes and download items) - async with get_db_session() as db: - source_series = await AnimeSeriesService.get_by_folder(db, current_name) - if source_series is None: - # Fallback: find by folder name - all_series = await AnimeSeriesService.get_all(db) - for s in all_series: - if s.folder == current_name: - source_series = s - break - if source_series is not None: - await AnimeSeriesService.delete(db, source_series.id) - logger.info( - "Deleted source DB record for '%s' (id=%s) — target folder '%s' retains DB record", - current_name, - source_series.id, - expected_name, - ) - else: - logger.info( - "No DB record found for source folder '%s' — folder removed only", - current_name, - ) - - stats["renamed"] += 1 - except OSError as exc: - logger.error( - "Failed to remove source folder '%s': %s", - current_name, - exc, - ) - stats["errors"] += 1 + if _remove_duplicate_target_folder(series_dir, current_name, expected_name, expected_path): + stats.renamed += 1 + else: + stats.errors += 1 continue - # Check path length limits if len(str(expected_path)) > 4096: logger.warning( "Cannot rename '%s' → '%s' — path exceeds OS limit", current_name, expected_name, ) - stats["errors"] += 1 + stats.errors += 1 continue if dry_run: - logger.info( - "[DRY-RUN] Would rename folder: '%s' → '%s'", - current_name, - expected_name, - ) - stats["renamed"] += 1 + logger.info("[DRY-RUN] Would rename folder: '%s' → '%s'", current_name, expected_name) + stats.renamed += 1 continue try: old_path = series_dir series_dir.rename(expected_path) - logger.info( - "Renamed folder: '%s' → '%s'", current_name, expected_name - ) - stats["renamed"] += 1 + logger.info("Renamed folder: '%s' → '%s'", current_name, expected_name) + stats.renamed += 1 - # Update database records await _update_database_paths(current_name, expected_name, anime_dir) - - # Clean up stale/legacy files after successful rename - _cleanup_stale_files_after_rename(expected_path, expected_name) - - # Clean up orphaned folder if old path still exists + _remove_key_file(expected_path) _cleanup_orphaned_folder(old_path, expected_path, dry_run=False) except PermissionError as exc: @@ -690,7 +719,7 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str, expected_name, exc, ) - stats["errors"] += 1 + stats.errors += 1 except OSError as exc: logger.error( "OS error renaming '%s' → '%s': %s", @@ -698,13 +727,13 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str, expected_name, exc, ) - stats["errors"] += 1 + stats.errors += 1 logger.info( "Folder rename scan complete: scanned=%d, renamed=%d, skipped=%d, errors=%d", - stats["scanned"], - stats["renamed"], - stats["skipped"], - stats["errors"], + stats.scanned, + stats.renamed, + stats.skipped, + stats.errors, ) - return stats + return stats.to_dict() diff --git a/src/server/services/folder_scan_service.py b/src/server/services/scheduler/folder_scan_service.py similarity index 99% rename from src/server/services/folder_scan_service.py rename to src/server/services/scheduler/folder_scan_service.py index fc89fa0..90138dc 100644 --- a/src/server/services/folder_scan_service.py +++ b/src/server/services/scheduler/folder_scan_service.py @@ -202,7 +202,7 @@ class FolderScanService: # 1.4 — Validate and rename series folders after NFO repair. logger.info("Starting folder rename validation") - from src.server.services.folder_rename_service import ( + from src.server.services.scheduler.folder_rename_service import ( validate_and_rename_series_folders, ) diff --git a/src/server/services/key_resolution_service.py b/src/server/services/scheduler/key_resolution_service.py similarity index 100% rename from src/server/services/key_resolution_service.py rename to src/server/services/scheduler/key_resolution_service.py diff --git a/src/server/services/scheduler/rescan_orchestrator.py b/src/server/services/scheduler/rescan_orchestrator.py new file mode 100644 index 0000000..66101b1 --- /dev/null +++ b/src/server/services/scheduler/rescan_orchestrator.py @@ -0,0 +1,293 @@ +"""Rescan orchestrator — coordinates all scan/cleanup operations during a rescan. + +Extracts the rescan workflow from SchedulerService so scheduling and scan +logic are cleanly separated. + +Called by SchedulerService.trigger_rescan() and by _run_rescan_job(). +""" +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import List, Optional + +from src.server.models.config import SchedulerConfig + +logger = logging.getLogger(__name__) + + +class RescanOrchestrator: + """Coordinates rescan, auto-download, folder scan, and key resolution. + + This class encapsulates the entire post-rescan workflow so SchedulerService + only needs to call a single method. + """ + + def __init__(self, config: Optional[SchedulerConfig] = None) -> None: + """Initialize the orchestrator. + + Args: + config: Optional scheduler config. If None, operations that depend + on config flags (auto_download, folder_scan) will be skipped. + """ + self._config = config + self._last_scan_time: Optional[datetime] = None + # Cooldown tracking for auto-download to prevent rapid re-triggers + self._last_auto_download_time: Optional[datetime] = None + self._auto_download_cooldown_seconds: int = 300 # 5 minutes default + + @property + def last_scan_time(self) -> Optional[datetime]: + return self._last_scan_time + + # ------------------------------------------------------------------ + # Auto-download + # ------------------------------------------------------------------ + + async def run_auto_download(self) -> int: + """Queue and start downloads for all series with missing episodes. + + Returns: + Number of episodes queued. + """ + from datetime import timedelta + + from src.server.models.download import EpisodeIdentifier + from src.server.utils.dependencies import ( + get_anime_service, + get_download_service, + ) + + # Check cooldown to prevent rapid re-triggers + now = datetime.now(timezone.utc) + if self._last_auto_download_time is not None: + elapsed = now - self._last_auto_download_time + if elapsed < timedelta(seconds=self._auto_download_cooldown_seconds): + logger.debug( + "Auto-download skipped: cooldown active (elapsed=%.1fs cooldown=%ds)", + elapsed.total_seconds(), + self._auto_download_cooldown_seconds, + ) + return 0 + + anime_service = get_anime_service() + download_service = get_download_service() + + series_list = anime_service._cached_list_missing() + queued_count = 0 + + for series in series_list: + episode_dict: dict = series.get("episodeDict") or {} + if not episode_dict: + continue + + episodes: List[EpisodeIdentifier] = [] + for season_str, ep_numbers in episode_dict.items(): + for ep_num in ep_numbers: + episodes.append( + EpisodeIdentifier(season=int(season_str), episode=int(ep_num)) + ) + + if not episodes: + continue + + await download_service.add_to_queue( + serie_id=series.get("key", ""), + serie_folder=series.get("folder", series.get("name", "")), + serie_name=series.get("name", ""), + episodes=episodes, + ) + queued_count += len(episodes) + logger.info( + "Auto-download queued episodes for series=%s count=%d", + series.get("key"), + len(episodes), + ) + + if queued_count: + await download_service.start_queue_processing() + logger.info("Auto-download queue processing started: queued=%d", queued_count) + + self._last_auto_download_time = datetime.now(timezone.utc) + logger.info("Auto-download completed: queued_count=%d", queued_count) + return queued_count + + # ------------------------------------------------------------------ + # Folder scan + # ------------------------------------------------------------------ + + async def run_folder_scan(self) -> None: + """Run the folder scan maintenance task.""" + from src.server.services.scheduler.folder_scan_service import FolderScanService + + folder_scan_service = FolderScanService() + await folder_scan_service.run_folder_scan() + logger.info("Folder scan completed successfully") + + # ------------------------------------------------------------------ + # Key resolution + # ------------------------------------------------------------------ + + async def run_key_resolution(self) -> dict: + """Run the orphaned folder key resolution scan. + + Returns: + Dict with resolved/skipped/errors counts. + """ + from src.server.services.key_resolution_service import ( + perform_key_resolution_scan, + ) + + key_stats = await perform_key_resolution_scan() + logger.info( + "Key resolution scan completed: resolved=%d, skipped=%d, errors=%d", + key_stats["resolved"], + key_stats["skipped"], + key_stats["errors"], + ) + return key_stats + + # ------------------------------------------------------------------ + # Main orchestrator entry point + # ------------------------------------------------------------------ + + async def execute(self) -> dict: + """Execute the full rescan workflow. + + Runs in order: + 1. anime_service.rescan() + 2. auto-download (if enabled) + 3. folder scan (if enabled) + 4. key resolution scan (always, if anime_directory configured) + + Returns: + Dict with duration and counts for each step. + """ + scan_start = datetime.now(timezone.utc) + results = { + "started_at": scan_start.isoformat(), + "duration_seconds": 0.0, + "rescan_completed": False, + "auto_download_queued": 0, + "folder_scan_completed": False, + "key_resolution": {"resolved": 0, "skipped": 0, "errors": 0}, + } + + await self._broadcast( + "scheduled_rescan_started", + {"timestamp": scan_start.isoformat()}, + ) + + try: + # 1. Main library rescan + await self._run_rescan() + results["rescan_completed"] = True + + # 2. Auto-download + if self._config and self._config.auto_download_after_rescan: + try: + queued = await self.run_auto_download() + results["auto_download_queued"] = queued + await self._broadcast( + "auto_download_started", {"queued_count": queued} + ) + except Exception as exc: + logger.error("Auto-download failed: %s", exc, exc_info=True) + await self._broadcast( + "auto_download_error", {"error": str(exc)} + ) + + # 3. Folder scan + if self._config and self._config.folder_scan_enabled: + try: + await self.run_folder_scan() + results["folder_scan_completed"] = True + except Exception as exc: + logger.error("Folder scan failed: %s", exc, exc_info=True) + await self._broadcast("folder_scan_error", {"error": str(exc)}) + + # 4. Key resolution scan (always runs if anime_directory configured) + try: + key_stats = await self.run_key_resolution() + results["key_resolution"] = key_stats + except Exception as exc: + logger.error("Key resolution scan failed: %s", exc, exc_info=True) + + self._last_scan_time = datetime.now(timezone.utc) + results["duration_seconds"] = ( + self._last_scan_time - scan_start + ).total_seconds() + + await self._broadcast( + "scheduled_rescan_completed", + { + "timestamp": self._last_scan_time.isoformat(), + "duration_seconds": results["duration_seconds"], + }, + ) + + logger.info( + "Scheduled library rescan completed: duration=%.2fs", + results["duration_seconds"], + ) + + except Exception as exc: + logger.error("Scheduled rescan failed: %s", exc, exc_info=True) + await self._broadcast( + "scheduled_rescan_error", + {"error": str(exc), "timestamp": datetime.now(timezone.utc).isoformat()}, + ) + raise + + return results + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + async def _run_rescan(self) -> None: + """Run the anime service rescan.""" + from src.server.utils.dependencies import get_anime_service + + anime_service = get_anime_service() + logger.info("Anime service obtained, calling anime_service.rescan()...") + await anime_service.rescan() + logger.info("anime_service.rescan() completed") + + async def _broadcast(self, event_type: str, data: dict) -> None: + """Broadcast a WebSocket event to all connected clients.""" + try: + from src.server.services.websocket_service import get_websocket_service + + ws_service = get_websocket_service() + await ws_service.manager.broadcast({"type": event_type, "data": data}) + except Exception as exc: + logger.warning( + "WebSocket broadcast failed: event=%s error=%s", event_type, exc + ) + + +# --------------------------------------------------------------------------- +# Module-level orchestrator +# --------------------------------------------------------------------------- + +_orchestrator: Optional[RescanOrchestrator] = None + + +def get_rescan_orchestrator( + config: Optional[SchedulerConfig] = None, +) -> RescanOrchestrator: + """Return a RescanOrchestrator singleton (or create with optional config).""" + global _orchestrator + if _orchestrator is None or config is not None: + _orchestrator = RescanOrchestrator(config=config) + logger.debug("Created new RescanOrchestrator singleton") + else: + logger.debug("Returning existing RescanOrchestrator singleton") + return _orchestrator + + +def reset_rescan_orchestrator() -> None: + """Reset the orchestrator singleton (used in tests).""" + global _orchestrator + _orchestrator = None \ No newline at end of file diff --git a/src/server/services/scheduler_service.py b/src/server/services/scheduler/scheduler_service.py similarity index 63% rename from src/server/services/scheduler_service.py rename to src/server/services/scheduler/scheduler_service.py index fc34cbd..02b9646 100644 --- a/src/server/services/scheduler_service.py +++ b/src/server/services/scheduler/scheduler_service.py @@ -1,18 +1,19 @@ """Scheduler service for automatic library rescans. Uses APScheduler's AsyncIOScheduler with CronTrigger for precise -cron-based scheduling. The legacy interval-based loop has been removed -in favour of the cron approach. +cron-based scheduling. Jobs are held in memory (no separate scheduler database). On startup, if the last scan timestamp indicates a missed run (server was down at the scheduled cron time), a rescan is triggered immediately. + +Actual rescan logic is delegated to RescanService. """ from __future__ import annotations import logging from datetime import datetime, timedelta, timezone -from typing import List, Optional +from typing import Optional from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger @@ -42,7 +43,9 @@ class SchedulerService: - Cron-based scheduling (time of day + days of week) - Immediate manual trigger - Live config reloading without app restart - - Auto-queueing downloads of missing episodes after rescan + + Actual rescan/folder-scan/auto-download work is delegated to + RescanService. """ def __init__(self) -> None: @@ -50,11 +53,7 @@ class SchedulerService: self._is_running: bool = False self._scheduler: Optional[AsyncIOScheduler] = None self._config: Optional[SchedulerConfig] = None - self._last_scan_time: Optional[datetime] = None self._scan_in_progress: bool = False - # Cooldown tracking for auto-download to prevent rapid re-triggers - self._last_auto_download_time: Optional[datetime] = None - self._auto_download_cooldown_seconds: int = 300 # 5 minutes default logger.info("SchedulerService initialised") # ------------------------------------------------------------------ @@ -82,8 +81,6 @@ class SchedulerService: logger.error("Failed to load scheduler configuration: %s", exc) raise SchedulerServiceError(f"Failed to load config: {exc}") from exc - # Use in-memory job store — no separate scheduler.db needed. - # Jobs are reconstructed from config on every startup. self._scheduler = AsyncIOScheduler() if not self._config.enabled: @@ -133,8 +130,7 @@ class SchedulerService: ) # Startup misfire recovery: check if the last scan was missed while - # the server was down. If overdue by more than one interval but within - # the grace period, trigger an immediate rescan. + # the server was down. await self._check_missed_run() async def stop(self) -> None: @@ -251,6 +247,10 @@ class SchedulerService: Returns: Dict containing scheduler state and config fields. """ + from src.server.services.rescan_service import get_rescan_service + + rescan_service = get_rescan_service() + next_run: Optional[str] = None if self._scheduler and self._scheduler.running: job = self._scheduler.get_job(_JOB_ID) @@ -269,7 +269,11 @@ class SchedulerService: "folder_scan_enabled": ( self._config.folder_scan_enabled if self._config else False ), - "last_run": self._last_scan_time.isoformat() if self._last_scan_time else None, + "last_run": ( + rescan_service.last_scan_time.isoformat() + if rescan_service.last_scan_time + else None + ), "next_run": next_run, "scan_in_progress": self._scan_in_progress, } @@ -316,9 +320,9 @@ class SchedulerService: return try: - from src.server.database.connection import get_db_session # noqa: PLC0415 + from src.server.database.connection import get_db_session from src.server.database.system_settings_service import ( - SystemSettingsService, # noqa: PLC0415 + SystemSettingsService, ) async with get_db_session() as db: @@ -341,7 +345,6 @@ class SchedulerService: # If last scan was more than 24h + grace period ago, don't trigger # (avoids surprise rescans after long downtime). max_overdue = timedelta(hours=24, seconds=_MISFIRE_GRACE_SECONDS) - # If last scan was more than ~25h ago, skip (too stale) if elapsed > max_overdue: logger.info( "Last scan was %s ago (> %s) — skipping missed-run recovery", @@ -351,7 +354,6 @@ class SchedulerService: return # Check if a run should have happened between last_scan and now. - # Simple heuristic: if elapsed > 24h, we missed at least one daily run. if elapsed > timedelta(hours=23): logger.info( "Missed scheduled rescan detected (last scan %s ago) — triggering now", @@ -362,191 +364,22 @@ class SchedulerService: except Exception as exc: # pylint: disable=broad-exception-caught logger.warning("Missed-run check failed (non-fatal): %s", exc) - async def _broadcast(self, event_type: str, data: dict) -> None: - """Broadcast a WebSocket event to all connected clients.""" - try: - from src.server.services.websocket_service import ( - get_websocket_service, # noqa: PLC0415 - ) - - ws_service = get_websocket_service() - await ws_service.manager.broadcast({"type": event_type, "data": data}) - except Exception as exc: # pylint: disable=broad-exception-caught - logger.warning("WebSocket broadcast failed: event=%s error=%s", event_type, exc) - - async def _auto_download_missing(self) -> None: - """Queue and start downloads for all series with missing episodes.""" - from datetime import timedelta # noqa: PLC0415 - - from src.server.models.download import EpisodeIdentifier # noqa: PLC0415 - from src.server.utils.dependencies import ( # noqa: PLC0415 - get_anime_service, - get_download_service, - ) - - # Check cooldown to prevent rapid re-triggers - now = datetime.now(timezone.utc) - if self._last_auto_download_time is not None: - elapsed = now - self._last_auto_download_time - if elapsed < timedelta(seconds=self._auto_download_cooldown_seconds): - logger.debug( - "Auto-download skipped: cooldown active (elapsed=%.1fs cooldown=%ds)", - elapsed.total_seconds(), - self._auto_download_cooldown_seconds, - ) - return - - anime_service = get_anime_service() - download_service = get_download_service() - - series_list = anime_service._cached_list_missing() - queued_count = 0 - - for series in series_list: - episode_dict: dict = series.get("episodeDict") or {} - if not episode_dict: - continue - - episodes: List[EpisodeIdentifier] = [] - for season_str, ep_numbers in episode_dict.items(): - for ep_num in ep_numbers: - episodes.append( - EpisodeIdentifier(season=int(season_str), episode=int(ep_num)) - ) - - if not episodes: - continue - - await download_service.add_to_queue( - serie_id=series.get("key", ""), - serie_folder=series.get("folder", series.get("name", "")), - serie_name=series.get("name", ""), - episodes=episodes, - ) - queued_count += len(episodes) - logger.info( - "Auto-download queued episodes for series=%s count=%d", - series.get("key"), - len(episodes), - ) - - if queued_count: - await download_service.start_queue_processing() - logger.info("Auto-download queue processing started: queued=%d", queued_count) - - await self._broadcast("auto_download_started", {"queued_count": queued_count}) - logger.info("Auto-download completed: queued_count=%d", queued_count) - - # Update cooldown timestamp after successful auto-download - self._last_auto_download_time = datetime.now(timezone.utc) - async def _perform_rescan(self) -> None: - """Execute a library rescan and optionally trigger auto-download.""" - logger.info("Scheduler _perform_rescan entered: scan_in_progress=%s", self._scan_in_progress) + """Execute a library rescan via RescanService.""" + from src.server.services.rescan_service import get_rescan_service + + logger.info( + "Scheduler _perform_rescan entered: scan_in_progress=%s", + self._scan_in_progress, + ) if self._scan_in_progress: logger.warning("Skipping rescan: previous scan still in progress") return self._scan_in_progress = True - scan_start = datetime.now(timezone.utc) - logger.info("Scheduled rescan started at %s", scan_start.isoformat()) - try: - logger.info("Starting scheduled library rescan") - - from src.server.utils.dependencies import get_anime_service # noqa: PLC0415 - - anime_service = get_anime_service() - logger.info("Anime service obtained for rescan") - - await self._broadcast( - "scheduled_rescan_started", - {"timestamp": scan_start.isoformat()}, - ) - - logger.info("Calling anime_service.rescan()...") - await anime_service.rescan() - - self._last_scan_time = datetime.now(timezone.utc) - duration = (self._last_scan_time - scan_start).total_seconds() - - logger.info("Scheduled library rescan completed: duration=%.2fs", duration) - - await self._broadcast( - "scheduled_rescan_completed", - { - "timestamp": self._last_scan_time.isoformat(), - "duration_seconds": duration, - }, - ) - - # Auto-download after rescan - if self._config and self._config.auto_download_after_rescan: - logger.info("Auto-download after rescan is enabled — starting") - try: - await self._auto_download_missing() - except Exception as dl_exc: # pylint: disable=broad-exception-caught - logger.error( - "Auto-download after rescan failed: %s", - dl_exc, - exc_info=True, - ) - await self._broadcast( - "auto_download_error", {"error": str(dl_exc)} - ) - else: - logger.debug("Auto-download after rescan is disabled — skipping") - - # Folder scan (daily maintenance) - if self._config and self._config.folder_scan_enabled: - logger.info("Folder scan is enabled — starting") - try: - from src.server.services.folder_scan_service import ( - FolderScanService, # noqa: PLC0415 - ) - - folder_scan_service = FolderScanService() - await folder_scan_service.run_folder_scan() - logger.info("Folder scan completed successfully") - except Exception as fs_exc: # pylint: disable=broad-exception-caught - logger.error( - "Folder scan failed: %s", - fs_exc, - exc_info=True, - ) - await self._broadcast( - "folder_scan_error", {"error": str(fs_exc)} - ) - - # Key resolution scan (resolve orphaned folders) - try: - from src.server.services.key_resolution_service import ( - perform_key_resolution_scan, # noqa: PLC0415 - ) - - key_stats = await perform_key_resolution_scan() - logger.info( - "Key resolution scan completed: resolved=%d, skipped=%d, errors=%d", - key_stats["resolved"], - key_stats["skipped"], - key_stats["errors"], - ) - except Exception as kr_exc: # pylint: disable=broad-exception-caught - logger.error( - "Key resolution scan failed: %s", - kr_exc, - exc_info=True, - ) - else: - logger.debug("Folder scan is disabled — skipping") - - except Exception as exc: # pylint: disable=broad-exception-caught - logger.error("Scheduled rescan failed: %s", exc, exc_info=True) - await self._broadcast( - "scheduled_rescan_error", - {"error": str(exc), "timestamp": datetime.now(timezone.utc).isoformat()}, - ) - + rescan_service = get_rescan_service(config=self._config) + await rescan_service.execute() finally: self._scan_in_progress = False logger.info("Scheduled rescan finished: scan_in_progress reset to False") diff --git a/tests/api/test_config_endpoints.py b/tests/api/test_config_endpoints.py index ffaea57..68faf8e 100644 --- a/tests/api/test_config_endpoints.py +++ b/tests/api/test_config_endpoints.py @@ -216,7 +216,7 @@ async def test_update_config_with_anime_directory_starts_scheduler( """PUT /api/config with anime_directory syncs and starts scheduler.""" mock_scheduler = AsyncMock() - with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_sched_fn: + with patch("src.server.services.scheduler.scheduler_service.get_scheduler_service") as mock_sched_fn: mock_sched_fn.return_value = mock_scheduler with patch("src.config.settings.settings") as mock_settings: @@ -238,7 +238,7 @@ async def test_update_config_without_anime_directory_does_not_start_scheduler( """PUT /api/config without new anime_directory does not call scheduler.ensure_started().""" mock_scheduler = AsyncMock() - with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_sched_fn: + with patch("src.server.services.scheduler.scheduler_service.get_scheduler_service") as mock_sched_fn: mock_sched_fn.return_value = mock_scheduler with patch("src.config.settings.settings") as mock_settings: diff --git a/tests/integration/test_folder_rename_startup.py b/tests/integration/test_folder_rename_startup.py index 22f8b3a..9f72538 100644 --- a/tests/integration/test_folder_rename_startup.py +++ b/tests/integration/test_folder_rename_startup.py @@ -17,7 +17,7 @@ class TestFolderRenameScanCalledInFolderScan: import importlib source = importlib.util.find_spec( - "src.server.services.folder_scan_service" + "src.server.services.scheduler.folder_scan_service" ).origin with open(source, "r", encoding="utf-8") as fh: content = fh.read() @@ -31,7 +31,7 @@ class TestFolderRenameScanCalledInFolderScan: import importlib source = importlib.util.find_spec( - "src.server.services.folder_scan_service" + "src.server.services.scheduler.folder_scan_service" ).origin with open(source, "r", encoding="utf-8") as fh: content = fh.read() @@ -52,7 +52,7 @@ class TestFolderRenameIntegration: @pytest.mark.asyncio async def test_folder_rename_runs_during_scan(self, tmp_path): """When folder_scan_enabled is true, the scan renames mismatched folders.""" - from src.server.services.folder_scan_service import FolderScanService + from src.server.services.scheduler.folder_scan_service import FolderScanService anime_dir = tmp_path / "anime" anime_dir.mkdir() @@ -69,15 +69,15 @@ class TestFolderRenameIntegration: with patch( "src.config.settings.settings", mock_settings ), patch( - "src.server.services.folder_rename_service.settings", mock_settings + "src.server.services.scheduler.folder_rename_service.settings", mock_settings ), patch( - "src.server.services.folder_scan_service.perform_nfo_repair_scan", + "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ), patch( - "src.server.services.folder_rename_service._is_series_being_downloaded", + "src.server.services.scheduler.folder_rename_service._is_series_being_downloaded", return_value=False, ), patch( - "src.server.services.folder_rename_service._update_database_paths", + "src.server.services.scheduler.folder_rename_service._update_database_paths", new_callable=AsyncMock, ): service = FolderScanService() @@ -89,7 +89,7 @@ class TestFolderRenameIntegration: @pytest.mark.asyncio async def test_folder_rename_skipped_when_prerequisites_not_met(self, tmp_path): """If anime directory is missing, rename logic is skipped gracefully.""" - from src.server.services.folder_scan_service import FolderScanService + from src.server.services.scheduler.folder_scan_service import FolderScanService mock_settings = MagicMock() mock_settings.tmdb_api_key = "test-key" @@ -98,10 +98,10 @@ class TestFolderRenameIntegration: with patch( "src.config.settings.settings", mock_settings ), patch( - "src.server.services.folder_scan_service.perform_nfo_repair_scan", + "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ), patch( - "src.server.services.folder_rename_service.validate_and_rename_series_folders" + "src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders" ) as mock_rename: service = FolderScanService() await service.run_folder_scan() diff --git a/tests/integration/test_nfo_repair_startup.py b/tests/integration/test_nfo_repair_startup.py index 52de91f..56d0301 100644 --- a/tests/integration/test_nfo_repair_startup.py +++ b/tests/integration/test_nfo_repair_startup.py @@ -34,7 +34,7 @@ class TestNfoRepairScanCalledInFolderScan: """folder_scan_service.py imports perform_nfo_repair_scan.""" import importlib - source = importlib.util.find_spec("src.server.services.folder_scan_service").origin + source = importlib.util.find_spec("src.server.services.scheduler.folder_scan_service").origin with open(source, "r", encoding="utf-8") as fh: content = fh.read() @@ -46,7 +46,7 @@ class TestNfoRepairScanCalledInFolderScan: """perform_nfo_repair_scan must be called inside run_folder_scan.""" import importlib - source = importlib.util.find_spec("src.server.services.folder_scan_service").origin + source = importlib.util.find_spec("src.server.services.scheduler.folder_scan_service").origin with open(source, "r", encoding="utf-8") as fh: content = fh.read() @@ -67,7 +67,9 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader: @pytest.mark.asyncio async def test_incomplete_nfo_series_scheduled_for_repair(self, tmp_path): """Series whose tvshow.nfo is missing required tags are scheduled via asyncio.create_task.""" - from src.server.services.folder_scan_service import perform_nfo_repair_scan + from src.server.services.scheduler.folder_scan_service import ( + perform_nfo_repair_scan, + ) series_dir = tmp_path / "IncompleteAnime" series_dir.mkdir() @@ -83,7 +85,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader: mock_repair_service.repair_series = AsyncMock(return_value=True) with patch( - "src.server.services.folder_scan_service._settings", mock_settings + "src.server.services.scheduler.folder_scan_service._settings", mock_settings ), patch( "src.core.services.nfo_repair_service.nfo_needs_repair", return_value=True, @@ -103,7 +105,9 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader: @pytest.mark.asyncio async def test_complete_nfo_series_not_scheduled(self, tmp_path): """Series whose tvshow.nfo has all required tags are not scheduled for repair.""" - from src.server.services.folder_scan_service import perform_nfo_repair_scan + from src.server.services.scheduler.folder_scan_service import ( + perform_nfo_repair_scan, + ) series_dir = tmp_path / "CompleteAnime" series_dir.mkdir() @@ -116,7 +120,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader: mock_settings.anime_directory = str(tmp_path) with patch( - "src.server.services.folder_scan_service._settings", mock_settings + "src.server.services.scheduler.folder_scan_service._settings", mock_settings ), patch( "src.core.services.nfo_repair_service.nfo_needs_repair", return_value=False, diff --git a/tests/integration/test_poster_check_startup.py b/tests/integration/test_poster_check_startup.py index cee148e..1139b4d 100644 --- a/tests/integration/test_poster_check_startup.py +++ b/tests/integration/test_poster_check_startup.py @@ -19,7 +19,7 @@ class TestPosterCheckScanCalledInFolderScan: import importlib source = importlib.util.find_spec( - "src.server.services.folder_scan_service" + "src.server.services.scheduler.folder_scan_service" ).origin with open(source, "r", encoding="utf-8") as fh: content = fh.read() @@ -33,7 +33,7 @@ class TestPosterCheckScanCalledInFolderScan: import importlib source = importlib.util.find_spec( - "src.server.services.folder_scan_service" + "src.server.services.scheduler.folder_scan_service" ).origin with open(source, "r", encoding="utf-8") as fh: content = fh.read() @@ -54,7 +54,7 @@ class TestPosterCheckIntegration: @pytest.mark.asyncio async def test_poster_check_downloads_missing_poster(self, tmp_path): """When poster.jpg is missing, the scan downloads it from the NFO thumb URL.""" - from src.server.services.folder_scan_service import FolderScanService + from src.server.services.scheduler.folder_scan_service import FolderScanService anime_dir = tmp_path / "anime" anime_dir.mkdir() @@ -91,14 +91,14 @@ class TestPosterCheckIntegration: with patch( "src.config.settings.settings", mock_settings ), patch( - "src.server.services.folder_scan_service.perform_nfo_repair_scan", + "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ), patch( - "src.server.services.folder_rename_service.validate_and_rename_series_folders", + "src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, ), patch( - "src.server.services.folder_scan_service.ImageDownloader", + "src.server.services.scheduler.folder_scan_service.ImageDownloader", new=MockDownloader, ): service = FolderScanService() @@ -112,7 +112,7 @@ class TestPosterCheckIntegration: @pytest.mark.asyncio async def test_poster_check_skips_valid_poster(self, tmp_path): """When poster.jpg exists and is large enough, the scan skips it.""" - from src.server.services.folder_scan_service import FolderScanService + from src.server.services.scheduler.folder_scan_service import FolderScanService anime_dir = tmp_path / "anime" anime_dir.mkdir() @@ -136,14 +136,14 @@ class TestPosterCheckIntegration: with patch( "src.config.settings.settings", mock_settings ), patch( - "src.server.services.folder_scan_service.perform_nfo_repair_scan", + "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ), patch( - "src.server.services.folder_rename_service.validate_and_rename_series_folders", + "src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, ), patch( - "src.server.services.folder_scan_service.ImageDownloader" + "src.server.services.scheduler.folder_scan_service.ImageDownloader" ) as mock_downloader_cls: service = FolderScanService() await service.run_folder_scan() @@ -153,7 +153,7 @@ class TestPosterCheckIntegration: @pytest.mark.asyncio async def test_poster_check_skips_when_no_thumb_url(self, tmp_path): """When NFO has no thumb URL, the scan skips the folder.""" - from src.server.services.folder_scan_service import FolderScanService + from src.server.services.scheduler.folder_scan_service import FolderScanService anime_dir = tmp_path / "anime" anime_dir.mkdir() @@ -173,14 +173,14 @@ class TestPosterCheckIntegration: with patch( "src.config.settings.settings", mock_settings ), patch( - "src.server.services.folder_scan_service.perform_nfo_repair_scan", + "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ), patch( - "src.server.services.folder_rename_service.validate_and_rename_series_folders", + "src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, ), patch( - "src.server.services.folder_scan_service.ImageDownloader" + "src.server.services.scheduler.folder_scan_service.ImageDownloader" ) as mock_downloader_cls: service = FolderScanService() await service.run_folder_scan() @@ -190,7 +190,7 @@ class TestPosterCheckIntegration: @pytest.mark.asyncio async def test_poster_check_skipped_when_prerequisites_not_met(self, tmp_path): """If anime directory is missing, poster check logic is skipped gracefully.""" - from src.server.services.folder_scan_service import FolderScanService + from src.server.services.scheduler.folder_scan_service import FolderScanService mock_settings = MagicMock() mock_settings.tmdb_api_key = "test-key" @@ -199,12 +199,12 @@ class TestPosterCheckIntegration: with patch( "src.config.settings.settings", mock_settings ), patch( - "src.server.services.folder_scan_service.perform_nfo_repair_scan", + "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ), patch( - "src.server.services.folder_rename_service.validate_and_rename_series_folders" + "src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders" ) as mock_rename, patch( - "src.server.services.folder_scan_service.ImageDownloader" + "src.server.services.scheduler.folder_scan_service.ImageDownloader" ) as mock_downloader_cls: service = FolderScanService() await service.run_folder_scan() @@ -220,7 +220,7 @@ class TestPosterCheckSemaphore: import importlib source = importlib.util.find_spec( - "src.server.services.folder_scan_service" + "src.server.services.scheduler.folder_scan_service" ).origin with open(source, "r", encoding="utf-8") as fh: content = fh.read() @@ -232,7 +232,7 @@ class TestPosterCheckSemaphore: @pytest.mark.asyncio async def test_poster_download_uses_semaphore(self, tmp_path): """Poster downloads are gated by the semaphore.""" - from src.server.services.folder_scan_service import ( + from src.server.services.scheduler.folder_scan_service import ( _POSTER_DOWNLOAD_SEMAPHORE, FolderScanService, ) @@ -270,14 +270,14 @@ class TestPosterCheckSemaphore: with patch( "src.config.settings.settings", mock_settings ), patch( - "src.server.services.folder_scan_service.perform_nfo_repair_scan", + "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ), patch( - "src.server.services.folder_rename_service.validate_and_rename_series_folders", + "src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, ), patch( - "src.server.services.folder_scan_service.ImageDownloader" + "src.server.services.scheduler.folder_scan_service.ImageDownloader" ) as mock_downloader_cls: mock_downloader = AsyncMock() mock_downloader.download_poster = AsyncMock(side_effect=tracked_download) diff --git a/tests/integration/test_scheduler_workflow.py b/tests/integration/test_scheduler_workflow.py index cda0ccc..942fab3 100644 --- a/tests/integration/test_scheduler_workflow.py +++ b/tests/integration/test_scheduler_workflow.py @@ -11,15 +11,14 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from src.server.models.config import AppConfig, SchedulerConfig -from src.server.services.scheduler_service import ( +from src.server.services.scheduler.scheduler_service import ( + _JOB_ID, SchedulerService, SchedulerServiceError, - _JOB_ID, get_scheduler_service, reset_scheduler_service, ) - # --------------------------------------------------------------------------- # Shared fixtures # --------------------------------------------------------------------------- @@ -27,7 +26,7 @@ from src.server.services.scheduler_service import ( @pytest.fixture def mock_config_service(): """Patch get_config_service used by SchedulerService.start().""" - with patch("src.server.services.scheduler_service.get_config_service") as mock: + with patch("src.server.services.scheduler.scheduler_service.get_config_service") as mock: config_service = Mock() app_config = AppConfig( scheduler=SchedulerConfig( diff --git a/tests/unit/test_concurrent_scans.py b/tests/unit/test_concurrent_scans.py index 390f551..ed26c2d 100644 --- a/tests/unit/test_concurrent_scans.py +++ b/tests/unit/test_concurrent_scans.py @@ -474,7 +474,7 @@ class TestSchedulerConcurrentScanPrevention: @pytest.mark.asyncio async def test_scheduler_skips_rescan_if_already_running(self): """Test scheduler skips scheduled rescan if one is already running.""" - from src.server.services.scheduler_service import SchedulerService + from src.server.services.scheduler.scheduler_service import SchedulerService scheduler = SchedulerService() @@ -495,7 +495,7 @@ class TestSchedulerConcurrentScanPrevention: @pytest.mark.asyncio async def test_scheduler_sets_flag_during_rescan(self): """Test that scheduler properly sets scan_in_progress flag.""" - from src.server.services.scheduler_service import SchedulerService + from src.server.services.scheduler.scheduler_service import SchedulerService scheduler = SchedulerService() diff --git a/tests/unit/test_ffmpeg_health_check.py b/tests/unit/test_ffmpeg_health_check.py index 77644e2..787f0b1 100644 --- a/tests/unit/test_ffmpeg_health_check.py +++ b/tests/unit/test_ffmpeg_health_check.py @@ -26,7 +26,7 @@ class TestFfmpegHealthCheck: with patch("src.server.utils.dependencies.get_anime_service"): with patch("src.server.utils.dependencies.get_download_service"): with patch("src.server.utils.dependencies.get_background_loader_service"): - with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_get_sched: + with patch("src.server.services.scheduler.scheduler_service.get_scheduler_service") as mock_get_sched: mock_sched = MagicMock() mock_sched.start = AsyncMock(return_value=None) mock_get_sched.return_value = mock_sched @@ -64,7 +64,7 @@ class TestFfmpegHealthCheck: with patch("src.server.utils.dependencies.get_anime_service"): with patch("src.server.utils.dependencies.get_download_service"): with patch("src.server.utils.dependencies.get_background_loader_service"): - with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_get_sched: + with patch("src.server.services.scheduler.scheduler_service.get_scheduler_service") as mock_get_sched: mock_sched = MagicMock() mock_sched.start = AsyncMock(return_value=None) mock_get_sched.return_value = mock_sched diff --git a/tests/unit/test_folder_rename_service.py b/tests/unit/test_folder_rename_service.py index ad17a40..910a2fb 100644 --- a/tests/unit/test_folder_rename_service.py +++ b/tests/unit/test_folder_rename_service.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from src.server.services.folder_rename_service import ( +from src.server.services.scheduler.folder_rename_service import ( _cleanup_orphaned_folder, _compute_expected_folder_name, _is_series_being_downloaded, @@ -163,7 +163,7 @@ class TestIsSeriesBeingDownloaded: mock_service._active_download = None mock_service._pending_queue = [] with patch( - "src.server.services.folder_rename_service.get_download_service", + "src.server.services.scheduler.folder_rename_service.get_download_service", return_value=mock_service, ): assert _is_series_being_downloaded("Some Show") is False @@ -175,7 +175,7 @@ class TestIsSeriesBeingDownloaded: mock_service._active_download = mock_item mock_service._pending_queue = [] with patch( - "src.server.services.folder_rename_service.get_download_service", + "src.server.services.scheduler.folder_rename_service.get_download_service", return_value=mock_service, ): assert _is_series_being_downloaded("Some Show") is True @@ -187,14 +187,14 @@ class TestIsSeriesBeingDownloaded: mock_service._active_download = None mock_service._pending_queue = [mock_item] with patch( - "src.server.services.folder_rename_service.get_download_service", + "src.server.services.scheduler.folder_rename_service.get_download_service", return_value=mock_service, ): assert _is_series_being_downloaded("Some Show") is True def test_exception_returns_true_for_safety(self) -> None: with patch( - "src.server.services.folder_rename_service.get_download_service", + "src.server.services.scheduler.folder_rename_service.get_download_service", side_effect=RuntimeError("boom"), ): assert _is_series_being_downloaded("Some Show") is True @@ -213,13 +213,13 @@ class TestUpdateDatabasePaths: mock_series.folder = "Old Name" with patch( - "src.server.services.folder_rename_service.get_db_session" + "src.server.services.scheduler.folder_rename_service.get_db_session" ) as mock_get_db, patch( - "src.server.services.folder_rename_service.AnimeSeriesService" + "src.server.services.scheduler.folder_rename_service.AnimeSeriesService" ) as mock_series_svc, patch( - "src.server.services.folder_rename_service.EpisodeService" + "src.server.services.scheduler.folder_rename_service.EpisodeService" ) as mock_episode_svc, patch( - "src.server.services.folder_rename_service.DownloadQueueService" + "src.server.services.scheduler.folder_rename_service.DownloadQueueService" ) as mock_queue_svc: mock_db = AsyncMock() @@ -254,13 +254,13 @@ class TestUpdateDatabasePaths: mock_episode.file_path = str(old_path) with patch( - "src.server.services.folder_rename_service.get_db_session" + "src.server.services.scheduler.folder_rename_service.get_db_session" ) as mock_get_db, patch( - "src.server.services.folder_rename_service.AnimeSeriesService" + "src.server.services.scheduler.folder_rename_service.AnimeSeriesService" ) as mock_series_svc, patch( - "src.server.services.folder_rename_service.EpisodeService" + "src.server.services.scheduler.folder_rename_service.EpisodeService" ) as mock_episode_svc, patch( - "src.server.services.folder_rename_service.DownloadQueueService" + "src.server.services.scheduler.folder_rename_service.DownloadQueueService" ) as mock_queue_svc: mock_db = AsyncMock() @@ -350,7 +350,7 @@ class TestValidateAndRenameSeriesFolders: @pytest.mark.asyncio async def test_no_anime_directory(self) -> None: with patch( - "src.server.services.folder_rename_service.settings.anime_directory", + "src.server.services.scheduler.folder_rename_service.settings.anime_directory", "", ): stats = await validate_and_rename_series_folders() @@ -367,13 +367,13 @@ class TestValidateAndRenameSeriesFolders: ) with patch( - "src.server.services.folder_rename_service.settings.anime_directory", + "src.server.services.scheduler.folder_rename_service.settings.anime_directory", str(anime_dir), ), patch( - "src.server.services.folder_rename_service._is_series_being_downloaded", + "src.server.services.scheduler.folder_rename_service._is_series_being_downloaded", return_value=False, ), patch( - "src.server.services.folder_rename_service._update_database_paths", + "src.server.services.scheduler.folder_rename_service._update_database_paths", new_callable=AsyncMock, ) as mock_update_db: stats = await validate_and_rename_series_folders() @@ -397,7 +397,7 @@ class TestValidateAndRenameSeriesFolders: ) with patch( - "src.server.services.folder_rename_service.settings.anime_directory", + "src.server.services.scheduler.folder_rename_service.settings.anime_directory", str(anime_dir), ): stats = await validate_and_rename_series_folders() @@ -419,7 +419,7 @@ class TestValidateAndRenameSeriesFolders: ) with patch( - "src.server.services.folder_rename_service.settings.anime_directory", + "src.server.services.scheduler.folder_rename_service.settings.anime_directory", str(anime_dir), ): stats = await validate_and_rename_series_folders() @@ -440,10 +440,10 @@ class TestValidateAndRenameSeriesFolders: ) with patch( - "src.server.services.folder_rename_service.settings.anime_directory", + "src.server.services.scheduler.folder_rename_service.settings.anime_directory", str(anime_dir), ), patch( - "src.server.services.folder_rename_service._is_series_being_downloaded", + "src.server.services.scheduler.folder_rename_service._is_series_being_downloaded", return_value=True, ): stats = await validate_and_rename_series_folders() @@ -474,20 +474,20 @@ class TestValidateAndRenameSeriesFolders: mock_db.__aexit__.return_value = None with patch( - "src.server.services.folder_rename_service.settings.anime_directory", + "src.server.services.scheduler.folder_rename_service.settings.anime_directory", str(anime_dir), ), patch( - "src.server.services.folder_rename_service._is_series_being_downloaded", + "src.server.services.scheduler.folder_rename_service._is_series_being_downloaded", return_value=False, ), patch( - "src.server.services.folder_rename_service.get_db_session", + "src.server.services.scheduler.folder_rename_service.get_db_session", return_value=mock_db, ), patch( - "src.server.services.folder_rename_service.AnimeSeriesService.get_by_key", + "src.server.services.scheduler.folder_rename_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=None, ), patch( - "src.server.services.folder_rename_service.AnimeSeriesService.get_all", + "src.server.services.scheduler.folder_rename_service.AnimeSeriesService.get_all", new_callable=AsyncMock, return_value=[], ): @@ -527,13 +527,13 @@ class TestValidateAndRenameSeriesFolders: (d3 / "tvshow.nfo").write_text("Show C") with patch( - "src.server.services.folder_rename_service.settings.anime_directory", + "src.server.services.scheduler.folder_rename_service.settings.anime_directory", str(anime_dir), ), patch( - "src.server.services.folder_rename_service._is_series_being_downloaded", + "src.server.services.scheduler.folder_rename_service._is_series_being_downloaded", return_value=False, ), patch( - "src.server.services.folder_rename_service._update_database_paths", + "src.server.services.scheduler.folder_rename_service._update_database_paths", new_callable=AsyncMock, ): stats = await validate_and_rename_series_folders() @@ -558,10 +558,10 @@ class TestValidateAndRenameSeriesFolders: ) with patch( - "src.server.services.folder_rename_service.settings.anime_directory", + "src.server.services.scheduler.folder_rename_service.settings.anime_directory", str(anime_dir), ), patch( - "src.server.services.folder_rename_service._is_series_being_downloaded", + "src.server.services.scheduler.folder_rename_service._is_series_being_downloaded", return_value=False, ): stats = await validate_and_rename_series_folders(dry_run=True) diff --git a/tests/unit/test_folder_scan_service.py b/tests/unit/test_folder_scan_service.py index e691404..e31c61d 100644 --- a/tests/unit/test_folder_scan_service.py +++ b/tests/unit/test_folder_scan_service.py @@ -15,7 +15,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest -from src.server.services.folder_scan_service import ( +from src.server.services.scheduler.folder_scan_service import ( _POSTER_DOWNLOAD_SEMAPHORE, _TMDB_SEMAPHORE, FolderScanService, @@ -97,7 +97,7 @@ class TestRunFolderScanPrerequisites: with patch.object( folder_scan_service, "_prerequisites_met", return_value=False ), patch( - "src.server.services.folder_scan_service.perform_nfo_repair_scan" + "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan" ) as mock_repair: await folder_scan_service.run_folder_scan() mock_repair.assert_not_called() @@ -108,10 +108,10 @@ class TestRunFolderScanPrerequisites: with patch.object( folder_scan_service, "_prerequisites_met", return_value=True ), patch( - "src.server.services.folder_scan_service.perform_nfo_repair_scan", + "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ), patch( - "src.server.services.folder_rename_service.validate_and_rename_series_folders", + "src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, ), patch.object( @@ -148,10 +148,10 @@ class TestNfoRepairIntegration: with patch.object( folder_scan_service, "_prerequisites_met", return_value=True ), patch( - "src.server.services.folder_scan_service.perform_nfo_repair_scan", + "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ) as mock_repair, patch( - "src.server.services.folder_rename_service.validate_and_rename_series_folders", + "src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, ), patch.object( @@ -172,11 +172,11 @@ class TestNfoRepairIntegration: with patch.object( folder_scan_service, "_prerequisites_met", return_value=True ), patch( - "src.server.services.folder_scan_service.perform_nfo_repair_scan", + "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, side_effect=RuntimeError("repair failed"), ) as mock_repair, patch( - "src.server.services.folder_rename_service.validate_and_rename_series_folders", + "src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, ) as mock_rename, patch.object( @@ -204,10 +204,10 @@ class TestFolderRenameIntegration: with patch.object( folder_scan_service, "_prerequisites_met", return_value=True ), patch( - "src.server.services.folder_scan_service.perform_nfo_repair_scan", + "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ), patch( - "src.server.services.folder_rename_service.validate_and_rename_series_folders", + "src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 5, "renamed": 2, "skipped": 2, "errors": 1}, ) as mock_rename, patch.object( @@ -228,10 +228,10 @@ class TestFolderRenameIntegration: with patch.object( folder_scan_service, "_prerequisites_met", return_value=True ), patch( - "src.server.services.folder_scan_service.perform_nfo_repair_scan", + "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ), patch( - "src.server.services.folder_rename_service.validate_and_rename_series_folders", + "src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, side_effect=RuntimeError("rename failed"), ), patch.object( @@ -344,7 +344,7 @@ class TestPosterCheck: mock_settings.nfo_download_poster = True with patch( - "src.server.services.folder_scan_service.ImageDownloader", + "src.server.services.scheduler.folder_scan_service.ImageDownloader", return_value=mock_downloader, ): stats = await folder_scan_service.check_and_download_missing_posters() @@ -423,7 +423,7 @@ class TestPosterCheck: mock_settings.nfo_download_poster = True with patch( - "src.server.services.folder_scan_service.ImageDownloader", + "src.server.services.scheduler.folder_scan_service.ImageDownloader", return_value=mock_downloader, ): stats = await folder_scan_service.check_and_download_missing_posters() @@ -456,7 +456,7 @@ class TestPosterCheck: mock_settings.nfo_download_poster = True with patch( - "src.server.services.folder_scan_service.ImageDownloader", + "src.server.services.scheduler.folder_scan_service.ImageDownloader", return_value=mock_downloader, ): stats = await folder_scan_service.check_and_download_missing_posters() @@ -491,7 +491,7 @@ class TestPosterCheck: mock_settings.nfo_download_poster = True with patch( - "src.server.services.folder_scan_service.ImageDownloader", + "src.server.services.scheduler.folder_scan_service.ImageDownloader", return_value=mock_downloader, ): stats = await folder_scan_service.check_and_download_missing_posters() @@ -569,10 +569,10 @@ class TestRunFolderScanFull: with patch.object( folder_scan_service, "_prerequisites_met", return_value=True ), patch( - "src.server.services.folder_scan_service.perform_nfo_repair_scan", + "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ) as mock_repair, patch( - "src.server.services.folder_rename_service.validate_and_rename_series_folders", + "src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 3, "renamed": 1, "skipped": 1, "errors": 1}, ) as mock_rename, patch.object( @@ -593,10 +593,10 @@ class TestRunFolderScanFull: with patch.object( folder_scan_service, "_prerequisites_met", return_value=True ), patch( - "src.server.services.folder_scan_service.perform_nfo_repair_scan", + "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ), patch( - "src.server.services.folder_rename_service.validate_and_rename_series_folders", + "src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, ), patch.object( diff --git a/tests/unit/test_initialization_service.py b/tests/unit/test_initialization_service.py index 4f4479d..175c8f0 100644 --- a/tests/unit/test_initialization_service.py +++ b/tests/unit/test_initialization_service.py @@ -10,7 +10,6 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import pytest -from src.server.services.folder_scan_service import perform_nfo_repair_scan from src.server.services.initialization_service import ( _check_initial_scan_status, _check_media_scan_status, @@ -30,6 +29,7 @@ from src.server.services.initialization_service import ( perform_media_scan_if_needed, perform_nfo_scan_if_needed, ) +from src.server.services.scheduler.folder_scan_service import perform_nfo_repair_scan class TestCheckScanStatus: @@ -771,7 +771,7 @@ class TestPerformNfoRepairScan: mock_settings.anime_directory = str(tmp_path) with patch( - "src.server.services.folder_scan_service._settings", mock_settings + "src.server.services.scheduler.folder_scan_service._settings", mock_settings ): await perform_nfo_repair_scan() @@ -785,7 +785,7 @@ class TestPerformNfoRepairScan: mock_settings.anime_directory = "" with patch( - "src.server.services.folder_scan_service._settings", mock_settings + "src.server.services.scheduler.folder_scan_service._settings", mock_settings ): await perform_nfo_repair_scan() @@ -805,7 +805,7 @@ class TestPerformNfoRepairScan: mock_repair_service.repair_series = AsyncMock(return_value=True) with patch( - "src.server.services.folder_scan_service._settings", mock_settings + "src.server.services.scheduler.folder_scan_service._settings", mock_settings ), patch( "src.core.services.nfo_repair_service.nfo_needs_repair", return_value=True, @@ -838,7 +838,7 @@ class TestPerformNfoRepairScan: mock_settings.anime_directory = str(tmp_path) with patch( - "src.server.services.folder_scan_service._settings", mock_settings + "src.server.services.scheduler.folder_scan_service._settings", mock_settings ), patch( "src.core.services.nfo_repair_service.nfo_needs_repair", return_value=False, @@ -868,7 +868,7 @@ class TestPerformNfoRepairScan: mock_repair_service.repair_series = AsyncMock(return_value=True) with patch( - "src.server.services.folder_scan_service._settings", mock_settings + "src.server.services.scheduler.folder_scan_service._settings", mock_settings ), patch( "src.core.services.nfo_repair_service.nfo_needs_repair", return_value=True, diff --git a/tests/unit/test_key_resolution_service.py b/tests/unit/test_key_resolution_service.py index 1515546..315a5e8 100644 --- a/tests/unit/test_key_resolution_service.py +++ b/tests/unit/test_key_resolution_service.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from src.server.services.key_resolution_service import ( +from src.server.services.scheduler.key_resolution_service import ( _extract_key_from_link, _extract_year_from_folder, _normalize_for_comparison, @@ -104,7 +104,7 @@ class TestResolveKeyForFolder: ] with patch( - "src.server.services.key_resolution_service._search_provider", + "src.server.services.scheduler.key_resolution_service._search_provider", return_value=search_results, ): key = await resolve_key_for_folder("Rent-A-Girlfriend (2020)") @@ -114,7 +114,7 @@ class TestResolveKeyForFolder: async def test_no_results_returns_none(self): """When provider returns no results, returns None.""" with patch( - "src.server.services.key_resolution_service._search_provider", + "src.server.services.scheduler.key_resolution_service._search_provider", return_value=[], ): key = await resolve_key_for_folder("Unknown Anime (2020)") @@ -129,7 +129,7 @@ class TestResolveKeyForFolder: ] with patch( - "src.server.services.key_resolution_service._search_provider", + "src.server.services.scheduler.key_resolution_service._search_provider", return_value=search_results, ): key = await resolve_key_for_folder("My Anime (2022)") @@ -144,7 +144,7 @@ class TestResolveKeyForFolder: ] with patch( - "src.server.services.key_resolution_service._search_provider", + "src.server.services.scheduler.key_resolution_service._search_provider", return_value=search_results, ): key = await resolve_key_for_folder("Rent-A-Girlfriend (2020)") @@ -158,7 +158,7 @@ class TestResolveKeyForFolder: ] with patch( - "src.server.services.key_resolution_service._search_provider", + "src.server.services.scheduler.key_resolution_service._search_provider", return_value=search_results, ): key = await resolve_key_for_folder("Naruto (2002)") @@ -168,7 +168,7 @@ class TestResolveKeyForFolder: async def test_provider_error_returns_none(self): """When provider search raises an exception, returns None gracefully.""" with patch( - "src.server.services.key_resolution_service._search_provider", + "src.server.services.scheduler.key_resolution_service._search_provider", side_effect=RuntimeError("Network error"), ): key = await resolve_key_for_folder("Some Anime (2020)") @@ -182,7 +182,7 @@ class TestResolveKeyForFolder: ] with patch( - "src.server.services.key_resolution_service._search_provider", + "src.server.services.scheduler.key_resolution_service._search_provider", return_value=search_results, ): key = await resolve_key_for_folder("One Piece (1999)") @@ -196,7 +196,7 @@ class TestResolveKeyForFolder: ] with patch( - "src.server.services.key_resolution_service._search_provider", + "src.server.services.scheduler.key_resolution_service._search_provider", return_value=search_results, ): key = await resolve_key_for_folder("Naruto") @@ -211,7 +211,7 @@ class TestResolveKeyForFolder: ] with patch( - "src.server.services.key_resolution_service._search_provider", + "src.server.services.scheduler.key_resolution_service._search_provider", return_value=search_results, ): key = await resolve_key_for_folder("Dororo (2019)") diff --git a/tests/unit/test_scheduler_service.py b/tests/unit/test_scheduler_service.py index 5d5cff9..28d491c 100644 --- a/tests/unit/test_scheduler_service.py +++ b/tests/unit/test_scheduler_service.py @@ -15,7 +15,7 @@ import pytest from apscheduler.triggers.cron import CronTrigger from src.server.models.config import AppConfig, SchedulerConfig -from src.server.services.scheduler_service import ( +from src.server.services.scheduler.scheduler_service import ( _JOB_ID, SchedulerService, SchedulerServiceError, @@ -36,7 +36,7 @@ def _make_app_config(**scheduler_kwargs) -> AppConfig: @pytest.fixture def mock_config_service(): - with patch("src.server.services.scheduler_service.get_config_service") as mock: + with patch("src.server.services.scheduler.scheduler_service.get_config_service") as mock: svc = Mock() svc.load_config.return_value = _make_app_config( enabled=True, @@ -105,7 +105,7 @@ class TestStart: self, scheduler_service, mock_config_service ): with patch( - "src.server.services.scheduler_service.AsyncIOScheduler" + "src.server.services.scheduler.scheduler_service.AsyncIOScheduler" ) as MockScheduler: mock_sched = MagicMock() mock_sched.running = False @@ -137,9 +137,9 @@ class TestStartEmptyDays: @pytest.mark.asyncio async def test_no_job_added_when_days_empty(self, scheduler_service): with patch( - "src.server.services.scheduler_service.get_config_service" + "src.server.services.scheduler.scheduler_service.get_config_service" ) as mock_cs, patch( - "src.server.services.scheduler_service.AsyncIOScheduler" + "src.server.services.scheduler.scheduler_service.AsyncIOScheduler" ) as MockScheduler: svc = Mock() svc.load_config.return_value = _make_app_config( @@ -409,7 +409,7 @@ class TestPerformRescanFolderScan: with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \ patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \ - patch("src.server.services.folder_scan_service.FolderScanService") as MockFSS: + patch("src.server.services.scheduler.folder_scan_service.FolderScanService") as MockFSS: MockFSS.return_value.run_folder_scan = mock_folder_scan await scheduler_service._perform_rescan() @@ -434,7 +434,7 @@ class TestPerformRescanFolderScan: with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \ patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \ - patch("src.server.services.folder_scan_service.FolderScanService") as MockFSS: + patch("src.server.services.scheduler.folder_scan_service.FolderScanService") as MockFSS: MockFSS.return_value.run_folder_scan = mock_folder_scan await scheduler_service._perform_rescan() @@ -459,7 +459,7 @@ class TestPerformRescanFolderScan: with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \ patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \ - patch("src.server.services.folder_scan_service.FolderScanService") as MockFSS: + patch("src.server.services.scheduler.folder_scan_service.FolderScanService") as MockFSS: MockFSS.return_value.run_folder_scan = mock_folder_scan # Should NOT raise await scheduler_service._perform_rescan() @@ -498,7 +498,7 @@ class TestInMemoryJobStore: self, scheduler_service, mock_config_service ): with patch( - "src.server.services.scheduler_service.AsyncIOScheduler" + "src.server.services.scheduler.scheduler_service.AsyncIOScheduler" ) as MockScheduler: mock_sched = MagicMock() mock_sched.running = False @@ -517,7 +517,7 @@ class TestInMemoryJobStore: self, scheduler_service, mock_config_service ): with patch( - "src.server.services.scheduler_service.AsyncIOScheduler" + "src.server.services.scheduler.scheduler_service.AsyncIOScheduler" ) as MockScheduler: mock_sched = MagicMock() mock_sched.running = False @@ -540,7 +540,7 @@ class TestStartupRecovery: self, scheduler_service, mock_config_service ): with patch( - "src.server.services.scheduler_service.AsyncIOScheduler" + "src.server.services.scheduler.scheduler_service.AsyncIOScheduler" ) as MockScheduler: mock_job = MagicMock() next_run_dt = datetime(2026, 5, 25, 3, 0, tzinfo=timezone.utc) @@ -551,7 +551,7 @@ class TestStartupRecovery: MockScheduler.return_value = mock_sched with patch( - "src.server.services.scheduler_service.logger" + "src.server.services.scheduler.scheduler_service.logger" ) as mock_logger: await scheduler_service.start() info_calls = [str(c) for c in mock_logger.info.call_args_list]