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
This commit is contained in:
2026-06-03 20:58:30 +02:00
parent 9d64241230
commit 9c3f03d610
25 changed files with 1080 additions and 578 deletions

View File

@@ -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",
]

View File

@@ -0,0 +1,739 @@
"""Folder rename service for validating and renaming series folders.
After NFO repair, this service iterates over every subfolder in
``settings.anime_directory`` that contains a ``tvshow.nfo``. For each
folder it parses the NFO to extract ``<title>`` and ``<year>``, computes
the expected folder name ``f"{title} ({year})"``, sanitises it for
filesystem safety, and renames the folder if the current name differs.
Database records (``AnimeSeries.folder``, ``Episode.file_path``,
``DownloadQueueItem.file_destination``) are updated atomically to
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 Optional
from lxml import etree
from src.config.settings import settings
from src.server.database.connection import get_db_session
from src.server.database.service import (
AnimeSeriesService,
DownloadQueueService,
EpisodeService,
)
from src.server.utils.dependencies import get_download_service
from src.server.utils.filesystem import sanitize_folder_name
logger = logging.getLogger(__name__)
# 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.
Attributes:
key: The series key (folder name before rename).
folders: List of folder paths that map to this series.
nfo_paths: List of corresponding NFO file paths.
"""
key: str
folders: list[str]
nfo_paths: list[Path]
@property
def count(self) -> int:
return len(self.folders)
def __repr__(self) -> str:
return f"DuplicateGroup(key={self.key!r}, folders={self.folders})"
@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.
Folders with the same title+year (same expected name) are flagged as duplicates.
Args:
anime_dir: Path to the anime directory to scan.
Returns:
List of DuplicateGroup objects, one per series with duplicate folders.
"""
groups: dict[str, list[tuple[str, Path]]] = defaultdict(list)
for series_dir in anime_dir.iterdir():
if not series_dir.is_dir():
continue
nfo_path = series_dir / "tvshow.nfo"
if not nfo_path.exists():
continue
title, year = _parse_nfo_title_and_year(nfo_path)
if not title or not year:
continue
expected_name = _compute_expected_folder_name(title, year)
groups[expected_name].append((series_dir.name, nfo_path))
duplicates = []
for key, items in groups.items():
if len(items) > 1:
folders = [item[0] for item in items]
nfo_paths = [item[1] for item in items]
duplicates.append(DuplicateGroup(key=key, folders=folders, nfo_paths=nfo_paths))
return duplicates
def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) -> bool:
"""Attempt to merge a duplicate group automatically.
Uses the first folder as the canonical one and removes others if they are
empty or contain only symlinks.
Args:
group: The DuplicateGroup to merge.
dry_run: If True, only log actions without executing them.
Returns:
True if merge was successful, False otherwise.
"""
if len(group.folders) < 2:
return True
canonical = group.folders[0]
to_remove = group.folders[1:]
for folder in to_remove:
folder_path = group.nfo_paths[0].parent.parent / folder
if not folder_path.exists():
continue
try:
contents = list(folder_path.iterdir())
except PermissionError:
logger.warning("Permission denied accessing %s, skip merge", folder_path)
return False
except OSError:
return False
if not contents:
if dry_run:
logger.info("[DRY-RUN] Would delete empty duplicate folder: %s", folder_path)
else:
try:
folder_path.rmdir()
logger.info("Deleted empty duplicate folder: %s", folder_path)
except OSError:
return False
continue
canonical_path = folder_path.parent / canonical
all_symlinks = all(
item.is_symlink() and item.resolve() == canonical_path.resolve()
for item in contents
)
if all_symlinks:
if dry_run:
logger.info("[DRY-RUN] Would remove symlinks in duplicate folder: %s", folder_path)
else:
for item in contents:
item.unlink()
try:
folder_path.rmdir()
logger.info("Removed symlink-only duplicate folder: %s", folder_path)
except OSError:
return False
continue
logger.warning(
"Cannot auto-merge duplicate folders for '%s': %s (manual merge required)",
group.key,
[canonical] + to_remove,
)
return False
return True
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:
nfo_path: Absolute path to the ``tvshow.nfo`` file.
Returns:
Tuple of (title, year) where either may be ``None`` if missing
or empty.
"""
try:
tree = etree.parse(str(nfo_path))
root = tree.getroot()
title_elem = root.find("./title")
year_elem = root.find("./year")
title = title_elem.text.strip() if title_elem is not None and title_elem.text and title_elem.text.strip() else None
year = year_elem.text.strip() if year_elem is not None and year_elem.text and year_elem.text.strip() else None
return title, year
except etree.XMLSyntaxError as exc:
logger.warning("Malformed XML in %s: %s", nfo_path, exc)
return None, None
except Exception as exc:
logger.warning("Unexpected error parsing %s: %s", nfo_path, exc)
return None, None
def _compute_expected_folder_name(title: str, year: str) -> str:
"""Compute the expected folder name from title and year.
Removes any existing year suffixes (e.g., "(2021)") before adding the
canonical one to prevent duplication across multiple folder rename runs.
Args:
title: Series title from NFO.
year: Release year from NFO.
Returns:
Sanitised folder name in the format ``"{title} ({year})"``.
"""
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)
def _is_series_being_downloaded(series_folder: str) -> bool:
"""Check whether the given series has an active or pending download.
Args:
series_folder: The series folder name (as stored in the DB).
Returns:
``True`` if the series appears in the active download or the
pending queue.
"""
try:
download_service = get_download_service()
active = download_service._active_download
if active and active.serie_folder == series_folder:
return True
for item in download_service._pending_queue:
if item.serie_folder == series_folder:
return True
return False
except Exception as exc:
logger.warning(
"Could not check download status for %s: %s", series_folder, exc
)
return True
def _remove_key_file(path: Path) -> None:
"""Remove legacy 'key' file from a series folder.
Args:
path: Path to the series folder.
"""
key_file = path / "key"
if key_file.exists():
try:
key_file.unlink()
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)
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:
"""Clean up orphaned folder after successful rename.
After a folder is successfully renamed to new_path, this function checks
if the old_path still exists (orphaned folder) and removes it. If the
old folder contains files, they are moved to new_path before deletion.
Args:
old_path: The original folder path before rename.
new_path: The new folder path after rename.
dry_run: If True, only log actions without executing them.
Returns:
True if old folder was cleaned up (or would be in dry-run mode),
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)
return False
try:
contents = list(old_path.iterdir())
except PermissionError as 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)
return False
if not contents:
if dry_run:
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)
return False
except OSError as exc:
logger.warning("OS error deleting folder %s: %s", old_path, exc)
return False
if dry_run:
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)
return True
files_moved = 0
errors = 0
for item in contents:
if not _move_file(item, new_path / item.name):
errors += 1
else:
files_moved += 1
if files_moved > 0:
logger.info("Moved %d files from orphaned folder to %s", files_moved, new_path)
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)
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,
anime_dir: Path,
) -> None:
"""Update all database records that reference the old folder path.
Updates:
- ``AnimeSeries.folder`` → ``new_folder``
- ``Episode.file_path`` → adjusted to new folder
- ``DownloadQueueItem.file_destination`` → adjusted to new folder
Args:
old_folder: Previous folder name.
new_folder: New folder name.
anime_dir: Root anime directory path.
"""
old_series_path = anime_dir / old_folder
new_series_path = anime_dir / new_folder
async with get_db_session() as db:
series = await AnimeSeriesService.get_by_folder(db, old_folder)
if series is None:
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:
return
episodes = await EpisodeService.get_by_series(db, series.id)
_update_episode_paths(episodes, old_series_path, new_series_path)
await db.flush()
queue_items = await DownloadQueueService.get_all(db, with_series=True)
_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(
"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 _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
contains a ``tvshow.nfo``. For each folder:
1. Parse the NFO to extract ``<title>`` and ``<year>``.
2. Compute the expected folder name: ``f"{title} ({year})"``.
3. Sanitise the expected name for filesystem safety.
4. Compare with the current folder name.
5. If different, rename the folder and update the database.
Skips folders where title or year is missing/empty. Logs every
rename action.
Args:
dry_run: If True, simulate rename operations without actually
moving folders or updating the database.
Returns:
Dictionary with counts:
- ``"scanned"``: total folders scanned
- ``"renamed"``: folders renamed
- ``"skipped"``: folders skipped (missing title/year)
- ``"errors"``: folders that caused an error
"""
if not settings.anime_directory:
logger.warning("Folder rename skipped — anime directory not configured")
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 RenameStats().to_dict()
if dry_run:
logger.info("Running in DRY-RUN mode — no changes will be made")
stats = RenameStats()
pre_existing_duplicates: set[str] = set()
duplicates = _scan_for_pre_existing_duplicates(anime_dir)
for dup_group in duplicates:
if _try_merge_duplicate_group(dup_group, dry_run=dry_run):
logger.info(
"Auto-merged duplicate group for '%s' (%d folders)",
dup_group.key,
dup_group.count,
)
else:
for folder in dup_group.folders:
pre_existing_duplicates.add(folder)
logger.warning(
"Duplicate folders detected for series '%s': %s"
"manual cleanup required (different releases or non-empty duplicates)",
dup_group.key,
dup_group.folders,
)
for series_dir in sorted(anime_dir.iterdir()):
if not series_dir.is_dir():
continue
nfo_path = series_dir / "tvshow.nfo"
if not nfo_path.exists():
continue
stats.scanned += 1
title, year = _parse_nfo_title_and_year(nfo_path)
if not title or not year:
logger.info(
"Skipping rename for '%s' — missing title or year in NFO",
series_dir.name,
)
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)
continue
if _is_series_being_downloaded(current_name):
logger.info(
"Skipping rename for '%s' — series has active or pending downloads",
current_name,
)
stats.skipped += 1
continue
expected_path = anime_dir / expected_name
if current_name in pre_existing_duplicates:
logger.warning(
"Skipping rename for '%s' — pre-existing duplicate folder detected",
current_name,
)
stats.errors += 1
continue
if expected_path.exists():
if _remove_duplicate_target_folder(series_dir, current_name, expected_name, expected_path):
stats.renamed += 1
else:
stats.errors += 1
continue
if len(str(expected_path)) > 4096:
logger.warning(
"Cannot rename '%s''%s' — path exceeds OS limit",
current_name,
expected_name,
)
stats.errors += 1
continue
if dry_run:
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
await _update_database_paths(current_name, expected_name, anime_dir)
_remove_key_file(expected_path)
_cleanup_orphaned_folder(old_path, expected_path, dry_run=False)
except PermissionError as exc:
logger.error(
"Permission denied renaming '%s''%s': %s",
current_name,
expected_name,
exc,
)
stats.errors += 1
except OSError as exc:
logger.error(
"OS error renaming '%s''%s': %s",
current_name,
expected_name,
exc,
)
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,
)
return stats.to_dict()

View File

@@ -0,0 +1,428 @@
"""Folder scan service for daily maintenance tasks.
Encapsulates the daily folder-scan logic (orphaned-file detection,
metadata refresh, and missing-episode queuing) so that the scheduler
remains clean and the scan can be tested independently.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Optional
import structlog
from lxml import etree
from src.config.settings import settings as _settings
from src.core.utils.image_downloader import ImageDownloader
logger = structlog.get_logger(__name__)
# Module-level semaphore to limit concurrent TMDB operations to 3.
_TMDB_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
# Semaphore to limit concurrent poster image downloads to 3.
_POSTER_DOWNLOAD_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
# Semaphore to limit concurrent NFO repair TMDB operations to 3.
_NFO_REPAIR_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
async def _create_missing_nfo(series_dir: Path, series_name: str) -> None:
"""Create minimal NFO for series without one.
Creates a fresh :class:`NFOService` per invocation so concurrent
tasks cannot interfere with each other.
A module-level semaphore limits concurrent TMDB operations to 3.
Args:
series_dir: Absolute path to the series folder.
series_name: Human-readable series name for log messages.
"""
from src.core.services.nfo_factory import NFOServiceFactory
async with _NFO_REPAIR_SEMAPHORE:
try:
factory = NFOServiceFactory()
nfo_service = factory.create()
await nfo_service.create_minimal_nfo(
serie_name=series_name,
serie_folder=series_dir.name,
)
except Exception as exc: # pylint: disable=broad-except
logger.error(
"NFO creation failed for %s: %s",
series_name,
exc,
)
async def _repair_one_series(series_dir: Path, series_name: str) -> None:
"""Repair a single series NFO in isolation.
Creates a fresh :class:`NFOService` and :class:`NfoRepairService` per
invocation so that each repair owns its own ``aiohttp`` session/connector
and concurrent tasks cannot interfere with each other.
A module-level semaphore (``_NFO_REPAIR_SEMAPHORE``) limits the number of
simultaneous TMDB requests to avoid rate-limiting.
Any exception is caught and logged so the asyncio task never silently
drops an unhandled error.
Args:
series_dir: Absolute path to the series folder.
series_name: Human-readable series name for log messages.
"""
from src.core.services.nfo_factory import NFOServiceFactory
from src.core.services.nfo_repair_service import NfoRepairService
async with _NFO_REPAIR_SEMAPHORE:
try:
factory = NFOServiceFactory()
nfo_service = factory.create()
repair_service = NfoRepairService(nfo_service)
await repair_service.repair_series(series_dir, series_name)
except Exception as exc: # pylint: disable=broad-except
logger.error(
"NFO repair failed for %s: %s",
series_name,
exc,
)
async def perform_nfo_repair_scan(background_loader=None) -> None:
"""Scan all series folders, repair incomplete and create missing NFO files.
Called from ``FolderScanService.run_folder_scan()`` during the scheduled
daily folder scan (not on every startup). Checks each subfolder of
``settings.anime_directory`` for a ``tvshow.nfo``:
- Missing NFOs: creates minimal NFO via ``_create_missing_nfo``
- Incomplete NFOs: repairs via ``_repair_one_series``
Each repair task creates its own isolated :class:`NFOService` /
:class:`TMDBClient` so concurrent tasks never share an ``aiohttp``
session — this prevents "Connector is closed" errors when many repairs
run in parallel. A semaphore caps TMDB concurrency at 3 to stay within
rate limits.
The ``background_loader`` parameter is accepted for backwards-compatibility
but is no longer used.
Args:
background_loader: Unused. Kept to avoid breaking call-sites.
"""
from src.core.services.nfo_repair_service import nfo_needs_repair
if not _settings.tmdb_api_key:
logger.warning("NFO repair scan skipped — TMDB API key not configured")
return
if not _settings.anime_directory:
logger.warning("NFO repair scan skipped — anime directory not configured")
return
anime_dir = Path(_settings.anime_directory)
if not anime_dir.is_dir():
logger.warning("NFO repair scan skipped — anime directory not found: %s", anime_dir)
return
queued = 0
total = 0
missing_nfo_count = 0
repair_tasks: list[asyncio.Task] = []
for series_dir in sorted(anime_dir.iterdir()):
if not series_dir.is_dir():
continue
nfo_path = series_dir / "tvshow.nfo"
series_name = series_dir.name
if not nfo_path.exists():
# Create minimal NFO for series without one
missing_nfo_count += 1
repair_tasks.append(
asyncio.create_task(
_create_missing_nfo(series_dir, series_name),
name=f"nfo_create:{series_name}",
)
)
continue
total += 1
if nfo_needs_repair(nfo_path):
queued += 1
repair_tasks.append(
asyncio.create_task(
_repair_one_series(series_dir, series_name),
name=f"nfo_repair:{series_name}",
)
)
if repair_tasks:
logger.info(
"NFO repair scan: waiting for %d repair/create tasks to complete",
len(repair_tasks),
)
await asyncio.gather(*repair_tasks, return_exceptions=True)
logger.info("NFO repair scan tasks completed")
logger.info(
"NFO repair scan complete: %d of %d series queued for repair, %d missing NFOs queued for creation",
queued,
total,
missing_nfo_count,
)
class FolderScanServiceError(Exception):
"""Service-level exception for folder-scan operations."""
class FolderScanService:
"""Performs daily maintenance scans over the anime library folder.
The service is intentionally stateless; a new instance can be created
for every scheduled invocation or test case.
"""
async def run_folder_scan(self) -> None:
"""Execute the daily folder scan.
Checks prerequisites, logs progress, and delegates to sub-task
helpers. Any unhandled exception is caught and logged so the
scheduler task never crashes.
"""
logger.info("Folder scan started")
try:
if not self._prerequisites_met():
return
# 1.3 — Repair incomplete NFO files (synchronous, waits for completion).
logger.info("Starting NFO repair scan as part of folder scan")
await perform_nfo_repair_scan(background_loader=None)
logger.info("NFO repair scan complete")
# 1.4 — Validate and rename series folders after NFO repair.
logger.info("Starting folder rename validation")
from src.server.services.scheduler.folder_rename_service import (
validate_and_rename_series_folders,
)
rename_stats = await validate_and_rename_series_folders()
logger.info(
"Folder rename validation complete",
scanned=rename_stats["scanned"],
renamed=rename_stats["renamed"],
skipped=rename_stats["skipped"],
errors=rename_stats["errors"],
)
# 1.5 — Check and download missing poster.jpg files.
logger.info("Starting poster check")
poster_stats = await self.check_and_download_missing_posters()
logger.info(
"Poster check complete",
scanned=poster_stats["scanned"],
downloaded=poster_stats["downloaded"],
skipped=poster_stats["skipped"],
errors=poster_stats["errors"],
)
logger.info("Folder scan completed")
except Exception as exc: # pylint: disable=broad-exception-caught
logger.error("Folder scan failed", error=str(exc), exc_info=True)
# ------------------------------------------------------------------
# Poster check helpers
# ------------------------------------------------------------------
async def check_and_download_missing_posters(self) -> dict[str, int]:
"""Iterate over series folders and download missing poster.jpg files.
For each folder containing a ``tvshow.nfo``:
1. Check if ``poster.jpg`` exists and is at least
:attr:`ImageDownloader.min_file_size` bytes.
2. If missing or too small, parse ``tvshow.nfo`` for a ``<thumb>``
URL (preferring ``aspect="poster"``).
3. Download the image via :class:`ImageDownloader` under a
semaphore that limits concurrency to 3.
Returns:
Dictionary with counts:
- ``"scanned"``: total folders scanned
- ``"downloaded"``: posters successfully downloaded
- ``"skipped"``: folders skipped (no NFO, no thumb URL,
or poster already valid)
- ``"errors"``: folders that caused a download error
"""
from src.config.settings import settings # noqa: PLC0415
stats = {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
if not settings.anime_directory:
logger.warning("Poster check skipped — anime directory not configured")
return stats
anime_dir = Path(settings.anime_directory)
if not anime_dir.is_dir():
logger.warning(
"Poster check skipped — anime directory not found: %s", anime_dir
)
return stats
# Gather all series directories that contain a tvshow.nfo
series_dirs = [
d for d in anime_dir.iterdir()
if d.is_dir() and (d / "tvshow.nfo").exists()
]
if not series_dirs:
logger.debug("No series folders found for poster check")
return stats
# Process each series folder concurrently with semaphore
tasks = [
self._check_and_download_poster(series_dir, stats)
for series_dir in series_dirs
]
await asyncio.gather(*tasks, return_exceptions=True)
return stats
async def _check_and_download_poster(
self, series_dir: Path, stats: dict[str, int]
) -> None:
"""Check and download poster for a single series folder.
Args:
series_dir: Path to the series folder.
stats: Mutable stats dictionary to update.
"""
stats["scanned"] += 1
poster_path = series_dir / "poster.jpg"
# Check if poster already exists and is large enough
if poster_path.exists():
try:
# Default min_file_size from ImageDownloader is 1024 bytes (1 KB)
if poster_path.stat().st_size >= 1024:
logger.debug(
"Poster already valid for '%s'", series_dir.name
)
stats["skipped"] += 1
return
except OSError:
pass # Fall through to re-download
# Parse NFO for thumb URL
nfo_path = series_dir / "tvshow.nfo"
poster_url = self._extract_poster_url_from_nfo(nfo_path)
if not poster_url:
logger.info(
"No poster URL found in NFO for '%s', skipping",
series_dir.name,
)
stats["skipped"] += 1
return
# Respect the nfo_download_poster setting
from src.config.settings import settings as app_settings # noqa: PLC0415
if not app_settings.nfo_download_poster:
logger.debug(
"Poster download disabled by nfo_download_poster setting for '%s'",
series_dir.name,
)
stats["skipped"] += 1
return
# Download poster with semaphore
async with _POSTER_DOWNLOAD_SEMAPHORE:
try:
async with ImageDownloader() as downloader:
success = await downloader.download_poster(
poster_url, series_dir, skip_existing=False
)
if success:
logger.info(
"Downloaded poster for '%s'", series_dir.name
)
stats["downloaded"] += 1
else:
logger.warning(
"Failed to download poster for '%s'", series_dir.name
)
stats["errors"] += 1
except Exception as exc: # pylint: disable=broad-except
logger.error(
"Error downloading poster for '%s': %s",
series_dir.name,
exc,
)
stats["errors"] += 1
@staticmethod
def _extract_poster_url_from_nfo(nfo_path: Path) -> Optional[str]:
"""Parse tvshow.nfo and extract the poster thumb URL.
Prefers ``<thumb aspect="poster">``; falls back to the first
``<thumb>`` element if no aspect attribute is present.
Args:
nfo_path: Absolute path to the ``tvshow.nfo`` file.
Returns:
The poster URL string, or ``None`` if not found.
"""
if not nfo_path.exists():
return None
try:
tree = etree.parse(str(nfo_path))
root = tree.getroot()
# Prefer thumb with aspect="poster"
for thumb in root.findall(".//thumb"):
if thumb.get("aspect") == "poster" and thumb.text:
return thumb.text.strip()
# Fallback to first thumb with text
for thumb in root.findall(".//thumb"):
if thumb.text:
return thumb.text.strip()
return None
except etree.XMLSyntaxError:
logger.warning("Malformed XML in %s", nfo_path)
return None
except Exception: # pylint: disable=broad-except
return None
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _prerequisites_met(self) -> bool:
"""Verify that the environment is ready for a folder scan.
Returns:
True when ``settings.anime_directory`` exists and
``settings.tmdb_api_key`` is configured.
"""
from src.config.settings import settings # noqa: PLC0415
if not settings.tmdb_api_key:
logger.warning("Folder scan skipped — TMDB API key not configured")
return False
if not settings.anime_directory:
logger.warning("Folder scan skipped — anime directory not configured")
return False
anime_dir = Path(settings.anime_directory)
if not anime_dir.is_dir():
logger.warning(
"Folder scan skipped — anime directory not found: %s", anime_dir
)
return False
return True

View File

@@ -0,0 +1,317 @@
"""Key resolution service for orphaned anime folders.
Attempts to resolve provider keys for anime folders that have no key/data
file and no database entry, by searching the anime provider and matching
folder names to search results.
This service runs after nfo_repair_service during the daily folder scan.
"""
from __future__ import annotations
import asyncio
import re
from pathlib import Path
from typing import Optional
import structlog
from src.config.settings import settings as _settings
logger = structlog.get_logger(__name__)
# Limit concurrent provider searches to avoid rate-limiting.
_SEARCH_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(2)
def _strip_year_from_folder(folder_name: str) -> str:
"""Remove trailing year suffix like ' (2020)' from folder name.
Args:
folder_name: Folder name, e.g. 'Rent-A-Girlfriend (2020)'
Returns:
Name without year, e.g. 'Rent-A-Girlfriend'
"""
return re.sub(r"\s*\(\d{4}\)\s*$", "", folder_name).strip()
def _extract_year_from_folder(folder_name: str) -> Optional[int]:
"""Extract year from folder name like 'Anime Name (2020)'.
Returns:
Year as int or None if not present.
"""
match = re.search(r"\((\d{4})\)$", folder_name.strip())
if match:
return int(match.group(1))
return None
def _extract_key_from_link(link: str) -> Optional[str]:
"""Extract provider key from search result link.
Args:
link: Link like '/anime/stream/rent-a-girlfriend' or full URL.
Returns:
Key slug like 'rent-a-girlfriend' or None.
"""
if not link:
return None
if "/anime/stream/" in link:
parts = link.split("/anime/stream/")[-1].split("/")
key = parts[0].strip()
return key if key else None
# If link is just a slug
if "/" not in link and link.strip():
return link.strip()
return None
def _normalize_for_comparison(text: str) -> str:
"""Normalize text for case-insensitive comparison.
Strips whitespace, lowercases, and removes common punctuation
differences that shouldn't affect matching.
Args:
text: Raw text string.
Returns:
Normalized lowercase string.
"""
normalized = text.strip().lower()
# Remove common punctuation that varies between sources
normalized = re.sub(r"[:\-–—]", " ", normalized)
# Collapse multiple spaces
normalized = re.sub(r"\s+", " ", normalized)
return normalized.strip()
async def resolve_key_for_folder(folder_name: str) -> Optional[str]:
"""Attempt to resolve the provider key for a single folder.
Strategy:
1. Strip year suffix from folder name to get search query.
2. Search the anime provider with that query.
3. If exactly ONE result matches the folder name (case-insensitive),
return the key extracted from the result link.
4. If zero or multiple matches, return None (not confident enough).
Args:
folder_name: The anime folder name, e.g. 'Rent-A-Girlfriend (2020)'.
Returns:
The provider key string, or None if resolution is not confident.
"""
search_query = _strip_year_from_folder(folder_name)
if not search_query:
logger.debug("Empty search query after stripping year from '%s'", folder_name)
return None
async with _SEARCH_SEMAPHORE:
try:
loop = asyncio.get_running_loop()
results = await loop.run_in_executor(None, _search_provider, search_query)
except Exception as exc:
logger.warning(
"Provider search failed for '%s': %s", search_query, exc
)
return None
if not results:
logger.debug("No search results for folder '%s'", folder_name)
return None
# Filter results: find exact name matches (case-insensitive)
normalized_query = _normalize_for_comparison(search_query)
exact_matches = []
for result in results:
title = result.get("title") or result.get("name") or ""
normalized_title = _normalize_for_comparison(title)
if normalized_title == normalized_query:
key = _extract_key_from_link(result.get("link", ""))
if key:
exact_matches.append((key, title))
if len(exact_matches) == 1:
resolved_key, matched_title = exact_matches[0]
logger.info(
"Resolved key for folder '%s': key='%s' (matched title: '%s')",
folder_name,
resolved_key,
matched_title,
)
return resolved_key
if len(exact_matches) > 1:
logger.info(
"Multiple exact matches for folder '%s' (%d matches), skipping",
folder_name,
len(exact_matches),
)
else:
logger.debug(
"No exact title match for folder '%s' in %d results",
folder_name,
len(results),
)
return None
def _search_provider(query: str) -> list:
"""Call the anime provider search synchronously.
Args:
query: Search term.
Returns:
List of search result dicts with 'link' and 'title'/'name' fields.
"""
from src.core.providers.provider_factory import Loaders
loader = Loaders().GetLoader("aniworld.to")
return loader.search(query)
async def perform_key_resolution_scan() -> dict[str, int]:
"""Scan all anime folders and resolve missing keys.
Iterates over all subfolders of the anime directory. For each folder
that has no corresponding database entry, attempts to resolve the
provider key via provider search and saves it to the database.
Returns:
Dictionary with counts:
- 'scanned': total folders checked
- 'resolved': keys successfully resolved and saved
- 'skipped': folders already in DB or resolution uncertain
- 'errors': folders that caused errors during resolution
"""
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
stats = {"scanned": 0, "resolved": 0, "skipped": 0, "errors": 0}
if not _settings.anime_directory:
logger.warning("Key resolution scan skipped — anime directory not configured")
return stats
anime_dir = Path(_settings.anime_directory)
if not anime_dir.is_dir():
logger.warning(
"Key resolution scan skipped — anime directory not found: %s",
anime_dir,
)
return stats
# Collect folders that need resolution
folders_to_resolve: list[str] = []
async with get_db_session() as db:
for series_dir in sorted(anime_dir.iterdir()):
if not series_dir.is_dir():
continue
folder_name = series_dir.name
stats["scanned"] += 1
# Check if already in database
existing = await AnimeSeriesService.get_by_folder(db, folder_name)
if existing:
stats["skipped"] += 1
continue
folders_to_resolve.append(folder_name)
if not folders_to_resolve:
logger.info("Key resolution scan: all folders already have DB entries")
return stats
logger.info(
"Key resolution scan: %d folders need resolution", len(folders_to_resolve)
)
# Resolve keys one by one (provider search is rate-limited)
for folder_name in folders_to_resolve:
try:
key = await resolve_key_for_folder(folder_name)
if key:
# Save to database
await _save_resolved_key(folder_name, key)
stats["resolved"] += 1
else:
stats["skipped"] += 1
except Exception as exc:
logger.error(
"Error resolving key for folder '%s': %s",
folder_name,
exc,
)
stats["errors"] += 1
logger.info(
"Key resolution scan complete: scanned=%d, resolved=%d, skipped=%d, errors=%d",
stats["scanned"],
stats["resolved"],
stats["skipped"],
stats["errors"],
)
return stats
async def _save_resolved_key(folder_name: str, key: str) -> None:
"""Save a resolved key to the database.
Creates a new AnimeSeries entry with the resolved key and folder name.
Does NOT write any key/data file to disk.
Args:
folder_name: The anime folder name (e.g. 'Rent-A-Girlfriend (2020)').
key: The resolved provider key (e.g. 'rent-a-girlfriend').
"""
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
name = _strip_year_from_folder(folder_name)
year = _extract_year_from_folder(folder_name)
async with get_db_session() as db:
# Double-check: another task might have resolved it concurrently
existing = await AnimeSeriesService.get_by_folder(db, folder_name)
if existing:
logger.debug(
"Folder '%s' already in DB (resolved concurrently), skipping",
folder_name,
)
return
# Also check if a series with this key already exists
existing_key = await AnimeSeriesService.get_by_key(db, key)
if existing_key:
logger.warning(
"Key '%s' already exists in DB for folder '%s', "
"cannot assign to folder '%s'",
key,
existing_key.folder,
folder_name,
)
return
await AnimeSeriesService.create(
db,
key=key,
name=name,
site="aniworld.to",
folder=folder_name,
year=year,
loading_status="pending",
episodes_loaded=False,
)
logger.info(
"Saved resolved key '%s' for folder '%s' to database",
key,
folder_name,
)

View File

@@ -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

View File

@@ -0,0 +1,429 @@
"""Scheduler service for automatic library rescans.
Uses APScheduler's AsyncIOScheduler with CronTrigger for precise
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 Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from src.server.models.config import SchedulerConfig
from src.server.services.config_service import ConfigServiceError, get_config_service
logger = logging.getLogger(__name__)
_JOB_ID = "scheduled_rescan"
# Grace period for missed jobs (1 hour — handles server downtime between
# scheduled time and startup).
_MISFIRE_GRACE_SECONDS = 3600
class SchedulerServiceError(Exception):
"""Service-level exception for scheduler operations."""
class SchedulerService:
"""Manages automatic library rescans on a cron-based schedule.
Uses APScheduler's AsyncIOScheduler so scheduling integrates cleanly
with the running asyncio event loop. Supports:
- Cron-based scheduling (time of day + days of week)
- Immediate manual trigger
- Live config reloading without app restart
Actual rescan/folder-scan/auto-download work is delegated to
RescanService.
"""
def __init__(self) -> None:
"""Initialise the scheduler service."""
self._is_running: bool = False
self._scheduler: Optional[AsyncIOScheduler] = None
self._config: Optional[SchedulerConfig] = None
self._scan_in_progress: bool = False
logger.info("SchedulerService initialised")
# ------------------------------------------------------------------
# Public lifecycle methods
# ------------------------------------------------------------------
async def start(self) -> None:
"""Start the APScheduler with the configured cron trigger.
Raises:
SchedulerServiceError: If the scheduler is already running or
config cannot be loaded.
"""
logger.info("SchedulerService.start() called")
if self._is_running:
logger.warning("Scheduler start called but already running")
raise SchedulerServiceError("Scheduler is already running")
try:
config_service = get_config_service()
config = config_service.load_config()
self._config = config.scheduler
logger.info("Scheduler config loaded successfully")
except ConfigServiceError as exc:
logger.error("Failed to load scheduler configuration: %s", exc)
raise SchedulerServiceError(f"Failed to load config: {exc}") from exc
self._scheduler = AsyncIOScheduler()
if not self._config.enabled:
logger.info("Scheduler is disabled in configuration — not adding jobs")
self._is_running = True
return
logger.info(
"Scheduler config loaded: enabled=%s time=%s days=%s auto_download=%s folder_scan=%s",
self._config.enabled,
self._config.schedule_time,
self._config.schedule_days,
self._config.auto_download_after_rescan,
self._config.folder_scan_enabled,
)
trigger = self._build_cron_trigger()
if trigger is None:
logger.warning(
"schedule_days is empty — scheduler started but no job scheduled"
)
else:
self._scheduler.add_job(
_run_rescan_job,
trigger=trigger,
id=_JOB_ID,
replace_existing=True,
misfire_grace_time=_MISFIRE_GRACE_SECONDS,
coalesce=True,
)
logger.info(
"Scheduler started with cron trigger: time=%s days=%s",
self._config.schedule_time,
self._config.schedule_days,
)
self._scheduler.start()
self._is_running = True
# Log next scheduled run for visibility.
job = self._scheduler.get_job(_JOB_ID)
if job:
next_run = job.next_run_time
logger.info(
"Scheduler next run: %s",
next_run.isoformat() if next_run else None,
)
# Startup misfire recovery: check if the last scan was missed while
# the server was down.
await self._check_missed_run()
async def stop(self) -> None:
"""Stop the APScheduler gracefully."""
logger.info("SchedulerService.stop() called")
if not self._is_running:
logger.debug("Scheduler stop called but not running")
return
if self._scheduler and self._scheduler.running:
self._scheduler.shutdown(wait=False)
logger.info("Scheduler stopped")
else:
logger.info("Scheduler stop: scheduler was not running")
self._is_running = False
logger.info("SchedulerService stopped successfully")
async def ensure_started(self) -> None:
"""Ensure the scheduler is running (idempotent).
If already running, returns immediately. Otherwise, starts the scheduler.
This method is safe to call multiple times and from multiple callers.
Raises:
SchedulerServiceError: If startup fails (except for already running).
"""
if self._is_running:
logger.debug("Scheduler ensure_started called but already running")
return
logger.info("Scheduler ensure_started: starting scheduler")
await self.start()
async def trigger_rescan(self) -> bool:
"""Manually trigger a library rescan.
Returns:
True if rescan was started; False if a scan is already running.
Raises:
SchedulerServiceError: If the scheduler service is not started.
"""
if not self._is_running:
raise SchedulerServiceError("Scheduler is not running")
if self._scan_in_progress:
logger.warning("Cannot trigger rescan: scan already in progress")
return False
logger.info("Manual rescan triggered")
await self._perform_rescan()
return True
def reload_config(self, config: SchedulerConfig) -> None:
"""Apply a new SchedulerConfig immediately.
If the scheduler is already running the job is rescheduled (or
removed) without stopping the scheduler.
Args:
config: New scheduler configuration to apply.
"""
self._config = config
logger.info(
"Scheduler config reloaded: enabled=%s time=%s days=%s auto_download=%s folder_scan=%s",
config.enabled,
config.schedule_time,
config.schedule_days,
config.auto_download_after_rescan,
config.folder_scan_enabled,
)
if not self._scheduler or not self._scheduler.running:
return
if not config.enabled:
if self._scheduler.get_job(_JOB_ID):
self._scheduler.remove_job(_JOB_ID)
logger.info("Scheduler job removed (disabled)")
return
trigger = self._build_cron_trigger()
if trigger is None:
if self._scheduler.get_job(_JOB_ID):
self._scheduler.remove_job(_JOB_ID)
logger.warning("Scheduler job removed — schedule_days is empty")
else:
if self._scheduler.get_job(_JOB_ID):
self._scheduler.reschedule_job(_JOB_ID, trigger=trigger)
logger.info(
"Scheduler rescheduled with cron trigger: time=%s days=%s",
config.schedule_time,
config.schedule_days,
)
else:
self._scheduler.add_job(
_run_rescan_job,
trigger=trigger,
id=_JOB_ID,
replace_existing=True,
misfire_grace_time=_MISFIRE_GRACE_SECONDS,
coalesce=True,
)
logger.info(
"Scheduler job added with cron trigger: time=%s days=%s",
config.schedule_time,
config.schedule_days,
)
def get_status(self) -> dict:
"""Return current scheduler status including cron configuration.
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)
if job and job.next_run_time:
next_run = job.next_run_time.isoformat()
return {
"is_running": self._is_running,
"enabled": self._config.enabled if self._config else False,
"interval_minutes": self._config.interval_minutes if self._config else None,
"schedule_time": self._config.schedule_time if self._config else None,
"schedule_days": self._config.schedule_days if self._config else [],
"auto_download_after_rescan": (
self._config.auto_download_after_rescan if self._config else False
),
"folder_scan_enabled": (
self._config.folder_scan_enabled if self._config else False
),
"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,
}
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _build_cron_trigger(self) -> Optional[CronTrigger]:
"""Convert config fields into an APScheduler CronTrigger.
Returns:
CronTrigger instance or None if schedule_days is empty.
"""
if not self._config or not self._config.schedule_days:
return None
hour_str, minute_str = self._config.schedule_time.split(":")
day_of_week = ",".join(self._config.schedule_days)
trigger = CronTrigger(
hour=int(hour_str),
minute=int(minute_str),
day_of_week=day_of_week,
)
logger.debug(
"CronTrigger built: hour=%s minute=%s day_of_week=%s",
hour_str,
minute_str,
day_of_week,
)
return trigger
async def _check_missed_run(self) -> None:
"""Check if a scheduled rescan was missed while the server was down.
Compares system_settings.last_scan_timestamp against the expected
schedule. If the last scan is overdue (more than 24h ago for a daily
schedule) but within the grace period, triggers an immediate rescan.
"""
if not self._config or not self._config.enabled:
return
if not self._config.schedule_days:
return
try:
from src.server.database.connection import get_db_session
from src.server.database.system_settings_service import (
SystemSettingsService,
)
async with get_db_session() as db:
settings = await SystemSettingsService.get_or_create(db)
last_scan = settings.last_scan_timestamp
if last_scan is None:
# Never scanned before — trigger immediately
logger.info("No previous scan recorded — triggering immediate rescan")
await self._perform_rescan()
return
# Ensure timezone-aware comparison
if last_scan.tzinfo is None:
last_scan = last_scan.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
elapsed = now - last_scan
# 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 elapsed > max_overdue:
logger.info(
"Last scan was %s ago (> %s) — skipping missed-run recovery",
elapsed,
max_overdue,
)
return
# Check if a run should have happened between last_scan and now.
if elapsed > timedelta(hours=23):
logger.info(
"Missed scheduled rescan detected (last scan %s ago) — triggering now",
elapsed,
)
await self._perform_rescan()
except Exception as exc: # pylint: disable=broad-exception-caught
logger.warning("Missed-run check failed (non-fatal): %s", exc)
async def _perform_rescan(self) -> None:
"""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
try:
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")
# ---------------------------------------------------------------------------
# Module-level job runner
#
# APScheduler cannot serialize bound methods (SchedulerService instance
# contains a reference to the scheduler itself, creating a circular pickle
# error). Using a module-level function avoids this.
# ---------------------------------------------------------------------------
async def _run_rescan_job() -> None:
"""Module-level job entry point — delegates to the current service."""
logger.info("=" * 60)
logger.info("APScheduler triggered _run_rescan_job")
logger.info("Getting scheduler service singleton...")
svc = get_scheduler_service()
logger.info("Scheduler service obtained, calling _perform_rescan()")
await svc._perform_rescan()
logger.info("_run_rescan_job completed")
logger.info("=" * 60)
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
_scheduler_service: Optional[SchedulerService] = None
def get_scheduler_service() -> SchedulerService:
"""Return the singleton SchedulerService instance."""
global _scheduler_service
if _scheduler_service is None:
logger.info("Creating new SchedulerService singleton")
_scheduler_service = SchedulerService()
else:
logger.debug("Returning existing SchedulerService singleton")
return _scheduler_service
def reset_scheduler_service() -> None:
"""Reset the singleton (used in tests)."""
global _scheduler_service
_scheduler_service = None