Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbaf80e941 | |||
| 4fc597c5de | |||
| a77bb371df | |||
| 420d10bb34 | |||
| e29918488c | |||
| 9c3f03d610 | |||
| 9d64241230 | |||
| 49cd84f3e5 | |||
| e46759347e | |||
| 75f743e6cc | |||
| 4dc5ffa19e |
@@ -17,6 +17,9 @@ __pycache__/
|
|||||||
# Docker files (not needed inside the image)
|
# Docker files (not needed inside the image)
|
||||||
Docker/
|
Docker/
|
||||||
|
|
||||||
|
# Exception: VERSION is needed by Dockerfile.app
|
||||||
|
!Docker/VERSION
|
||||||
|
|
||||||
# Test and dev files
|
# Test and dev files
|
||||||
tests/
|
tests/
|
||||||
Temp/
|
Temp/
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ COPY src/ ./src/
|
|||||||
COPY run_server.py .
|
COPY run_server.py .
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
COPY data/config.json ./data/config.json
|
COPY data/config.json ./data/config.json
|
||||||
|
COPY Docker/VERSION ./Docker/VERSION
|
||||||
|
|
||||||
# Create runtime directories
|
# Create runtime directories
|
||||||
RUN mkdir -p /app/data/config_backups /app/logs
|
RUN mkdir -p /app/data/config_backups /app/logs
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v1.3.3
|
v1.3.6
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aniworld-web",
|
"name": "aniworld-web",
|
||||||
"version": "1.3.3",
|
"version": "1.3.6",
|
||||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -454,6 +454,24 @@ class SerieScanner:
|
|||||||
str(e)
|
str(e)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Fetch series name from provider if not already set
|
||||||
|
if not serie.name:
|
||||||
|
try:
|
||||||
|
fetched_name = self.loader.get_title(serie.key)
|
||||||
|
if fetched_name:
|
||||||
|
serie.name = fetched_name
|
||||||
|
logger.info(
|
||||||
|
"Fetched name from provider: %s (name=%s)",
|
||||||
|
serie.key,
|
||||||
|
serie.name
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Could not fetch name for %s: %s",
|
||||||
|
serie.key,
|
||||||
|
str(e)
|
||||||
|
)
|
||||||
|
|
||||||
# Delegate the provider to compare local files with
|
# Delegate the provider to compare local files with
|
||||||
# remote metadata, yielding missing episodes per
|
# remote metadata, yielding missing episodes per
|
||||||
# season. Results are saved back to disk so that both
|
# season. Results are saved back to disk so that both
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ from src.server.exceptions import (
|
|||||||
from src.server.models.anime import AnimeMetadataUpdate
|
from src.server.models.anime import AnimeMetadataUpdate
|
||||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||||
from src.server.services.background_loader_service import BackgroundLoaderService
|
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 (
|
from src.server.utils.dependencies import (
|
||||||
get_anime_service,
|
get_anime_service,
|
||||||
get_background_loader_service,
|
get_background_loader_service,
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ async def setup_auth(req: SetupRequest):
|
|||||||
|
|
||||||
# Start scheduler if anime_directory is now set
|
# Start scheduler if anime_directory is now set
|
||||||
try:
|
try:
|
||||||
from src.server.services.scheduler_service import (
|
from src.server.services.scheduler.scheduler_service import (
|
||||||
get_scheduler_service,
|
get_scheduler_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ async def update_config(
|
|||||||
# Start scheduler if anime_directory was just configured
|
# Start scheduler if anime_directory was just configured
|
||||||
if anime_dir_changed:
|
if anime_dir_changed:
|
||||||
try:
|
try:
|
||||||
from src.server.services.scheduler_service import (
|
from src.server.services.scheduler.scheduler_service import (
|
||||||
get_scheduler_service,
|
get_scheduler_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -195,7 +195,9 @@ async def basic_health_check(request: Request) -> HealthStatus:
|
|||||||
# Get scheduler status for health monitoring
|
# Get scheduler status for health monitoring
|
||||||
scheduler_status: dict = {}
|
scheduler_status: dict = {}
|
||||||
try:
|
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()
|
scheduler_status = get_scheduler_service().get_status()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
|
|
||||||
from src.server.models.config import SchedulerConfig
|
from src.server.models.config import SchedulerConfig
|
||||||
from src.server.services.config_service import ConfigServiceError, get_config_service
|
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
|
from src.server.utils.dependencies import require_auth
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -119,6 +119,11 @@ async def initialize_database(
|
|||||||
result["tables_created"] = tables
|
result["tables_created"] = tables
|
||||||
logger.info("Created %s tables", len(tables))
|
logger.info("Created %s tables", len(tables))
|
||||||
|
|
||||||
|
# Migrate schema if needed (add missing columns to existing tables)
|
||||||
|
migrations = await migrate_schema_if_needed(engine)
|
||||||
|
if migrations:
|
||||||
|
logger.info("Applied %s schema migrations", len(migrations))
|
||||||
|
|
||||||
# Validate schema if requested
|
# Validate schema if requested
|
||||||
if validate_schema:
|
if validate_schema:
|
||||||
validation = await validate_database_schema(engine)
|
validation = await validate_database_schema(engine)
|
||||||
@@ -305,6 +310,66 @@ async def validate_database_schema(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Schema Migration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_schema_if_needed(
|
||||||
|
engine: Optional[AsyncEngine] = None
|
||||||
|
) -> List[str]:
|
||||||
|
"""Migrate database schema to current version if needed.
|
||||||
|
|
||||||
|
Handles adding missing columns to existing tables for backward
|
||||||
|
compatibility with older database schemas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
engine: Optional database engine (uses default if not provided)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of migration operations performed
|
||||||
|
"""
|
||||||
|
if engine is None:
|
||||||
|
engine = get_engine()
|
||||||
|
|
||||||
|
migrations_applied = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
# Get existing columns in system_settings table
|
||||||
|
existing_columns = await conn.run_sync(
|
||||||
|
lambda sync_conn: [
|
||||||
|
col["name"]
|
||||||
|
for col in inspect(sync_conn).get_columns("system_settings")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Migration: Add legacy_key_cleanup_completed column if missing
|
||||||
|
if "legacy_key_cleanup_completed" not in existing_columns:
|
||||||
|
logger.info(
|
||||||
|
"Migrating system_settings table: "
|
||||||
|
"adding legacy_key_cleanup_completed column"
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text("""
|
||||||
|
ALTER TABLE system_settings
|
||||||
|
ADD COLUMN legacy_key_cleanup_completed BOOLEAN
|
||||||
|
NOT NULL DEFAULT 0
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
migrations_applied.append("added legacy_key_cleanup_completed")
|
||||||
|
logger.info(
|
||||||
|
"Migration complete: added legacy_key_cleanup_completed column"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Schema migration failed: %s", e)
|
||||||
|
# Don't raise - migration failures shouldn't block startup
|
||||||
|
# The missing column will be handled gracefully by the application
|
||||||
|
|
||||||
|
return migrations_applied
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Schema Version Management
|
# Schema Version Management
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -411,7 +411,9 @@ async def lifespan(_application: FastAPI):
|
|||||||
# anime_directory may be configured there even if the env var is empty.
|
# anime_directory may be configured there even if the env var is empty.
|
||||||
try:
|
try:
|
||||||
logger.info("Initializing scheduler service...")
|
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()
|
scheduler_service = get_scheduler_service()
|
||||||
logger.info("Scheduler service instance obtained, starting...")
|
logger.info("Scheduler service instance obtained, starting...")
|
||||||
await scheduler_service.start()
|
await scheduler_service.start()
|
||||||
@@ -496,7 +498,9 @@ async def lifespan(_application: FastAPI):
|
|||||||
# 1. Stop scheduler service (only if initialized)
|
# 1. Stop scheduler service (only if initialized)
|
||||||
if initialized['scheduler']:
|
if initialized['scheduler']:
|
||||||
try:
|
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()
|
scheduler_service = get_scheduler_service()
|
||||||
logger.info("Stopping scheduler service...")
|
logger.info("Stopping scheduler service...")
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(
|
||||||
|
|||||||
@@ -544,10 +544,9 @@ async def _check_media_scan_status() -> bool:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if media scan was completed, False otherwise
|
bool: True if media scan was completed, False otherwise
|
||||||
"""
|
"""
|
||||||
return await _check_scan_status(
|
# DISABLED: Always return True to skip startup scan
|
||||||
check_method=lambda svc, db: svc.is_initial_media_scan_completed(db),
|
# To re-enable, change to: return await _check_scan_status(...)
|
||||||
scan_type="media"
|
return True
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _mark_media_scan_completed() -> None:
|
async def _mark_media_scan_completed() -> None:
|
||||||
|
|||||||
291
src/server/services/rescan_service.py
Normal file
291
src/server/services/rescan_service.py
Normal file
@@ -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
|
||||||
45
src/server/services/scheduler/__init__.py
Normal file
45
src/server/services/scheduler/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
@@ -13,9 +13,12 @@ reflect the new paths.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Set, Tuple
|
from typing import Optional
|
||||||
|
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
@@ -31,10 +34,11 @@ from src.server.utils.filesystem import sanitize_folder_name
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Characters that are invalid in filesystem paths across platforms
|
# Pre-compiled pattern for stripping existing year suffixes
|
||||||
INVALID_PATH_CHARS = '<>:"/\\|?*\x00'
|
_YEAR_SUFFIX_PATTERN = re.compile(r'(\s*\(\d{4}\))+\s*$')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
class DuplicateGroup:
|
class DuplicateGroup:
|
||||||
"""Represents a group of duplicate folders for the same series.
|
"""Represents a group of duplicate folders for the same series.
|
||||||
|
|
||||||
@@ -44,10 +48,9 @@ class DuplicateGroup:
|
|||||||
nfo_paths: List of corresponding NFO file paths.
|
nfo_paths: List of corresponding NFO file paths.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, key: str, folders: List[str], nfo_paths: List[Path]):
|
key: str
|
||||||
self.key = key
|
folders: list[str]
|
||||||
self.folders = folders
|
nfo_paths: list[Path]
|
||||||
self.nfo_paths = nfo_paths
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def count(self) -> int:
|
def count(self) -> int:
|
||||||
@@ -57,7 +60,20 @@ class DuplicateGroup:
|
|||||||
return f"DuplicateGroup(key={self.key!r}, folders={self.folders})"
|
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.
|
"""Scan anime directory for pre-existing duplicate folders.
|
||||||
|
|
||||||
Groups folders by the series key extracted from their NFO files.
|
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:
|
Returns:
|
||||||
List of DuplicateGroup objects, one per series with duplicate folders.
|
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():
|
for series_dir in anime_dir.iterdir():
|
||||||
if not series_dir.is_dir():
|
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)
|
expected_name = _compute_expected_folder_name(title, year)
|
||||||
groups[expected_name].append((series_dir.name, nfo_path))
|
groups[expected_name].append((series_dir.name, nfo_path))
|
||||||
|
|
||||||
# Filter to only groups with more than one folder
|
|
||||||
duplicates = []
|
duplicates = []
|
||||||
for key, items in groups.items():
|
for key, items in groups.items():
|
||||||
if len(items) > 1:
|
if len(items) > 1:
|
||||||
@@ -111,16 +125,14 @@ def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) ->
|
|||||||
if len(group.folders) < 2:
|
if len(group.folders) < 2:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Keep first folder as canonical, mark others for removal
|
|
||||||
canonical = group.folders[0]
|
canonical = group.folders[0]
|
||||||
to_remove = group.folders[1:]
|
to_remove = group.folders[1:]
|
||||||
|
|
||||||
for folder in to_remove:
|
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():
|
if not folder_path.exists():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if folder is empty or only has symlinks
|
|
||||||
try:
|
try:
|
||||||
contents = list(folder_path.iterdir())
|
contents = list(folder_path.iterdir())
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
@@ -130,7 +142,6 @@ def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) ->
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
if not contents:
|
if not contents:
|
||||||
# Empty folder - safe to remove
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
logger.info("[DRY-RUN] Would delete empty duplicate folder: %s", folder_path)
|
logger.info("[DRY-RUN] Would delete empty duplicate folder: %s", folder_path)
|
||||||
else:
|
else:
|
||||||
@@ -141,9 +152,9 @@ def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) ->
|
|||||||
return False
|
return False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if all contents are symlinks pointing to canonical
|
canonical_path = folder_path.parent / canonical
|
||||||
all_symlinks = all(
|
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
|
for item in contents
|
||||||
)
|
)
|
||||||
if all_symlinks:
|
if all_symlinks:
|
||||||
@@ -159,7 +170,6 @@ def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) ->
|
|||||||
return False
|
return False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Cannot auto-merge - requires manual intervention
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Cannot auto-merge duplicate folders for '%s': %s (manual merge required)",
|
"Cannot auto-merge duplicate folders for '%s': %s (manual merge required)",
|
||||||
group.key,
|
group.key,
|
||||||
@@ -170,7 +180,7 @@ def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) ->
|
|||||||
return True
|
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.
|
"""Parse a tvshow.nfo and return (title, year) text values.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -194,7 +204,7 @@ def _parse_nfo_title_and_year(nfo_path: Path) -> Tuple[Optional[str], Optional[s
|
|||||||
except etree.XMLSyntaxError as exc:
|
except etree.XMLSyntaxError as exc:
|
||||||
logger.warning("Malformed XML in %s: %s", nfo_path, exc)
|
logger.warning("Malformed XML in %s: %s", nfo_path, exc)
|
||||||
return None, None
|
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)
|
logger.warning("Unexpected error parsing %s: %s", nfo_path, exc)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
@@ -212,13 +222,7 @@ def _compute_expected_folder_name(title: str, year: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
Sanitised folder name in the format ``"{title} ({year})"``.
|
Sanitised folder name in the format ``"{title} ({year})"``.
|
||||||
"""
|
"""
|
||||||
import re
|
clean_title = _YEAR_SUFFIX_PATTERN.sub('', title).strip()
|
||||||
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
year_suffix = f" ({year})"
|
year_suffix = f" ({year})"
|
||||||
raw_name = f"{clean_title}{year_suffix}"
|
raw_name = f"{clean_title}{year_suffix}"
|
||||||
return sanitize_folder_name(raw_name)
|
return sanitize_folder_name(raw_name)
|
||||||
@@ -236,42 +240,55 @@ def _is_series_being_downloaded(series_folder: str) -> bool:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
download_service = get_download_service()
|
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:
|
if active and active.serie_folder == series_folder:
|
||||||
return True
|
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:
|
if item.serie_folder == series_folder:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Could not check download status for %s: %s", series_folder, exc
|
"Could not check download status for %s: %s", series_folder, exc
|
||||||
)
|
)
|
||||||
# Safer to skip renaming if we can't verify download status.
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_stale_files_after_rename(new_path: Path, new_name: str) -> None:
|
def _remove_key_file(path: Path) -> None:
|
||||||
"""Remove legacy 'key' file after successful folder rename.
|
"""Remove legacy 'key' file from a series folder.
|
||||||
|
|
||||||
Also checks for orphaned folders with the same key that may have been
|
|
||||||
left behind from previous rename operations.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
new_path: The new folder path after rename.
|
path: Path to the series folder.
|
||||||
new_name: The new folder name.
|
|
||||||
"""
|
"""
|
||||||
key_file = new_path / "key"
|
key_file = path / "key"
|
||||||
if key_file.exists():
|
if key_file.exists():
|
||||||
try:
|
try:
|
||||||
key_file.unlink()
|
key_file.unlink()
|
||||||
logger.info(
|
logger.info("Removed legacy 'key' file after rename: %s", key_file)
|
||||||
"Removed legacy 'key' file after rename: %s", key_file
|
|
||||||
)
|
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.warning(
|
logger.warning("Could not remove legacy 'key' file %s: %s", key_file, exc)
|
||||||
"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:
|
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.
|
False if old folder does not exist or cleanup failed.
|
||||||
"""
|
"""
|
||||||
if not old_path.exists():
|
if not old_path.exists():
|
||||||
logger.debug(
|
logger.debug("Old folder does not exist, no cleanup needed: %s", old_path)
|
||||||
"Old folder does not exist, no cleanup needed: %s", old_path
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if folder is empty
|
|
||||||
try:
|
try:
|
||||||
contents = list(old_path.iterdir())
|
contents = list(old_path.iterdir())
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
logger.warning(
|
logger.warning("Permission denied accessing old folder %s: %s", old_path, exc)
|
||||||
"Permission denied accessing old folder %s: %s", old_path, exc
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.warning(
|
logger.warning("OS error accessing old folder %s: %s", old_path, exc)
|
||||||
"OS error accessing old folder %s: %s", old_path, exc
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not contents:
|
if not contents:
|
||||||
# Empty folder — delete it
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
logger.info(
|
logger.info("[DRY-RUN] Would delete empty orphaned folder: %s", old_path)
|
||||||
"[DRY-RUN] Would delete empty orphaned folder: %s", old_path
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
old_path.rmdir()
|
old_path.rmdir()
|
||||||
logger.info("Deleted empty orphaned folder: %s", old_path)
|
logger.info("Deleted empty orphaned folder: %s", old_path)
|
||||||
return True
|
return True
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
logger.warning(
|
logger.warning("Permission denied deleting folder %s: %s", old_path, exc)
|
||||||
"Permission denied deleting folder %s: %s", old_path, exc
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.warning(
|
logger.warning("OS error deleting folder %s: %s", old_path, exc)
|
||||||
"OS error deleting folder %s: %s", old_path, exc
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Folder has contents — move files to new_path then delete
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
logger.info(
|
logger.info("[DRY-RUN] Would move %d files from orphaned folder %s to %s",
|
||||||
"[DRY-RUN] Would move %d files from orphaned folder %s to %s",
|
len(contents), old_path, new_path)
|
||||||
len(contents), old_path, new_path
|
|
||||||
)
|
|
||||||
for item in contents:
|
for item in contents:
|
||||||
logger.info("[DRY-RUN] Would move: %s → %s", item, new_path / item.name)
|
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)
|
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
|
files_moved = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
for item in contents:
|
for item in contents:
|
||||||
try:
|
if not _move_file(item, new_path / item.name):
|
||||||
dest = new_path / item.name
|
errors += 1
|
||||||
item.rename(dest)
|
else:
|
||||||
logger.debug("Moved %s → %s", item, dest)
|
|
||||||
files_moved += 1
|
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:
|
if files_moved > 0:
|
||||||
logger.info(
|
logger.info("Moved %d files from orphaned folder to %s", files_moved, new_path)
|
||||||
"Moved %d files from orphaned folder to %s",
|
|
||||||
files_moved, new_path
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete the now-empty old folder
|
|
||||||
try:
|
try:
|
||||||
old_path.rmdir()
|
old_path.rmdir()
|
||||||
logger.info("Deleted orphaned folder after moving contents: %s", old_path)
|
logger.info("Deleted orphaned folder after moving contents: %s", old_path)
|
||||||
return errors == 0
|
return errors == 0
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.warning(
|
logger.warning("Could not delete orphaned folder %s (may not be empty): %s", old_path, exc)
|
||||||
"Could not delete orphaned folder %s (may not be empty): %s",
|
|
||||||
old_path, exc
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async 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
|
||||||
|
|
||||||
|
await 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(
|
async def _update_database_paths(
|
||||||
old_folder: str,
|
old_folder: str,
|
||||||
new_folder: str,
|
new_folder: str,
|
||||||
@@ -402,82 +447,138 @@ async def _update_database_paths(
|
|||||||
new_series_path = anime_dir / new_folder
|
new_series_path = anime_dir / new_folder
|
||||||
|
|
||||||
async with get_db_session() as db:
|
async with get_db_session() as db:
|
||||||
# 1. Update AnimeSeries.folder
|
|
||||||
series = await AnimeSeriesService.get_by_folder(db, old_folder)
|
series = await AnimeSeriesService.get_by_folder(db, old_folder)
|
||||||
if series is None:
|
if series is None:
|
||||||
# Fallback: try to find by folder name
|
|
||||||
all_series = await AnimeSeriesService.get_all(db)
|
all_series = await AnimeSeriesService.get_all(db)
|
||||||
for s in all_series:
|
for s in all_series:
|
||||||
if s.folder == old_folder:
|
if s.folder == old_folder:
|
||||||
series = s
|
series = s
|
||||||
break
|
break
|
||||||
|
|
||||||
|
await _update_series_folder(db, series, new_folder)
|
||||||
|
|
||||||
if series is None:
|
if series is None:
|
||||||
logger.warning(
|
|
||||||
"No database record found for folder '%s', skipping DB update",
|
|
||||||
old_folder,
|
|
||||||
)
|
|
||||||
return
|
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)
|
episodes = await EpisodeService.get_by_series(db, series.id)
|
||||||
for episode in episodes:
|
_update_episode_paths(episodes, old_series_path, new_series_path)
|
||||||
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
|
|
||||||
|
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# 3. Update DownloadQueueItem.file_destination for pending items
|
|
||||||
queue_items = await DownloadQueueService.get_all(db, with_series=True)
|
queue_items = await DownloadQueueService.get_all(db, with_series=True)
|
||||||
for item in queue_items:
|
_update_queue_destinations(queue_items, series.id, old_series_path, new_series_path)
|
||||||
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
|
|
||||||
|
|
||||||
await db.flush()
|
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(
|
logger.info(
|
||||||
"Database paths updated for series '%s' → '%s'",
|
"Removed source folder '%s' — series already exists at target",
|
||||||
old_folder,
|
current_name,
|
||||||
new_folder,
|
)
|
||||||
|
|
||||||
|
# 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.
|
"""Validate and rename series folders to match NFO metadata.
|
||||||
|
|
||||||
Iterates over every subfolder in ``settings.anime_directory`` that
|
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:
|
if not settings.anime_directory:
|
||||||
logger.warning("Folder rename skipped — anime directory not configured")
|
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)
|
anime_dir = Path(settings.anime_directory)
|
||||||
if not anime_dir.is_dir():
|
if not anime_dir.is_dir():
|
||||||
logger.warning(
|
logger.warning("Folder rename skipped — anime directory not found: %s", anime_dir)
|
||||||
"Folder rename skipped — anime directory not found: %s", anime_dir
|
return RenameStats().to_dict()
|
||||||
)
|
|
||||||
return {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
|
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
logger.info("Running in DRY-RUN mode — no changes will be made")
|
logger.info("Running in DRY-RUN mode — no changes will be made")
|
||||||
|
|
||||||
stats = {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
|
stats = RenameStats()
|
||||||
|
pre_existing_duplicates: set[str] = set()
|
||||||
# Detect pre-existing duplicates before rename loop
|
|
||||||
pre_existing_duplicates: Set[str] = set()
|
|
||||||
duplicates = _scan_for_pre_existing_duplicates(anime_dir)
|
duplicates = _scan_for_pre_existing_duplicates(anime_dir)
|
||||||
|
|
||||||
for dup_group in duplicates:
|
for dup_group in duplicates:
|
||||||
# Try automatic merge first
|
|
||||||
if _try_merge_duplicate_group(dup_group, dry_run=dry_run):
|
if _try_merge_duplicate_group(dup_group, dry_run=dry_run):
|
||||||
logger.info(
|
logger.info(
|
||||||
"Auto-merged duplicate group for '%s' (%d folders)",
|
"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,
|
dup_group.count,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Flag all folders in this group as pre-existing duplicates
|
|
||||||
for folder in dup_group.folders:
|
for folder in dup_group.folders:
|
||||||
pre_existing_duplicates.add(folder)
|
pre_existing_duplicates.add(folder)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -549,7 +645,7 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str,
|
|||||||
if not nfo_path.exists():
|
if not nfo_path.exists():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
stats["scanned"] += 1
|
stats.scanned += 1
|
||||||
|
|
||||||
title, year = _parse_nfo_title_and_year(nfo_path)
|
title, year = _parse_nfo_title_and_year(nfo_path)
|
||||||
if not title or not year:
|
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",
|
"Skipping rename for '%s' — missing title or year in NFO",
|
||||||
series_dir.name,
|
series_dir.name,
|
||||||
)
|
)
|
||||||
stats["skipped"] += 1
|
stats.skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
expected_name = _compute_expected_folder_name(title, year)
|
expected_name = _compute_expected_folder_name(title, year)
|
||||||
current_name = series_dir.name
|
current_name = series_dir.name
|
||||||
|
|
||||||
if expected_name == current_name:
|
if expected_name == current_name:
|
||||||
logger.debug(
|
logger.debug("Folder name already correct: '%s'", current_name)
|
||||||
"Folder name already correct: '%s'", current_name
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check for active downloads
|
|
||||||
if _is_series_being_downloaded(current_name):
|
if _is_series_being_downloaded(current_name):
|
||||||
logger.info(
|
logger.info(
|
||||||
"Skipping rename for '%s' — series has active or pending downloads",
|
"Skipping rename for '%s' — series has active or pending downloads",
|
||||||
current_name,
|
current_name,
|
||||||
)
|
)
|
||||||
stats["skipped"] += 1
|
stats.skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
expected_path = anime_dir / expected_name
|
expected_path = anime_dir / expected_name
|
||||||
|
|
||||||
# Check for pre-existing duplicate
|
|
||||||
if current_name in pre_existing_duplicates:
|
if current_name in pre_existing_duplicates:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Skipping rename for '%s' — pre-existing duplicate folder detected",
|
"Skipping rename for '%s' — pre-existing duplicate folder detected",
|
||||||
current_name,
|
current_name,
|
||||||
)
|
)
|
||||||
stats["errors"] += 1
|
stats.errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check for duplicate target
|
|
||||||
if expected_path.exists():
|
if expected_path.exists():
|
||||||
logger.warning(
|
if _remove_duplicate_target_folder(series_dir, current_name, expected_name, expected_path):
|
||||||
"Cannot rename '%s' → '%s' — target already exists",
|
stats.renamed += 1
|
||||||
current_name,
|
else:
|
||||||
expected_name,
|
stats.errors += 1
|
||||||
)
|
|
||||||
# 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
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check path length limits
|
|
||||||
if len(str(expected_path)) > 4096:
|
if len(str(expected_path)) > 4096:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Cannot rename '%s' → '%s' — path exceeds OS limit",
|
"Cannot rename '%s' → '%s' — path exceeds OS limit",
|
||||||
current_name,
|
current_name,
|
||||||
expected_name,
|
expected_name,
|
||||||
)
|
)
|
||||||
stats["errors"] += 1
|
stats.errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
logger.info(
|
logger.info("[DRY-RUN] Would rename folder: '%s' → '%s'", current_name, expected_name)
|
||||||
"[DRY-RUN] Would rename folder: '%s' → '%s'",
|
stats.renamed += 1
|
||||||
current_name,
|
|
||||||
expected_name,
|
|
||||||
)
|
|
||||||
stats["renamed"] += 1
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
old_path = series_dir
|
old_path = series_dir
|
||||||
series_dir.rename(expected_path)
|
series_dir.rename(expected_path)
|
||||||
logger.info(
|
logger.info("Renamed folder: '%s' → '%s'", current_name, expected_name)
|
||||||
"Renamed folder: '%s' → '%s'", current_name, expected_name
|
stats.renamed += 1
|
||||||
)
|
|
||||||
stats["renamed"] += 1
|
|
||||||
|
|
||||||
# Update database records
|
|
||||||
await _update_database_paths(current_name, expected_name, anime_dir)
|
await _update_database_paths(current_name, expected_name, anime_dir)
|
||||||
|
_remove_key_file(expected_path)
|
||||||
# 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
|
|
||||||
_cleanup_orphaned_folder(old_path, expected_path, dry_run=False)
|
_cleanup_orphaned_folder(old_path, expected_path, dry_run=False)
|
||||||
|
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
@@ -690,7 +719,7 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str,
|
|||||||
expected_name,
|
expected_name,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
stats["errors"] += 1
|
stats.errors += 1
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"OS error renaming '%s' → '%s': %s",
|
"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,
|
expected_name,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
stats["errors"] += 1
|
stats.errors += 1
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Folder rename scan complete: scanned=%d, renamed=%d, skipped=%d, errors=%d",
|
"Folder rename scan complete: scanned=%d, renamed=%d, skipped=%d, errors=%d",
|
||||||
stats["scanned"],
|
stats.scanned,
|
||||||
stats["renamed"],
|
stats.renamed,
|
||||||
stats["skipped"],
|
stats.skipped,
|
||||||
stats["errors"],
|
stats.errors,
|
||||||
)
|
)
|
||||||
return stats
|
return stats.to_dict()
|
||||||
@@ -202,7 +202,7 @@ class FolderScanService:
|
|||||||
|
|
||||||
# 1.4 — Validate and rename series folders after NFO repair.
|
# 1.4 — Validate and rename series folders after NFO repair.
|
||||||
logger.info("Starting folder rename validation")
|
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,
|
validate_and_rename_series_folders,
|
||||||
)
|
)
|
||||||
|
|
||||||
293
src/server/services/scheduler/rescan_orchestrator.py
Normal file
293
src/server/services/scheduler/rescan_orchestrator.py
Normal 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.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
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
"""Scheduler service for automatic library rescans.
|
"""Scheduler service for automatic library rescans.
|
||||||
|
|
||||||
Uses APScheduler's AsyncIOScheduler with CronTrigger for precise
|
Uses APScheduler's AsyncIOScheduler with CronTrigger for precise
|
||||||
cron-based scheduling. The legacy interval-based loop has been removed
|
cron-based scheduling.
|
||||||
in favour of the cron approach.
|
|
||||||
|
|
||||||
Jobs are held in memory (no separate scheduler database). On startup,
|
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
|
if the last scan timestamp indicates a missed run (server was down at the
|
||||||
scheduled cron time), a rescan is triggered immediately.
|
scheduled cron time), a rescan is triggered immediately.
|
||||||
|
|
||||||
|
Actual rescan logic is delegated to RescanService.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
@@ -42,7 +43,9 @@ class SchedulerService:
|
|||||||
- Cron-based scheduling (time of day + days of week)
|
- Cron-based scheduling (time of day + days of week)
|
||||||
- Immediate manual trigger
|
- Immediate manual trigger
|
||||||
- Live config reloading without app restart
|
- 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:
|
def __init__(self) -> None:
|
||||||
@@ -50,11 +53,7 @@ class SchedulerService:
|
|||||||
self._is_running: bool = False
|
self._is_running: bool = False
|
||||||
self._scheduler: Optional[AsyncIOScheduler] = None
|
self._scheduler: Optional[AsyncIOScheduler] = None
|
||||||
self._config: Optional[SchedulerConfig] = None
|
self._config: Optional[SchedulerConfig] = None
|
||||||
self._last_scan_time: Optional[datetime] = None
|
|
||||||
self._scan_in_progress: bool = False
|
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")
|
logger.info("SchedulerService initialised")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -82,8 +81,6 @@ class SchedulerService:
|
|||||||
logger.error("Failed to load scheduler configuration: %s", exc)
|
logger.error("Failed to load scheduler configuration: %s", exc)
|
||||||
raise SchedulerServiceError(f"Failed to load config: {exc}") from 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()
|
self._scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
if not self._config.enabled:
|
if not self._config.enabled:
|
||||||
@@ -133,8 +130,7 @@ class SchedulerService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Startup misfire recovery: check if the last scan was missed while
|
# 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 server was down.
|
||||||
# the grace period, trigger an immediate rescan.
|
|
||||||
await self._check_missed_run()
|
await self._check_missed_run()
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
@@ -251,6 +247,10 @@ class SchedulerService:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict containing scheduler state and config fields.
|
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
|
next_run: Optional[str] = None
|
||||||
if self._scheduler and self._scheduler.running:
|
if self._scheduler and self._scheduler.running:
|
||||||
job = self._scheduler.get_job(_JOB_ID)
|
job = self._scheduler.get_job(_JOB_ID)
|
||||||
@@ -269,7 +269,11 @@ class SchedulerService:
|
|||||||
"folder_scan_enabled": (
|
"folder_scan_enabled": (
|
||||||
self._config.folder_scan_enabled if self._config else False
|
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,
|
"next_run": next_run,
|
||||||
"scan_in_progress": self._scan_in_progress,
|
"scan_in_progress": self._scan_in_progress,
|
||||||
}
|
}
|
||||||
@@ -316,9 +320,9 @@ class SchedulerService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
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 (
|
from src.server.database.system_settings_service import (
|
||||||
SystemSettingsService, # noqa: PLC0415
|
SystemSettingsService,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with get_db_session() as db:
|
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
|
# If last scan was more than 24h + grace period ago, don't trigger
|
||||||
# (avoids surprise rescans after long downtime).
|
# (avoids surprise rescans after long downtime).
|
||||||
max_overdue = timedelta(hours=24, seconds=_MISFIRE_GRACE_SECONDS)
|
max_overdue = timedelta(hours=24, seconds=_MISFIRE_GRACE_SECONDS)
|
||||||
# If last scan was more than ~25h ago, skip (too stale)
|
|
||||||
if elapsed > max_overdue:
|
if elapsed > max_overdue:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Last scan was %s ago (> %s) — skipping missed-run recovery",
|
"Last scan was %s ago (> %s) — skipping missed-run recovery",
|
||||||
@@ -351,7 +354,6 @@ class SchedulerService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Check if a run should have happened between last_scan and now.
|
# 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):
|
if elapsed > timedelta(hours=23):
|
||||||
logger.info(
|
logger.info(
|
||||||
"Missed scheduled rescan detected (last scan %s ago) — triggering now",
|
"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
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
logger.warning("Missed-run check failed (non-fatal): %s", exc)
|
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:
|
async def _perform_rescan(self) -> None:
|
||||||
"""Execute a library rescan and optionally trigger auto-download."""
|
"""Execute a library rescan via RescanService."""
|
||||||
logger.info("Scheduler _perform_rescan entered: scan_in_progress=%s", self._scan_in_progress)
|
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:
|
if self._scan_in_progress:
|
||||||
logger.warning("Skipping rescan: previous scan still in progress")
|
logger.warning("Skipping rescan: previous scan still in progress")
|
||||||
return
|
return
|
||||||
|
|
||||||
self._scan_in_progress = True
|
self._scan_in_progress = True
|
||||||
scan_start = datetime.now(timezone.utc)
|
|
||||||
logger.info("Scheduled rescan started at %s", scan_start.isoformat())
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Starting scheduled library rescan")
|
rescan_service = get_rescan_service(config=self._config)
|
||||||
|
await rescan_service.execute()
|
||||||
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()},
|
|
||||||
)
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
self._scan_in_progress = False
|
self._scan_in_progress = False
|
||||||
logger.info("Scheduled rescan finished: scan_in_progress reset to False")
|
logger.info("Scheduled rescan finished: scan_in_progress reset to False")
|
||||||
@@ -216,7 +216,7 @@ async def test_update_config_with_anime_directory_starts_scheduler(
|
|||||||
"""PUT /api/config with anime_directory syncs and starts scheduler."""
|
"""PUT /api/config with anime_directory syncs and starts scheduler."""
|
||||||
mock_scheduler = AsyncMock()
|
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
|
mock_sched_fn.return_value = mock_scheduler
|
||||||
|
|
||||||
with patch("src.config.settings.settings") as mock_settings:
|
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()."""
|
"""PUT /api/config without new anime_directory does not call scheduler.ensure_started()."""
|
||||||
mock_scheduler = AsyncMock()
|
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
|
mock_sched_fn.return_value = mock_scheduler
|
||||||
|
|
||||||
with patch("src.config.settings.settings") as mock_settings:
|
with patch("src.config.settings.settings") as mock_settings:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class TestFolderRenameScanCalledInFolderScan:
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
source = importlib.util.find_spec(
|
source = importlib.util.find_spec(
|
||||||
"src.server.services.folder_scan_service"
|
"src.server.services.scheduler.folder_scan_service"
|
||||||
).origin
|
).origin
|
||||||
with open(source, "r", encoding="utf-8") as fh:
|
with open(source, "r", encoding="utf-8") as fh:
|
||||||
content = fh.read()
|
content = fh.read()
|
||||||
@@ -31,7 +31,7 @@ class TestFolderRenameScanCalledInFolderScan:
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
source = importlib.util.find_spec(
|
source = importlib.util.find_spec(
|
||||||
"src.server.services.folder_scan_service"
|
"src.server.services.scheduler.folder_scan_service"
|
||||||
).origin
|
).origin
|
||||||
with open(source, "r", encoding="utf-8") as fh:
|
with open(source, "r", encoding="utf-8") as fh:
|
||||||
content = fh.read()
|
content = fh.read()
|
||||||
@@ -52,7 +52,7 @@ class TestFolderRenameIntegration:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_folder_rename_runs_during_scan(self, tmp_path):
|
async def test_folder_rename_runs_during_scan(self, tmp_path):
|
||||||
"""When folder_scan_enabled is true, the scan renames mismatched folders."""
|
"""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 = tmp_path / "anime"
|
||||||
anime_dir.mkdir()
|
anime_dir.mkdir()
|
||||||
@@ -69,15 +69,15 @@ class TestFolderRenameIntegration:
|
|||||||
with patch(
|
with patch(
|
||||||
"src.config.settings.settings", mock_settings
|
"src.config.settings.settings", mock_settings
|
||||||
), patch(
|
), patch(
|
||||||
"src.server.services.folder_rename_service.settings", mock_settings
|
"src.server.services.scheduler.folder_rename_service.settings", mock_settings
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
), patch(
|
), 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,
|
return_value=False,
|
||||||
), patch(
|
), patch(
|
||||||
"src.server.services.folder_rename_service._update_database_paths",
|
"src.server.services.scheduler.folder_rename_service._update_database_paths",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
):
|
):
|
||||||
service = FolderScanService()
|
service = FolderScanService()
|
||||||
@@ -89,7 +89,7 @@ class TestFolderRenameIntegration:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_folder_rename_skipped_when_prerequisites_not_met(self, tmp_path):
|
async def test_folder_rename_skipped_when_prerequisites_not_met(self, tmp_path):
|
||||||
"""If anime directory is missing, rename logic is skipped gracefully."""
|
"""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 = MagicMock()
|
||||||
mock_settings.tmdb_api_key = "test-key"
|
mock_settings.tmdb_api_key = "test-key"
|
||||||
@@ -98,10 +98,10 @@ class TestFolderRenameIntegration:
|
|||||||
with patch(
|
with patch(
|
||||||
"src.config.settings.settings", mock_settings
|
"src.config.settings.settings", mock_settings
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
), patch(
|
), 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:
|
) as mock_rename:
|
||||||
service = FolderScanService()
|
service = FolderScanService()
|
||||||
await service.run_folder_scan()
|
await service.run_folder_scan()
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class TestNfoRepairScanCalledInFolderScan:
|
|||||||
"""folder_scan_service.py imports perform_nfo_repair_scan."""
|
"""folder_scan_service.py imports perform_nfo_repair_scan."""
|
||||||
import importlib
|
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:
|
with open(source, "r", encoding="utf-8") as fh:
|
||||||
content = fh.read()
|
content = fh.read()
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ class TestNfoRepairScanCalledInFolderScan:
|
|||||||
"""perform_nfo_repair_scan must be called inside run_folder_scan."""
|
"""perform_nfo_repair_scan must be called inside run_folder_scan."""
|
||||||
import importlib
|
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:
|
with open(source, "r", encoding="utf-8") as fh:
|
||||||
content = fh.read()
|
content = fh.read()
|
||||||
|
|
||||||
@@ -67,7 +67,9 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_incomplete_nfo_series_scheduled_for_repair(self, tmp_path):
|
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."""
|
"""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 = tmp_path / "IncompleteAnime"
|
||||||
series_dir.mkdir()
|
series_dir.mkdir()
|
||||||
@@ -83,7 +85,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
|
|||||||
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.folder_scan_service._settings", mock_settings
|
"src.server.services.scheduler.folder_scan_service._settings", mock_settings
|
||||||
), patch(
|
), patch(
|
||||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
@@ -103,7 +105,9 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_complete_nfo_series_not_scheduled(self, tmp_path):
|
async def test_complete_nfo_series_not_scheduled(self, tmp_path):
|
||||||
"""Series whose tvshow.nfo has all required tags are not scheduled for repair."""
|
"""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 = tmp_path / "CompleteAnime"
|
||||||
series_dir.mkdir()
|
series_dir.mkdir()
|
||||||
@@ -116,7 +120,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
|
|||||||
mock_settings.anime_directory = str(tmp_path)
|
mock_settings.anime_directory = str(tmp_path)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.folder_scan_service._settings", mock_settings
|
"src.server.services.scheduler.folder_scan_service._settings", mock_settings
|
||||||
), patch(
|
), patch(
|
||||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class TestPosterCheckScanCalledInFolderScan:
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
source = importlib.util.find_spec(
|
source = importlib.util.find_spec(
|
||||||
"src.server.services.folder_scan_service"
|
"src.server.services.scheduler.folder_scan_service"
|
||||||
).origin
|
).origin
|
||||||
with open(source, "r", encoding="utf-8") as fh:
|
with open(source, "r", encoding="utf-8") as fh:
|
||||||
content = fh.read()
|
content = fh.read()
|
||||||
@@ -33,7 +33,7 @@ class TestPosterCheckScanCalledInFolderScan:
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
source = importlib.util.find_spec(
|
source = importlib.util.find_spec(
|
||||||
"src.server.services.folder_scan_service"
|
"src.server.services.scheduler.folder_scan_service"
|
||||||
).origin
|
).origin
|
||||||
with open(source, "r", encoding="utf-8") as fh:
|
with open(source, "r", encoding="utf-8") as fh:
|
||||||
content = fh.read()
|
content = fh.read()
|
||||||
@@ -54,7 +54,7 @@ class TestPosterCheckIntegration:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_poster_check_downloads_missing_poster(self, tmp_path):
|
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."""
|
"""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 = tmp_path / "anime"
|
||||||
anime_dir.mkdir()
|
anime_dir.mkdir()
|
||||||
@@ -91,14 +91,14 @@ class TestPosterCheckIntegration:
|
|||||||
with patch(
|
with patch(
|
||||||
"src.config.settings.settings", mock_settings
|
"src.config.settings.settings", mock_settings
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
||||||
), patch(
|
), patch(
|
||||||
"src.server.services.folder_scan_service.ImageDownloader",
|
"src.server.services.scheduler.folder_scan_service.ImageDownloader",
|
||||||
new=MockDownloader,
|
new=MockDownloader,
|
||||||
):
|
):
|
||||||
service = FolderScanService()
|
service = FolderScanService()
|
||||||
@@ -112,7 +112,7 @@ class TestPosterCheckIntegration:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_poster_check_skips_valid_poster(self, tmp_path):
|
async def test_poster_check_skips_valid_poster(self, tmp_path):
|
||||||
"""When poster.jpg exists and is large enough, the scan skips it."""
|
"""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 = tmp_path / "anime"
|
||||||
anime_dir.mkdir()
|
anime_dir.mkdir()
|
||||||
@@ -136,14 +136,14 @@ class TestPosterCheckIntegration:
|
|||||||
with patch(
|
with patch(
|
||||||
"src.config.settings.settings", mock_settings
|
"src.config.settings.settings", mock_settings
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
||||||
), patch(
|
), patch(
|
||||||
"src.server.services.folder_scan_service.ImageDownloader"
|
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
|
||||||
) as mock_downloader_cls:
|
) as mock_downloader_cls:
|
||||||
service = FolderScanService()
|
service = FolderScanService()
|
||||||
await service.run_folder_scan()
|
await service.run_folder_scan()
|
||||||
@@ -153,7 +153,7 @@ class TestPosterCheckIntegration:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_poster_check_skips_when_no_thumb_url(self, tmp_path):
|
async def test_poster_check_skips_when_no_thumb_url(self, tmp_path):
|
||||||
"""When NFO has no thumb URL, the scan skips the folder."""
|
"""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 = tmp_path / "anime"
|
||||||
anime_dir.mkdir()
|
anime_dir.mkdir()
|
||||||
@@ -173,14 +173,14 @@ class TestPosterCheckIntegration:
|
|||||||
with patch(
|
with patch(
|
||||||
"src.config.settings.settings", mock_settings
|
"src.config.settings.settings", mock_settings
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
||||||
), patch(
|
), patch(
|
||||||
"src.server.services.folder_scan_service.ImageDownloader"
|
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
|
||||||
) as mock_downloader_cls:
|
) as mock_downloader_cls:
|
||||||
service = FolderScanService()
|
service = FolderScanService()
|
||||||
await service.run_folder_scan()
|
await service.run_folder_scan()
|
||||||
@@ -190,7 +190,7 @@ class TestPosterCheckIntegration:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_poster_check_skipped_when_prerequisites_not_met(self, tmp_path):
|
async def test_poster_check_skipped_when_prerequisites_not_met(self, tmp_path):
|
||||||
"""If anime directory is missing, poster check logic is skipped gracefully."""
|
"""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 = MagicMock()
|
||||||
mock_settings.tmdb_api_key = "test-key"
|
mock_settings.tmdb_api_key = "test-key"
|
||||||
@@ -199,12 +199,12 @@ class TestPosterCheckIntegration:
|
|||||||
with patch(
|
with patch(
|
||||||
"src.config.settings.settings", mock_settings
|
"src.config.settings.settings", mock_settings
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
), patch(
|
), 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(
|
) as mock_rename, patch(
|
||||||
"src.server.services.folder_scan_service.ImageDownloader"
|
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
|
||||||
) as mock_downloader_cls:
|
) as mock_downloader_cls:
|
||||||
service = FolderScanService()
|
service = FolderScanService()
|
||||||
await service.run_folder_scan()
|
await service.run_folder_scan()
|
||||||
@@ -220,7 +220,7 @@ class TestPosterCheckSemaphore:
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
source = importlib.util.find_spec(
|
source = importlib.util.find_spec(
|
||||||
"src.server.services.folder_scan_service"
|
"src.server.services.scheduler.folder_scan_service"
|
||||||
).origin
|
).origin
|
||||||
with open(source, "r", encoding="utf-8") as fh:
|
with open(source, "r", encoding="utf-8") as fh:
|
||||||
content = fh.read()
|
content = fh.read()
|
||||||
@@ -232,7 +232,7 @@ class TestPosterCheckSemaphore:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_poster_download_uses_semaphore(self, tmp_path):
|
async def test_poster_download_uses_semaphore(self, tmp_path):
|
||||||
"""Poster downloads are gated by the semaphore."""
|
"""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,
|
_POSTER_DOWNLOAD_SEMAPHORE,
|
||||||
FolderScanService,
|
FolderScanService,
|
||||||
)
|
)
|
||||||
@@ -270,14 +270,14 @@ class TestPosterCheckSemaphore:
|
|||||||
with patch(
|
with patch(
|
||||||
"src.config.settings.settings", mock_settings
|
"src.config.settings.settings", mock_settings
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
||||||
), patch(
|
), patch(
|
||||||
"src.server.services.folder_scan_service.ImageDownloader"
|
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
|
||||||
) as mock_downloader_cls:
|
) as mock_downloader_cls:
|
||||||
mock_downloader = AsyncMock()
|
mock_downloader = AsyncMock()
|
||||||
mock_downloader.download_poster = AsyncMock(side_effect=tracked_download)
|
mock_downloader.download_poster = AsyncMock(side_effect=tracked_download)
|
||||||
|
|||||||
@@ -11,15 +11,14 @@ from unittest.mock import AsyncMock, Mock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.server.models.config import AppConfig, SchedulerConfig
|
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,
|
SchedulerService,
|
||||||
SchedulerServiceError,
|
SchedulerServiceError,
|
||||||
_JOB_ID,
|
|
||||||
get_scheduler_service,
|
get_scheduler_service,
|
||||||
reset_scheduler_service,
|
reset_scheduler_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shared fixtures
|
# Shared fixtures
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -27,7 +26,7 @@ from src.server.services.scheduler_service import (
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_config_service():
|
def mock_config_service():
|
||||||
"""Patch get_config_service used by SchedulerService.start()."""
|
"""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()
|
config_service = Mock()
|
||||||
app_config = AppConfig(
|
app_config = AppConfig(
|
||||||
scheduler=SchedulerConfig(
|
scheduler=SchedulerConfig(
|
||||||
|
|||||||
@@ -474,7 +474,7 @@ class TestSchedulerConcurrentScanPrevention:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scheduler_skips_rescan_if_already_running(self):
|
async def test_scheduler_skips_rescan_if_already_running(self):
|
||||||
"""Test scheduler skips scheduled rescan if one is already running."""
|
"""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()
|
scheduler = SchedulerService()
|
||||||
|
|
||||||
@@ -495,7 +495,7 @@ class TestSchedulerConcurrentScanPrevention:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scheduler_sets_flag_during_rescan(self):
|
async def test_scheduler_sets_flag_during_rescan(self):
|
||||||
"""Test that scheduler properly sets scan_in_progress flag."""
|
"""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()
|
scheduler = SchedulerService()
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class TestFfmpegHealthCheck:
|
|||||||
with patch("src.server.utils.dependencies.get_anime_service"):
|
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_download_service"):
|
||||||
with patch("src.server.utils.dependencies.get_background_loader_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 = MagicMock()
|
||||||
mock_sched.start = AsyncMock(return_value=None)
|
mock_sched.start = AsyncMock(return_value=None)
|
||||||
mock_get_sched.return_value = mock_sched
|
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_anime_service"):
|
||||||
with patch("src.server.utils.dependencies.get_download_service"):
|
with patch("src.server.utils.dependencies.get_download_service"):
|
||||||
with patch("src.server.utils.dependencies.get_background_loader_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 = MagicMock()
|
||||||
mock_sched.start = AsyncMock(return_value=None)
|
mock_sched.start = AsyncMock(return_value=None)
|
||||||
mock_get_sched.return_value = mock_sched
|
mock_get_sched.return_value = mock_sched
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.server.services.folder_rename_service import (
|
from src.server.services.scheduler.folder_rename_service import (
|
||||||
_cleanup_orphaned_folder,
|
_cleanup_orphaned_folder,
|
||||||
_compute_expected_folder_name,
|
_compute_expected_folder_name,
|
||||||
_is_series_being_downloaded,
|
_is_series_being_downloaded,
|
||||||
@@ -163,7 +163,7 @@ class TestIsSeriesBeingDownloaded:
|
|||||||
mock_service._active_download = None
|
mock_service._active_download = None
|
||||||
mock_service._pending_queue = []
|
mock_service._pending_queue = []
|
||||||
with patch(
|
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,
|
return_value=mock_service,
|
||||||
):
|
):
|
||||||
assert _is_series_being_downloaded("Some Show") is False
|
assert _is_series_being_downloaded("Some Show") is False
|
||||||
@@ -175,7 +175,7 @@ class TestIsSeriesBeingDownloaded:
|
|||||||
mock_service._active_download = mock_item
|
mock_service._active_download = mock_item
|
||||||
mock_service._pending_queue = []
|
mock_service._pending_queue = []
|
||||||
with patch(
|
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,
|
return_value=mock_service,
|
||||||
):
|
):
|
||||||
assert _is_series_being_downloaded("Some Show") is True
|
assert _is_series_being_downloaded("Some Show") is True
|
||||||
@@ -187,14 +187,14 @@ class TestIsSeriesBeingDownloaded:
|
|||||||
mock_service._active_download = None
|
mock_service._active_download = None
|
||||||
mock_service._pending_queue = [mock_item]
|
mock_service._pending_queue = [mock_item]
|
||||||
with patch(
|
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,
|
return_value=mock_service,
|
||||||
):
|
):
|
||||||
assert _is_series_being_downloaded("Some Show") is True
|
assert _is_series_being_downloaded("Some Show") is True
|
||||||
|
|
||||||
def test_exception_returns_true_for_safety(self) -> None:
|
def test_exception_returns_true_for_safety(self) -> None:
|
||||||
with patch(
|
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"),
|
side_effect=RuntimeError("boom"),
|
||||||
):
|
):
|
||||||
assert _is_series_being_downloaded("Some Show") is True
|
assert _is_series_being_downloaded("Some Show") is True
|
||||||
@@ -213,20 +213,20 @@ class TestUpdateDatabasePaths:
|
|||||||
mock_series.folder = "Old Name"
|
mock_series.folder = "Old Name"
|
||||||
|
|
||||||
with patch(
|
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(
|
) 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(
|
) 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(
|
) as mock_episode_svc, patch(
|
||||||
"src.server.services.folder_rename_service.DownloadQueueService"
|
"src.server.services.scheduler.folder_rename_service.DownloadQueueService"
|
||||||
) as mock_queue_svc:
|
) as mock_queue_svc:
|
||||||
|
|
||||||
mock_db = AsyncMock()
|
mock_db = AsyncMock()
|
||||||
mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db)
|
mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db)
|
||||||
mock_get_db.return_value.__aexit__ = AsyncMock(return_value=False)
|
mock_get_db.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
mock_series_svc.get_by_key = AsyncMock(return_value=mock_series)
|
mock_series_svc.get_by_folder = AsyncMock(return_value=mock_series)
|
||||||
mock_series_svc.get_all = AsyncMock(return_value=[])
|
mock_series_svc.get_all = AsyncMock(return_value=[])
|
||||||
mock_series_svc.update = AsyncMock(return_value=mock_series)
|
mock_series_svc.update = AsyncMock(return_value=mock_series)
|
||||||
|
|
||||||
@@ -254,20 +254,20 @@ class TestUpdateDatabasePaths:
|
|||||||
mock_episode.file_path = str(old_path)
|
mock_episode.file_path = str(old_path)
|
||||||
|
|
||||||
with patch(
|
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(
|
) 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(
|
) 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(
|
) as mock_episode_svc, patch(
|
||||||
"src.server.services.folder_rename_service.DownloadQueueService"
|
"src.server.services.scheduler.folder_rename_service.DownloadQueueService"
|
||||||
) as mock_queue_svc:
|
) as mock_queue_svc:
|
||||||
|
|
||||||
mock_db = AsyncMock()
|
mock_db = AsyncMock()
|
||||||
mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db)
|
mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db)
|
||||||
mock_get_db.return_value.__aexit__ = AsyncMock(return_value=False)
|
mock_get_db.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
mock_series_svc.get_by_key = AsyncMock(return_value=mock_series)
|
mock_series_svc.get_by_folder = AsyncMock(return_value=mock_series)
|
||||||
mock_series_svc.get_all = AsyncMock(return_value=[])
|
mock_series_svc.get_all = AsyncMock(return_value=[])
|
||||||
mock_series_svc.update = AsyncMock(return_value=mock_series)
|
mock_series_svc.update = AsyncMock(return_value=mock_series)
|
||||||
|
|
||||||
@@ -350,7 +350,7 @@ class TestValidateAndRenameSeriesFolders:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_anime_directory(self) -> None:
|
async def test_no_anime_directory(self) -> None:
|
||||||
with patch(
|
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()
|
stats = await validate_and_rename_series_folders()
|
||||||
@@ -367,13 +367,13 @@ class TestValidateAndRenameSeriesFolders:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.folder_rename_service.settings.anime_directory",
|
"src.server.services.scheduler.folder_rename_service.settings.anime_directory",
|
||||||
str(anime_dir),
|
str(anime_dir),
|
||||||
), patch(
|
), 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,
|
return_value=False,
|
||||||
), patch(
|
), patch(
|
||||||
"src.server.services.folder_rename_service._update_database_paths",
|
"src.server.services.scheduler.folder_rename_service._update_database_paths",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
) as mock_update_db:
|
) as mock_update_db:
|
||||||
stats = await validate_and_rename_series_folders()
|
stats = await validate_and_rename_series_folders()
|
||||||
@@ -397,7 +397,7 @@ class TestValidateAndRenameSeriesFolders:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.folder_rename_service.settings.anime_directory",
|
"src.server.services.scheduler.folder_rename_service.settings.anime_directory",
|
||||||
str(anime_dir),
|
str(anime_dir),
|
||||||
):
|
):
|
||||||
stats = await validate_and_rename_series_folders()
|
stats = await validate_and_rename_series_folders()
|
||||||
@@ -419,7 +419,7 @@ class TestValidateAndRenameSeriesFolders:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.folder_rename_service.settings.anime_directory",
|
"src.server.services.scheduler.folder_rename_service.settings.anime_directory",
|
||||||
str(anime_dir),
|
str(anime_dir),
|
||||||
):
|
):
|
||||||
stats = await validate_and_rename_series_folders()
|
stats = await validate_and_rename_series_folders()
|
||||||
@@ -440,10 +440,10 @@ class TestValidateAndRenameSeriesFolders:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.folder_rename_service.settings.anime_directory",
|
"src.server.services.scheduler.folder_rename_service.settings.anime_directory",
|
||||||
str(anime_dir),
|
str(anime_dir),
|
||||||
), patch(
|
), 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,
|
return_value=True,
|
||||||
):
|
):
|
||||||
stats = await validate_and_rename_series_folders()
|
stats = await validate_and_rename_series_folders()
|
||||||
@@ -474,20 +474,20 @@ class TestValidateAndRenameSeriesFolders:
|
|||||||
mock_db.__aexit__.return_value = None
|
mock_db.__aexit__.return_value = None
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.folder_rename_service.settings.anime_directory",
|
"src.server.services.scheduler.folder_rename_service.settings.anime_directory",
|
||||||
str(anime_dir),
|
str(anime_dir),
|
||||||
), patch(
|
), 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,
|
return_value=False,
|
||||||
), patch(
|
), patch(
|
||||||
"src.server.services.folder_rename_service.get_db_session",
|
"src.server.services.scheduler.folder_rename_service.get_db_session",
|
||||||
return_value=mock_db,
|
return_value=mock_db,
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
return_value=None,
|
return_value=None,
|
||||||
), patch(
|
), patch(
|
||||||
"src.server.services.folder_rename_service.AnimeSeriesService.get_all",
|
"src.server.services.scheduler.folder_rename_service.AnimeSeriesService.get_all",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=[],
|
return_value=[],
|
||||||
):
|
):
|
||||||
@@ -527,13 +527,13 @@ class TestValidateAndRenameSeriesFolders:
|
|||||||
(d3 / "tvshow.nfo").write_text("<tvshow><title>Show C</title></tvshow>")
|
(d3 / "tvshow.nfo").write_text("<tvshow><title>Show C</title></tvshow>")
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.folder_rename_service.settings.anime_directory",
|
"src.server.services.scheduler.folder_rename_service.settings.anime_directory",
|
||||||
str(anime_dir),
|
str(anime_dir),
|
||||||
), patch(
|
), 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,
|
return_value=False,
|
||||||
), patch(
|
), patch(
|
||||||
"src.server.services.folder_rename_service._update_database_paths",
|
"src.server.services.scheduler.folder_rename_service._update_database_paths",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
):
|
):
|
||||||
stats = await validate_and_rename_series_folders()
|
stats = await validate_and_rename_series_folders()
|
||||||
@@ -558,10 +558,10 @@ class TestValidateAndRenameSeriesFolders:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.folder_rename_service.settings.anime_directory",
|
"src.server.services.scheduler.folder_rename_service.settings.anime_directory",
|
||||||
str(anime_dir),
|
str(anime_dir),
|
||||||
), patch(
|
), 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,
|
return_value=False,
|
||||||
):
|
):
|
||||||
stats = await validate_and_rename_series_folders(dry_run=True)
|
stats = await validate_and_rename_series_folders(dry_run=True)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.server.services.folder_scan_service import (
|
from src.server.services.scheduler.folder_scan_service import (
|
||||||
_POSTER_DOWNLOAD_SEMAPHORE,
|
_POSTER_DOWNLOAD_SEMAPHORE,
|
||||||
_TMDB_SEMAPHORE,
|
_TMDB_SEMAPHORE,
|
||||||
FolderScanService,
|
FolderScanService,
|
||||||
@@ -97,7 +97,7 @@ class TestRunFolderScanPrerequisites:
|
|||||||
with patch.object(
|
with patch.object(
|
||||||
folder_scan_service, "_prerequisites_met", return_value=False
|
folder_scan_service, "_prerequisites_met", return_value=False
|
||||||
), patch(
|
), 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:
|
) as mock_repair:
|
||||||
await folder_scan_service.run_folder_scan()
|
await folder_scan_service.run_folder_scan()
|
||||||
mock_repair.assert_not_called()
|
mock_repair.assert_not_called()
|
||||||
@@ -108,10 +108,10 @@ class TestRunFolderScanPrerequisites:
|
|||||||
with patch.object(
|
with patch.object(
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
folder_scan_service, "_prerequisites_met", return_value=True
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
||||||
), patch.object(
|
), patch.object(
|
||||||
@@ -148,10 +148,10 @@ class TestNfoRepairIntegration:
|
|||||||
with patch.object(
|
with patch.object(
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
folder_scan_service, "_prerequisites_met", return_value=True
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
) as mock_repair, patch(
|
) 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,
|
new_callable=AsyncMock,
|
||||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
||||||
), patch.object(
|
), patch.object(
|
||||||
@@ -172,11 +172,11 @@ class TestNfoRepairIntegration:
|
|||||||
with patch.object(
|
with patch.object(
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
folder_scan_service, "_prerequisites_met", return_value=True
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
side_effect=RuntimeError("repair failed"),
|
side_effect=RuntimeError("repair failed"),
|
||||||
) as mock_repair, patch(
|
) 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,
|
new_callable=AsyncMock,
|
||||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
||||||
) as mock_rename, patch.object(
|
) as mock_rename, patch.object(
|
||||||
@@ -204,10 +204,10 @@ class TestFolderRenameIntegration:
|
|||||||
with patch.object(
|
with patch.object(
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
folder_scan_service, "_prerequisites_met", return_value=True
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
return_value={"scanned": 5, "renamed": 2, "skipped": 2, "errors": 1},
|
return_value={"scanned": 5, "renamed": 2, "skipped": 2, "errors": 1},
|
||||||
) as mock_rename, patch.object(
|
) as mock_rename, patch.object(
|
||||||
@@ -228,10 +228,10 @@ class TestFolderRenameIntegration:
|
|||||||
with patch.object(
|
with patch.object(
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
folder_scan_service, "_prerequisites_met", return_value=True
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
side_effect=RuntimeError("rename failed"),
|
side_effect=RuntimeError("rename failed"),
|
||||||
), patch.object(
|
), patch.object(
|
||||||
@@ -344,7 +344,7 @@ class TestPosterCheck:
|
|||||||
mock_settings.nfo_download_poster = True
|
mock_settings.nfo_download_poster = True
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.folder_scan_service.ImageDownloader",
|
"src.server.services.scheduler.folder_scan_service.ImageDownloader",
|
||||||
return_value=mock_downloader,
|
return_value=mock_downloader,
|
||||||
):
|
):
|
||||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||||
@@ -423,7 +423,7 @@ class TestPosterCheck:
|
|||||||
mock_settings.nfo_download_poster = True
|
mock_settings.nfo_download_poster = True
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.folder_scan_service.ImageDownloader",
|
"src.server.services.scheduler.folder_scan_service.ImageDownloader",
|
||||||
return_value=mock_downloader,
|
return_value=mock_downloader,
|
||||||
):
|
):
|
||||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||||
@@ -456,7 +456,7 @@ class TestPosterCheck:
|
|||||||
mock_settings.nfo_download_poster = True
|
mock_settings.nfo_download_poster = True
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.folder_scan_service.ImageDownloader",
|
"src.server.services.scheduler.folder_scan_service.ImageDownloader",
|
||||||
return_value=mock_downloader,
|
return_value=mock_downloader,
|
||||||
):
|
):
|
||||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||||
@@ -491,7 +491,7 @@ class TestPosterCheck:
|
|||||||
mock_settings.nfo_download_poster = True
|
mock_settings.nfo_download_poster = True
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.folder_scan_service.ImageDownloader",
|
"src.server.services.scheduler.folder_scan_service.ImageDownloader",
|
||||||
return_value=mock_downloader,
|
return_value=mock_downloader,
|
||||||
):
|
):
|
||||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||||
@@ -569,10 +569,10 @@ class TestRunFolderScanFull:
|
|||||||
with patch.object(
|
with patch.object(
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
folder_scan_service, "_prerequisites_met", return_value=True
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
) as mock_repair, patch(
|
) 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,
|
new_callable=AsyncMock,
|
||||||
return_value={"scanned": 3, "renamed": 1, "skipped": 1, "errors": 1},
|
return_value={"scanned": 3, "renamed": 1, "skipped": 1, "errors": 1},
|
||||||
) as mock_rename, patch.object(
|
) as mock_rename, patch.object(
|
||||||
@@ -593,10 +593,10 @@ class TestRunFolderScanFull:
|
|||||||
with patch.object(
|
with patch.object(
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
folder_scan_service, "_prerequisites_met", return_value=True
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
), patch(
|
), 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,
|
new_callable=AsyncMock,
|
||||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
||||||
), patch.object(
|
), patch.object(
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from unittest.mock import AsyncMock, MagicMock, call, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.server.services.folder_scan_service import perform_nfo_repair_scan
|
|
||||||
from src.server.services.initialization_service import (
|
from src.server.services.initialization_service import (
|
||||||
_check_initial_scan_status,
|
_check_initial_scan_status,
|
||||||
_check_media_scan_status,
|
_check_media_scan_status,
|
||||||
@@ -30,6 +29,7 @@ from src.server.services.initialization_service import (
|
|||||||
perform_media_scan_if_needed,
|
perform_media_scan_if_needed,
|
||||||
perform_nfo_scan_if_needed,
|
perform_nfo_scan_if_needed,
|
||||||
)
|
)
|
||||||
|
from src.server.services.scheduler.folder_scan_service import perform_nfo_repair_scan
|
||||||
|
|
||||||
|
|
||||||
class TestCheckScanStatus:
|
class TestCheckScanStatus:
|
||||||
@@ -771,7 +771,7 @@ class TestPerformNfoRepairScan:
|
|||||||
mock_settings.anime_directory = str(tmp_path)
|
mock_settings.anime_directory = str(tmp_path)
|
||||||
|
|
||||||
with patch(
|
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()
|
await perform_nfo_repair_scan()
|
||||||
|
|
||||||
@@ -785,7 +785,7 @@ class TestPerformNfoRepairScan:
|
|||||||
mock_settings.anime_directory = ""
|
mock_settings.anime_directory = ""
|
||||||
|
|
||||||
with patch(
|
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()
|
await perform_nfo_repair_scan()
|
||||||
|
|
||||||
@@ -805,7 +805,7 @@ class TestPerformNfoRepairScan:
|
|||||||
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.folder_scan_service._settings", mock_settings
|
"src.server.services.scheduler.folder_scan_service._settings", mock_settings
|
||||||
), patch(
|
), patch(
|
||||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
@@ -838,7 +838,7 @@ class TestPerformNfoRepairScan:
|
|||||||
mock_settings.anime_directory = str(tmp_path)
|
mock_settings.anime_directory = str(tmp_path)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.folder_scan_service._settings", mock_settings
|
"src.server.services.scheduler.folder_scan_service._settings", mock_settings
|
||||||
), patch(
|
), patch(
|
||||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
@@ -868,7 +868,7 @@ class TestPerformNfoRepairScan:
|
|||||||
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.folder_scan_service._settings", mock_settings
|
"src.server.services.scheduler.folder_scan_service._settings", mock_settings
|
||||||
), patch(
|
), patch(
|
||||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.server.services.key_resolution_service import (
|
from src.server.services.scheduler.key_resolution_service import (
|
||||||
_extract_key_from_link,
|
_extract_key_from_link,
|
||||||
_extract_year_from_folder,
|
_extract_year_from_folder,
|
||||||
_normalize_for_comparison,
|
_normalize_for_comparison,
|
||||||
@@ -104,7 +104,7 @@ class TestResolveKeyForFolder:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.key_resolution_service._search_provider",
|
"src.server.services.scheduler.key_resolution_service._search_provider",
|
||||||
return_value=search_results,
|
return_value=search_results,
|
||||||
):
|
):
|
||||||
key = await resolve_key_for_folder("Rent-A-Girlfriend (2020)")
|
key = await resolve_key_for_folder("Rent-A-Girlfriend (2020)")
|
||||||
@@ -114,7 +114,7 @@ class TestResolveKeyForFolder:
|
|||||||
async def test_no_results_returns_none(self):
|
async def test_no_results_returns_none(self):
|
||||||
"""When provider returns no results, returns None."""
|
"""When provider returns no results, returns None."""
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.key_resolution_service._search_provider",
|
"src.server.services.scheduler.key_resolution_service._search_provider",
|
||||||
return_value=[],
|
return_value=[],
|
||||||
):
|
):
|
||||||
key = await resolve_key_for_folder("Unknown Anime (2020)")
|
key = await resolve_key_for_folder("Unknown Anime (2020)")
|
||||||
@@ -129,7 +129,7 @@ class TestResolveKeyForFolder:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.key_resolution_service._search_provider",
|
"src.server.services.scheduler.key_resolution_service._search_provider",
|
||||||
return_value=search_results,
|
return_value=search_results,
|
||||||
):
|
):
|
||||||
key = await resolve_key_for_folder("My Anime (2022)")
|
key = await resolve_key_for_folder("My Anime (2022)")
|
||||||
@@ -144,7 +144,7 @@ class TestResolveKeyForFolder:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.key_resolution_service._search_provider",
|
"src.server.services.scheduler.key_resolution_service._search_provider",
|
||||||
return_value=search_results,
|
return_value=search_results,
|
||||||
):
|
):
|
||||||
key = await resolve_key_for_folder("Rent-A-Girlfriend (2020)")
|
key = await resolve_key_for_folder("Rent-A-Girlfriend (2020)")
|
||||||
@@ -158,7 +158,7 @@ class TestResolveKeyForFolder:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.key_resolution_service._search_provider",
|
"src.server.services.scheduler.key_resolution_service._search_provider",
|
||||||
return_value=search_results,
|
return_value=search_results,
|
||||||
):
|
):
|
||||||
key = await resolve_key_for_folder("Naruto (2002)")
|
key = await resolve_key_for_folder("Naruto (2002)")
|
||||||
@@ -168,7 +168,7 @@ class TestResolveKeyForFolder:
|
|||||||
async def test_provider_error_returns_none(self):
|
async def test_provider_error_returns_none(self):
|
||||||
"""When provider search raises an exception, returns None gracefully."""
|
"""When provider search raises an exception, returns None gracefully."""
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.key_resolution_service._search_provider",
|
"src.server.services.scheduler.key_resolution_service._search_provider",
|
||||||
side_effect=RuntimeError("Network error"),
|
side_effect=RuntimeError("Network error"),
|
||||||
):
|
):
|
||||||
key = await resolve_key_for_folder("Some Anime (2020)")
|
key = await resolve_key_for_folder("Some Anime (2020)")
|
||||||
@@ -182,7 +182,7 @@ class TestResolveKeyForFolder:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.key_resolution_service._search_provider",
|
"src.server.services.scheduler.key_resolution_service._search_provider",
|
||||||
return_value=search_results,
|
return_value=search_results,
|
||||||
):
|
):
|
||||||
key = await resolve_key_for_folder("One Piece (1999)")
|
key = await resolve_key_for_folder("One Piece (1999)")
|
||||||
@@ -196,7 +196,7 @@ class TestResolveKeyForFolder:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.key_resolution_service._search_provider",
|
"src.server.services.scheduler.key_resolution_service._search_provider",
|
||||||
return_value=search_results,
|
return_value=search_results,
|
||||||
):
|
):
|
||||||
key = await resolve_key_for_folder("Naruto")
|
key = await resolve_key_for_folder("Naruto")
|
||||||
@@ -211,7 +211,7 @@ class TestResolveKeyForFolder:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.key_resolution_service._search_provider",
|
"src.server.services.scheduler.key_resolution_service._search_provider",
|
||||||
return_value=search_results,
|
return_value=search_results,
|
||||||
):
|
):
|
||||||
key = await resolve_key_for_folder("Dororo (2019)")
|
key = await resolve_key_for_folder("Dororo (2019)")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import pytest
|
|||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
from src.server.models.config import AppConfig, SchedulerConfig
|
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,
|
_JOB_ID,
|
||||||
SchedulerService,
|
SchedulerService,
|
||||||
SchedulerServiceError,
|
SchedulerServiceError,
|
||||||
@@ -36,7 +36,7 @@ def _make_app_config(**scheduler_kwargs) -> AppConfig:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_config_service():
|
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 = Mock()
|
||||||
svc.load_config.return_value = _make_app_config(
|
svc.load_config.return_value = _make_app_config(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
@@ -105,7 +105,7 @@ class TestStart:
|
|||||||
self, scheduler_service, mock_config_service
|
self, scheduler_service, mock_config_service
|
||||||
):
|
):
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
"src.server.services.scheduler.scheduler_service.AsyncIOScheduler"
|
||||||
) as MockScheduler:
|
) as MockScheduler:
|
||||||
mock_sched = MagicMock()
|
mock_sched = MagicMock()
|
||||||
mock_sched.running = False
|
mock_sched.running = False
|
||||||
@@ -137,9 +137,9 @@ class TestStartEmptyDays:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_job_added_when_days_empty(self, scheduler_service):
|
async def test_no_job_added_when_days_empty(self, scheduler_service):
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.scheduler_service.get_config_service"
|
"src.server.services.scheduler.scheduler_service.get_config_service"
|
||||||
) as mock_cs, patch(
|
) as mock_cs, patch(
|
||||||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
"src.server.services.scheduler.scheduler_service.AsyncIOScheduler"
|
||||||
) as MockScheduler:
|
) as MockScheduler:
|
||||||
svc = Mock()
|
svc = Mock()
|
||||||
svc.load_config.return_value = _make_app_config(
|
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), \
|
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.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
|
MockFSS.return_value.run_folder_scan = mock_folder_scan
|
||||||
await scheduler_service._perform_rescan()
|
await scheduler_service._perform_rescan()
|
||||||
|
|
||||||
@@ -434,7 +434,7 @@ class TestPerformRescanFolderScan:
|
|||||||
|
|
||||||
with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
|
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.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
|
MockFSS.return_value.run_folder_scan = mock_folder_scan
|
||||||
await scheduler_service._perform_rescan()
|
await scheduler_service._perform_rescan()
|
||||||
|
|
||||||
@@ -459,7 +459,7 @@ class TestPerformRescanFolderScan:
|
|||||||
|
|
||||||
with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
|
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.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
|
MockFSS.return_value.run_folder_scan = mock_folder_scan
|
||||||
# Should NOT raise
|
# Should NOT raise
|
||||||
await scheduler_service._perform_rescan()
|
await scheduler_service._perform_rescan()
|
||||||
@@ -498,7 +498,7 @@ class TestInMemoryJobStore:
|
|||||||
self, scheduler_service, mock_config_service
|
self, scheduler_service, mock_config_service
|
||||||
):
|
):
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
"src.server.services.scheduler.scheduler_service.AsyncIOScheduler"
|
||||||
) as MockScheduler:
|
) as MockScheduler:
|
||||||
mock_sched = MagicMock()
|
mock_sched = MagicMock()
|
||||||
mock_sched.running = False
|
mock_sched.running = False
|
||||||
@@ -517,7 +517,7 @@ class TestInMemoryJobStore:
|
|||||||
self, scheduler_service, mock_config_service
|
self, scheduler_service, mock_config_service
|
||||||
):
|
):
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
"src.server.services.scheduler.scheduler_service.AsyncIOScheduler"
|
||||||
) as MockScheduler:
|
) as MockScheduler:
|
||||||
mock_sched = MagicMock()
|
mock_sched = MagicMock()
|
||||||
mock_sched.running = False
|
mock_sched.running = False
|
||||||
@@ -540,7 +540,7 @@ class TestStartupRecovery:
|
|||||||
self, scheduler_service, mock_config_service
|
self, scheduler_service, mock_config_service
|
||||||
):
|
):
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
"src.server.services.scheduler.scheduler_service.AsyncIOScheduler"
|
||||||
) as MockScheduler:
|
) as MockScheduler:
|
||||||
mock_job = MagicMock()
|
mock_job = MagicMock()
|
||||||
next_run_dt = datetime(2026, 5, 25, 3, 0, tzinfo=timezone.utc)
|
next_run_dt = datetime(2026, 5, 25, 3, 0, tzinfo=timezone.utc)
|
||||||
@@ -551,7 +551,7 @@ class TestStartupRecovery:
|
|||||||
MockScheduler.return_value = mock_sched
|
MockScheduler.return_value = mock_sched
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.scheduler_service.logger"
|
"src.server.services.scheduler.scheduler_service.logger"
|
||||||
) as mock_logger:
|
) as mock_logger:
|
||||||
await scheduler_service.start()
|
await scheduler_service.start()
|
||||||
info_calls = [str(c) for c in mock_logger.info.call_args_list]
|
info_calls = [str(c) for c in mock_logger.info.call_args_list]
|
||||||
|
|||||||
Reference in New Issue
Block a user