backzup
This commit is contained in:
@@ -76,8 +76,6 @@ async def setup_auth(req: SetupRequest):
|
|||||||
config.scheduler.schedule_days = req.scheduler_schedule_days
|
config.scheduler.schedule_days = req.scheduler_schedule_days
|
||||||
if req.scheduler_auto_download_after_rescan is not None:
|
if req.scheduler_auto_download_after_rescan is not None:
|
||||||
config.scheduler.auto_download_after_rescan = req.scheduler_auto_download_after_rescan
|
config.scheduler.auto_download_after_rescan = req.scheduler_auto_download_after_rescan
|
||||||
if req.scheduler_folder_scan_enabled is not None:
|
|
||||||
config.scheduler.folder_scan_enabled = req.scheduler_folder_scan_enabled
|
|
||||||
|
|
||||||
# Update logging configuration
|
# Update logging configuration
|
||||||
if req.logging_level:
|
if req.logging_level:
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ def _build_response(config: SchedulerConfig) -> Dict[str, Any]:
|
|||||||
"schedule_time": config.schedule_time,
|
"schedule_time": config.schedule_time,
|
||||||
"schedule_days": config.schedule_days,
|
"schedule_days": config.schedule_days,
|
||||||
"auto_download_after_rescan": config.auto_download_after_rescan,
|
"auto_download_after_rescan": config.auto_download_after_rescan,
|
||||||
"folder_scan_enabled": config.folder_scan_enabled,
|
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"is_running": runtime.get("is_running", False),
|
"is_running": runtime.get("is_running", False),
|
||||||
|
|||||||
@@ -73,9 +73,6 @@ class SetupRequest(BaseModel):
|
|||||||
scheduler_auto_download_after_rescan: Optional[bool] = Field(
|
scheduler_auto_download_after_rescan: Optional[bool] = Field(
|
||||||
default=False, description="Auto-download missing episodes after rescan"
|
default=False, description="Auto-download missing episodes after rescan"
|
||||||
)
|
)
|
||||||
scheduler_folder_scan_enabled: Optional[bool] = Field(
|
|
||||||
default=False, description="Run folder maintenance during scheduled run"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Logging configuration
|
# Logging configuration
|
||||||
logging_level: Optional[str] = Field(
|
logging_level: Optional[str] = Field(
|
||||||
|
|||||||
@@ -39,14 +39,8 @@ class SchedulerConfig(BaseModel):
|
|||||||
description="Automatically queue and start downloads for all missing "
|
description="Automatically queue and start downloads for all missing "
|
||||||
"episodes after a scheduled rescan completes.",
|
"episodes after a scheduled rescan completes.",
|
||||||
)
|
)
|
||||||
folder_scan_enabled: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="Run folder maintenance (NFO repair, folder renaming, "
|
|
||||||
"poster checks) during the scheduled run.",
|
|
||||||
)
|
|
||||||
# Legacy alias fields — read via Pydantic alias
|
# Legacy alias fields — read via Pydantic alias
|
||||||
auto_download: Optional[bool] = Field(default=None, alias="auto_download")
|
auto_download: Optional[bool] = Field(default=None, alias="auto_download")
|
||||||
folder_scan: Optional[bool] = Field(default=None, alias="folder_scan")
|
|
||||||
|
|
||||||
def __init__(self, **data):
|
def __init__(self, **data):
|
||||||
super().__init__(**data)
|
super().__init__(**data)
|
||||||
@@ -54,8 +48,6 @@ class SchedulerConfig(BaseModel):
|
|||||||
# "key in data" checks for explicit presence (even False/None), not just truthiness.
|
# "key in data" checks for explicit presence (even False/None), not just truthiness.
|
||||||
if self.auto_download is not None and "auto_download_after_rescan" not in data:
|
if self.auto_download is not None and "auto_download_after_rescan" not in data:
|
||||||
object.__setattr__(self, "auto_download_after_rescan", self.auto_download)
|
object.__setattr__(self, "auto_download_after_rescan", self.auto_download)
|
||||||
if self.folder_scan is not None and "folder_scan_enabled" not in data:
|
|
||||||
object.__setattr__(self, "folder_scan_enabled", self.folder_scan)
|
|
||||||
|
|
||||||
@field_validator("schedule_time")
|
@field_validator("schedule_time")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -1,301 +0,0 @@
|
|||||||
"""Folder scan service for daily maintenance tasks.
|
|
||||||
|
|
||||||
Encapsulates the daily folder-scan logic (orphaned-file detection,
|
|
||||||
metadata refresh, and missing-episode queuing) so that the scheduler
|
|
||||||
remains clean and the scan can be tested independently.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import structlog
|
|
||||||
from lxml import etree
|
|
||||||
|
|
||||||
from src.config.settings import settings as _settings
|
|
||||||
from src.server.utils.image_downloader import ImageDownloader
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
# Module-level semaphore to limit concurrent TMDB operations to 3.
|
|
||||||
_TMDB_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
|
|
||||||
|
|
||||||
# Semaphore to limit concurrent poster image downloads to 3.
|
|
||||||
_POSTER_DOWNLOAD_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
|
|
||||||
|
|
||||||
# Semaphore to limit concurrent NFO repair TMDB operations to 3.
|
|
||||||
_NFO_REPAIR_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_missing_nfo(series_dir: Path, series_name: str) -> None:
|
|
||||||
"""Create minimal NFO for series without one.
|
|
||||||
|
|
||||||
Note: NFO service removed. This function is now a no-op stub.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def _repair_one_series(series_dir: Path, series_name: str) -> None:
|
|
||||||
"""Repair a single series NFO in isolation.
|
|
||||||
|
|
||||||
Note: NFO service removed. This function is now a no-op stub.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def perform_nfo_repair_scan(background_loader=None) -> None:
|
|
||||||
"""Scan all series folders, repair incomplete and create missing NFO files.
|
|
||||||
|
|
||||||
Note: NFO service removed. This function is now a no-op stub.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
background_loader: Unused. Kept to avoid breaking call-sites.
|
|
||||||
"""
|
|
||||||
logger.info("NFO repair scan skipped — NFO service removed")
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
class FolderScanServiceError(Exception):
|
|
||||||
"""Service-level exception for folder-scan operations."""
|
|
||||||
|
|
||||||
|
|
||||||
class FolderScanService:
|
|
||||||
"""Performs daily maintenance scans over the anime library folder.
|
|
||||||
|
|
||||||
The service is intentionally stateless; a new instance can be created
|
|
||||||
for every scheduled invocation or test case.
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def run_folder_scan(self) -> None:
|
|
||||||
"""Execute the daily folder scan.
|
|
||||||
|
|
||||||
Checks prerequisites, logs progress, and delegates to sub-task
|
|
||||||
helpers. Any unhandled exception is caught and logged so the
|
|
||||||
scheduler task never crashes.
|
|
||||||
"""
|
|
||||||
logger.info("Folder scan started")
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not self._prerequisites_met():
|
|
||||||
return
|
|
||||||
|
|
||||||
# 1.3 — Repair incomplete NFO files (synchronous, waits for completion).
|
|
||||||
# Note: NFO repair removed - NFO service no longer exists
|
|
||||||
logger.info("NFO repair scan skipped — NFO service removed")
|
|
||||||
|
|
||||||
# 1.4 — Validate and rename series folders after NFO repair.
|
|
||||||
# Note: folder_rename_service removed - skip entirely
|
|
||||||
logger.info("Folder rename validation skipped — service removed")
|
|
||||||
|
|
||||||
# 1.5 — Check and download missing poster.jpg files.
|
|
||||||
logger.info("Starting poster check")
|
|
||||||
poster_stats = await self.check_and_download_missing_posters()
|
|
||||||
logger.info(
|
|
||||||
"Poster check complete",
|
|
||||||
scanned=poster_stats["scanned"],
|
|
||||||
downloaded=poster_stats["downloaded"],
|
|
||||||
skipped=poster_stats["skipped"],
|
|
||||||
errors=poster_stats["errors"],
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Folder scan completed")
|
|
||||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
||||||
logger.error("Folder scan failed", error=str(exc), exc_info=True)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Poster check helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def check_and_download_missing_posters(self) -> dict[str, int]:
|
|
||||||
"""Iterate over series folders and download missing poster.jpg files.
|
|
||||||
|
|
||||||
For each folder containing a ``tvshow.nfo``:
|
|
||||||
1. Check if ``poster.jpg`` exists and is at least
|
|
||||||
:attr:`ImageDownloader.min_file_size` bytes.
|
|
||||||
2. If missing or too small, parse ``tvshow.nfo`` for a ``<thumb>``
|
|
||||||
URL (preferring ``aspect="poster"``).
|
|
||||||
3. Download the image via :class:`ImageDownloader` under a
|
|
||||||
semaphore that limits concurrency to 3.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with counts:
|
|
||||||
- ``"scanned"``: total folders scanned
|
|
||||||
- ``"downloaded"``: posters successfully downloaded
|
|
||||||
- ``"skipped"``: folders skipped (no NFO, no thumb URL,
|
|
||||||
or poster already valid)
|
|
||||||
- ``"errors"``: folders that caused a download error
|
|
||||||
"""
|
|
||||||
from src.config.settings import settings # noqa: PLC0415
|
|
||||||
|
|
||||||
stats = {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
|
|
||||||
|
|
||||||
if not settings.anime_directory:
|
|
||||||
logger.warning("Poster check skipped — anime directory not configured")
|
|
||||||
return stats
|
|
||||||
|
|
||||||
anime_dir = Path(settings.anime_directory)
|
|
||||||
if not anime_dir.is_dir():
|
|
||||||
logger.warning(
|
|
||||||
"Poster check skipped — anime directory not found: %s", anime_dir
|
|
||||||
)
|
|
||||||
return stats
|
|
||||||
|
|
||||||
# Gather all series directories that contain a tvshow.nfo
|
|
||||||
series_dirs = [
|
|
||||||
d for d in anime_dir.iterdir()
|
|
||||||
if d.is_dir() and (d / "tvshow.nfo").exists()
|
|
||||||
]
|
|
||||||
|
|
||||||
if not series_dirs:
|
|
||||||
logger.debug("No series folders found for poster check")
|
|
||||||
return stats
|
|
||||||
|
|
||||||
# Process each series folder concurrently with semaphore
|
|
||||||
tasks = [
|
|
||||||
self._check_and_download_poster(series_dir, stats)
|
|
||||||
for series_dir in series_dirs
|
|
||||||
]
|
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
|
||||||
|
|
||||||
return stats
|
|
||||||
|
|
||||||
async def _check_and_download_poster(
|
|
||||||
self, series_dir: Path, stats: dict[str, int]
|
|
||||||
) -> None:
|
|
||||||
"""Check and download poster for a single series folder.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
series_dir: Path to the series folder.
|
|
||||||
stats: Mutable stats dictionary to update.
|
|
||||||
"""
|
|
||||||
stats["scanned"] += 1
|
|
||||||
poster_path = series_dir / "poster.jpg"
|
|
||||||
|
|
||||||
# Check if poster already exists and is large enough
|
|
||||||
if poster_path.exists():
|
|
||||||
try:
|
|
||||||
# Default min_file_size from ImageDownloader is 1024 bytes (1 KB)
|
|
||||||
if poster_path.stat().st_size >= 1024:
|
|
||||||
logger.debug(
|
|
||||||
"Poster already valid for '%s'", series_dir.name
|
|
||||||
)
|
|
||||||
stats["skipped"] += 1
|
|
||||||
return
|
|
||||||
except OSError:
|
|
||||||
pass # Fall through to re-download
|
|
||||||
|
|
||||||
# Parse NFO for thumb URL
|
|
||||||
nfo_path = series_dir / "tvshow.nfo"
|
|
||||||
poster_url = self._extract_poster_url_from_nfo(nfo_path)
|
|
||||||
|
|
||||||
if not poster_url:
|
|
||||||
logger.info(
|
|
||||||
"No poster URL found in NFO for '%s', skipping",
|
|
||||||
series_dir.name,
|
|
||||||
)
|
|
||||||
stats["skipped"] += 1
|
|
||||||
return
|
|
||||||
|
|
||||||
# Respect the nfo_download_poster setting
|
|
||||||
from src.config.settings import settings as app_settings # noqa: PLC0415
|
|
||||||
|
|
||||||
if not app_settings.nfo_download_poster:
|
|
||||||
logger.debug(
|
|
||||||
"Poster download disabled by nfo_download_poster setting for '%s'",
|
|
||||||
series_dir.name,
|
|
||||||
)
|
|
||||||
stats["skipped"] += 1
|
|
||||||
return
|
|
||||||
|
|
||||||
# Download poster with semaphore
|
|
||||||
async with _POSTER_DOWNLOAD_SEMAPHORE:
|
|
||||||
try:
|
|
||||||
async with ImageDownloader() as downloader:
|
|
||||||
success = await downloader.download_poster(
|
|
||||||
poster_url, series_dir, skip_existing=False
|
|
||||||
)
|
|
||||||
if success:
|
|
||||||
logger.info(
|
|
||||||
"Downloaded poster for '%s'", series_dir.name
|
|
||||||
)
|
|
||||||
stats["downloaded"] += 1
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to download poster for '%s'", series_dir.name
|
|
||||||
)
|
|
||||||
stats["errors"] += 1
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
|
||||||
logger.error(
|
|
||||||
"Error downloading poster for '%s': %s",
|
|
||||||
series_dir.name,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
stats["errors"] += 1
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_poster_url_from_nfo(nfo_path: Path) -> Optional[str]:
|
|
||||||
"""Parse tvshow.nfo and extract the poster thumb URL.
|
|
||||||
|
|
||||||
Prefers ``<thumb aspect="poster">``; falls back to the first
|
|
||||||
``<thumb>`` element if no aspect attribute is present.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
nfo_path: Absolute path to the ``tvshow.nfo`` file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The poster URL string, or ``None`` if not found.
|
|
||||||
"""
|
|
||||||
if not nfo_path.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
tree = etree.parse(str(nfo_path))
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
# Prefer thumb with aspect="poster"
|
|
||||||
for thumb in root.findall(".//thumb"):
|
|
||||||
if thumb.get("aspect") == "poster" and thumb.text:
|
|
||||||
return thumb.text.strip()
|
|
||||||
|
|
||||||
# Fallback to first thumb with text
|
|
||||||
for thumb in root.findall(".//thumb"):
|
|
||||||
if thumb.text:
|
|
||||||
return thumb.text.strip()
|
|
||||||
|
|
||||||
return None
|
|
||||||
except etree.XMLSyntaxError:
|
|
||||||
logger.warning("Malformed XML in %s", nfo_path)
|
|
||||||
return None
|
|
||||||
except Exception: # pylint: disable=broad-except
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Private helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _prerequisites_met(self) -> bool:
|
|
||||||
"""Verify that the environment is ready for a folder scan.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True when ``settings.anime_directory`` exists and
|
|
||||||
``settings.tmdb_api_key`` is configured.
|
|
||||||
"""
|
|
||||||
from src.config.settings import settings # noqa: PLC0415
|
|
||||||
|
|
||||||
if not settings.tmdb_api_key:
|
|
||||||
logger.warning("Folder scan skipped — TMDB API key not configured")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not settings.anime_directory:
|
|
||||||
logger.warning("Folder scan skipped — anime directory not configured")
|
|
||||||
return False
|
|
||||||
|
|
||||||
anime_dir = Path(settings.anime_directory)
|
|
||||||
if not anime_dir.is_dir():
|
|
||||||
logger.warning(
|
|
||||||
"Folder scan skipped — anime directory not found: %s", anime_dir
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
@@ -90,12 +90,11 @@ class SchedulerService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Scheduler config loaded: enabled=%s time=%s days=%s auto_download=%s folder_scan=%s",
|
"Scheduler config loaded: enabled=%s time=%s days=%s auto_download=%s",
|
||||||
self._config.enabled,
|
self._config.enabled,
|
||||||
self._config.schedule_time,
|
self._config.schedule_time,
|
||||||
self._config.schedule_days,
|
self._config.schedule_days,
|
||||||
self._config.auto_download_after_rescan,
|
self._config.auto_download_after_rescan,
|
||||||
self._config.folder_scan_enabled,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
trigger = self._build_cron_trigger()
|
trigger = self._build_cron_trigger()
|
||||||
@@ -197,12 +196,11 @@ class SchedulerService:
|
|||||||
"""
|
"""
|
||||||
self._config = config
|
self._config = config
|
||||||
logger.info(
|
logger.info(
|
||||||
"Scheduler config reloaded: enabled=%s time=%s days=%s auto_download=%s folder_scan=%s",
|
"Scheduler config reloaded: enabled=%s time=%s days=%s auto_download=%s",
|
||||||
config.enabled,
|
config.enabled,
|
||||||
config.schedule_time,
|
config.schedule_time,
|
||||||
config.schedule_days,
|
config.schedule_days,
|
||||||
config.auto_download_after_rescan,
|
config.auto_download_after_rescan,
|
||||||
config.folder_scan_enabled,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not self._scheduler or not self._scheduler.running:
|
if not self._scheduler or not self._scheduler.running:
|
||||||
@@ -263,9 +261,6 @@ class SchedulerService:
|
|||||||
"auto_download_after_rescan": (
|
"auto_download_after_rescan": (
|
||||||
self._config.auto_download_after_rescan if self._config else False
|
self._config.auto_download_after_rescan if self._config else False
|
||||||
),
|
),
|
||||||
"folder_scan_enabled": (
|
|
||||||
self._config.folder_scan_enabled if self._config else False
|
|
||||||
),
|
|
||||||
"last_run": (
|
"last_run": (
|
||||||
self._last_scan_time.isoformat()
|
self._last_scan_time.isoformat()
|
||||||
if self._last_scan_time
|
if self._last_scan_time
|
||||||
@@ -389,14 +384,6 @@ class SchedulerService:
|
|||||||
logger.error("Auto-download failed: %s", exc, exc_info=True)
|
logger.error("Auto-download failed: %s", exc, exc_info=True)
|
||||||
await self._broadcast("auto_download_error", {"error": str(exc)})
|
await self._broadcast("auto_download_error", {"error": str(exc)})
|
||||||
|
|
||||||
# 3. Folder scan (if enabled)
|
|
||||||
if self._config and self._config.folder_scan_enabled:
|
|
||||||
try:
|
|
||||||
await self._run_folder_scan()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Folder scan failed: %s", exc, exc_info=True)
|
|
||||||
await self._broadcast("folder_scan_error", {"error": str(exc)})
|
|
||||||
|
|
||||||
self._last_scan_time = datetime.now(timezone.utc)
|
self._last_scan_time = datetime.now(timezone.utc)
|
||||||
duration = (self._last_scan_time - scan_start).total_seconds()
|
duration = (self._last_scan_time - scan_start).total_seconds()
|
||||||
|
|
||||||
@@ -494,14 +481,6 @@ class SchedulerService:
|
|||||||
logger.info("Auto-download completed: queued_count=%d", queued_count)
|
logger.info("Auto-download completed: queued_count=%d", queued_count)
|
||||||
return queued_count
|
return queued_count
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
async def _broadcast(self, event_type: str, data: dict) -> None:
|
async def _broadcast(self, event_type: str, data: dict) -> None:
|
||||||
"""Broadcast a WebSocket event to all connected clients."""
|
"""Broadcast a WebSocket event to all connected clients."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1561,8 +1561,6 @@ class AniWorldApp {
|
|||||||
document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled;
|
document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled;
|
||||||
document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00';
|
document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00';
|
||||||
document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan;
|
document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan;
|
||||||
const folderScanEl = document.getElementById('folder-scan-enabled');
|
|
||||||
if (folderScanEl) folderScanEl.checked = !!config.folder_scan_enabled;
|
|
||||||
|
|
||||||
// Update day-of-week checkboxes
|
// Update day-of-week checkboxes
|
||||||
const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun'];
|
const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun'];
|
||||||
@@ -1605,8 +1603,6 @@ class AniWorldApp {
|
|||||||
const enabled = document.getElementById('scheduled-rescan-enabled').checked;
|
const enabled = document.getElementById('scheduled-rescan-enabled').checked;
|
||||||
const scheduleTime = document.getElementById('scheduled-rescan-time').value || '03:00';
|
const scheduleTime = document.getElementById('scheduled-rescan-time').value || '03:00';
|
||||||
const autoDownload = document.getElementById('auto-download-after-rescan').checked;
|
const autoDownload = document.getElementById('auto-download-after-rescan').checked;
|
||||||
const folderScanEl = document.getElementById('folder-scan-enabled');
|
|
||||||
const folderScan = folderScanEl ? folderScanEl.checked : false;
|
|
||||||
|
|
||||||
// Collect checked day-of-week values
|
// Collect checked day-of-week values
|
||||||
const scheduleDays = ['mon','tue','wed','thu','fri','sat','sun']
|
const scheduleDays = ['mon','tue','wed','thu','fri','sat','sun']
|
||||||
@@ -1622,9 +1618,8 @@ class AniWorldApp {
|
|||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
schedule_time: scheduleTime,
|
schedule_time: scheduleTime,
|
||||||
schedule_days: scheduleDays,
|
schedule_days: scheduleDays,
|
||||||
auto_download_after_rescan: autoDownload,
|
auto_download_after_rescan: autoDownload
|
||||||
folder_scan_enabled: folderScan
|
})
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response) return;
|
if (!response) return;
|
||||||
|
|||||||
@@ -35,11 +35,6 @@ AniWorld.SchedulerConfig = (function() {
|
|||||||
autoDownload.checked = config.auto_download_after_rescan || false;
|
autoDownload.checked = config.auto_download_after_rescan || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderScan = document.getElementById('folder-scan-enabled');
|
|
||||||
if (folderScan) {
|
|
||||||
folderScan.checked = config.folder_scan_enabled || false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update schedule day checkboxes
|
// Update schedule day checkboxes
|
||||||
const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun'];
|
const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun'];
|
||||||
['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) {
|
['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) {
|
||||||
@@ -87,16 +82,12 @@ AniWorld.SchedulerConfig = (function() {
|
|||||||
const autoDownloadEl = document.getElementById('auto-download-after-rescan');
|
const autoDownloadEl = document.getElementById('auto-download-after-rescan');
|
||||||
const autoDownload = autoDownloadEl ? autoDownloadEl.checked : false;
|
const autoDownload = autoDownloadEl ? autoDownloadEl.checked : false;
|
||||||
|
|
||||||
const folderScanEl = document.getElementById('folder-scan-enabled');
|
|
||||||
const folderScan = folderScanEl ? folderScanEl.checked : false;
|
|
||||||
|
|
||||||
// POST directly to the scheduler config endpoint
|
// POST directly to the scheduler config endpoint
|
||||||
const payload = {
|
const payload = {
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
schedule_time: scheduleTime,
|
schedule_time: scheduleTime,
|
||||||
schedule_days: scheduleDays,
|
schedule_days: scheduleDays,
|
||||||
auto_download_after_rescan: autoDownload,
|
auto_download_after_rescan: autoDownload
|
||||||
folder_scan_enabled: folderScan
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload);
|
const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload);
|
||||||
|
|||||||
@@ -479,13 +479,6 @@
|
|||||||
<span>Auto-download missing episodes after rescan</span>
|
<span>Auto-download missing episodes after rescan</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-checkbox">
|
|
||||||
<input type="checkbox" id="scheduler_folder_scan" name="scheduler_folder_scan">
|
|
||||||
<span>Run folder maintenance (NFO repair, renaming, poster checks)</span>
|
|
||||||
</label>
|
|
||||||
<div class="form-help">Automatically repair NFOs, rename folders, and check posters during scheduled runs</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -768,7 +761,6 @@
|
|||||||
scheduler_schedule_time: document.getElementById('scheduler_schedule_time').value || '03:00',
|
scheduler_schedule_time: document.getElementById('scheduler_schedule_time').value || '03:00',
|
||||||
scheduler_schedule_days: Array.from(document.querySelectorAll('.scheduler-day-setup-cb:checked')).map(cb => cb.value),
|
scheduler_schedule_days: Array.from(document.querySelectorAll('.scheduler-day-setup-cb:checked')).map(cb => cb.value),
|
||||||
scheduler_auto_download_after_rescan: document.getElementById('scheduler_auto_download').checked,
|
scheduler_auto_download_after_rescan: document.getElementById('scheduler_auto_download').checked,
|
||||||
scheduler_folder_scan_enabled: document.getElementById('scheduler_folder_scan').checked,
|
|
||||||
logging_level: document.getElementById('logging_level').value,
|
logging_level: document.getElementById('logging_level').value,
|
||||||
logging_file: document.getElementById('logging_file').value.trim() || null,
|
logging_file: document.getElementById('logging_file').value.trim() || null,
|
||||||
logging_max_bytes: document.getElementById('logging_max_bytes').value ?
|
logging_max_bytes: document.getElementById('logging_max_bytes').value ?
|
||||||
|
|||||||
@@ -96,11 +96,11 @@ class TestConfigServiceLoadSave:
|
|||||||
assert loaded_config.other == sample_config.other
|
assert loaded_config.other == sample_config.other
|
||||||
|
|
||||||
def test_save_and_load_scheduler_flags_roundtrip(self, config_service):
|
def test_save_and_load_scheduler_flags_roundtrip(self, config_service):
|
||||||
"""Scheduler auto_download_after_rescan and folder_scan_enabled must
|
"""Scheduler auto_download_after_rescan must
|
||||||
survive a full save/load roundtrip through ConfigService.
|
survive a full save/load roundtrip through ConfigService.
|
||||||
|
|
||||||
Regression test for a bug where null legacy alias fields
|
Regression test for a bug where null legacy alias fields
|
||||||
(auto_download=None, folder_scan=None) were written to config.json
|
(auto_download=None) were written to config.json
|
||||||
on save. On reload the alias mapping was skipped (because the keys
|
on save. On reload the alias mapping was skipped (because the keys
|
||||||
were present), causing the primary boolean fields to reset to False.
|
were present), causing the primary boolean fields to reset to False.
|
||||||
"""
|
"""
|
||||||
@@ -108,7 +108,6 @@ class TestConfigServiceLoadSave:
|
|||||||
scheduler=SchedulerConfig(
|
scheduler=SchedulerConfig(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
auto_download_after_rescan=True,
|
auto_download_after_rescan=True,
|
||||||
folder_scan_enabled=True,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
config_service.save_config(original, create_backup=False)
|
config_service.save_config(original, create_backup=False)
|
||||||
@@ -117,14 +116,11 @@ class TestConfigServiceLoadSave:
|
|||||||
with open(config_service.config_path, "r", encoding="utf-8") as f:
|
with open(config_service.config_path, "r", encoding="utf-8") as f:
|
||||||
raw = json.load(f)
|
raw = json.load(f)
|
||||||
assert "auto_download" not in raw["scheduler"]
|
assert "auto_download" not in raw["scheduler"]
|
||||||
assert "folder_scan" not in raw["scheduler"]
|
|
||||||
assert raw["scheduler"]["auto_download_after_rescan"] is True
|
assert raw["scheduler"]["auto_download_after_rescan"] is True
|
||||||
assert raw["scheduler"]["folder_scan_enabled"] is True
|
|
||||||
|
|
||||||
# Verify loaded config preserves values
|
# Verify loaded config preserves values
|
||||||
loaded = config_service.load_config()
|
loaded = config_service.load_config()
|
||||||
assert loaded.scheduler.auto_download_after_rescan is True
|
assert loaded.scheduler.auto_download_after_rescan is True
|
||||||
assert loaded.scheduler.folder_scan_enabled is True
|
|
||||||
|
|
||||||
def test_save_includes_version(self, config_service, sample_config):
|
def test_save_includes_version(self, config_service, sample_config):
|
||||||
"""Test that saved config includes version field."""
|
"""Test that saved config includes version field."""
|
||||||
|
|||||||
@@ -1,519 +0,0 @@
|
|||||||
"""Unit tests for FolderScanService (Tasks 1.2–1.5).
|
|
||||||
|
|
||||||
Covers:
|
|
||||||
- Prerequisites checking (TMDB key, anime directory)
|
|
||||||
- NFO repair integration (Task 1.3)
|
|
||||||
- Folder rename validation (Task 1.4)
|
|
||||||
- Poster check and download (Task 1.5)
|
|
||||||
- Exception handling and semaphore usage
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.server.services.scheduler.folder_scan_service import (
|
|
||||||
_POSTER_DOWNLOAD_SEMAPHORE,
|
|
||||||
_TMDB_SEMAPHORE,
|
|
||||||
FolderScanService,
|
|
||||||
FolderScanServiceError,
|
|
||||||
perform_nfo_repair_scan,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fixtures
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def folder_scan_service() -> FolderScanService:
|
|
||||||
"""Return a fresh FolderScanService instance."""
|
|
||||||
return FolderScanService()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_settings(tmp_path: Path):
|
|
||||||
"""Return a mock settings object with valid prerequisites."""
|
|
||||||
mock = MagicMock()
|
|
||||||
mock.tmdb_api_key = "test-api-key"
|
|
||||||
mock.anime_directory = str(tmp_path)
|
|
||||||
mock.nfo_download_poster = True
|
|
||||||
return mock
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1.2 – Skeleton / prerequisites
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestPrerequisites:
|
|
||||||
"""Test _prerequisites_met checks."""
|
|
||||||
|
|
||||||
def test_prerequisites_met(self, folder_scan_service, tmp_path):
|
|
||||||
"""All prerequisites present → True."""
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings"
|
|
||||||
) as mock_settings:
|
|
||||||
mock_settings.tmdb_api_key = "key"
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
assert folder_scan_service._prerequisites_met() is True
|
|
||||||
|
|
||||||
def test_missing_tmdb_key(self, folder_scan_service, tmp_path):
|
|
||||||
"""Missing TMDB API key → False."""
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings"
|
|
||||||
) as mock_settings:
|
|
||||||
mock_settings.tmdb_api_key = None
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
assert folder_scan_service._prerequisites_met() is False
|
|
||||||
|
|
||||||
def test_missing_anime_directory(self, folder_scan_service):
|
|
||||||
"""Missing anime_directory → False."""
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings"
|
|
||||||
) as mock_settings:
|
|
||||||
mock_settings.tmdb_api_key = "key"
|
|
||||||
mock_settings.anime_directory = None
|
|
||||||
assert folder_scan_service._prerequisites_met() is False
|
|
||||||
|
|
||||||
def test_anime_directory_not_found(self, folder_scan_service, tmp_path):
|
|
||||||
"""anime_directory points to non-existent path → False."""
|
|
||||||
non_existent = tmp_path / "does_not_exist"
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings"
|
|
||||||
) as mock_settings:
|
|
||||||
mock_settings.tmdb_api_key = "key"
|
|
||||||
mock_settings.anime_directory = str(non_existent)
|
|
||||||
assert folder_scan_service._prerequisites_met() is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunFolderScanPrerequisites:
|
|
||||||
"""Test run_folder_scan skips when prerequisites not met."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_skips_when_prerequisites_missing(self, folder_scan_service):
|
|
||||||
"""If _prerequisites_met returns False, scan exits early."""
|
|
||||||
with patch.object(
|
|
||||||
folder_scan_service, "_prerequisites_met", return_value=False
|
|
||||||
), patch(
|
|
||||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan"
|
|
||||||
) as mock_repair:
|
|
||||||
await folder_scan_service.run_folder_scan()
|
|
||||||
mock_repair.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_logs_start_and_completion(self, folder_scan_service, tmp_path):
|
|
||||||
"""Scan logs start and completion when prerequisites are met."""
|
|
||||||
with patch.object(
|
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
|
||||||
), patch.object(
|
|
||||||
folder_scan_service,
|
|
||||||
"check_and_download_missing_posters",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
|
|
||||||
):
|
|
||||||
# Should not raise
|
|
||||||
await folder_scan_service.run_folder_scan()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_catches_unhandled_exceptions(self, folder_scan_service):
|
|
||||||
"""Unhandled exceptions are caught and logged, not re-raised."""
|
|
||||||
with patch.object(
|
|
||||||
folder_scan_service,
|
|
||||||
"_prerequisites_met",
|
|
||||||
side_effect=RuntimeError("boom"),
|
|
||||||
):
|
|
||||||
# Must NOT raise
|
|
||||||
await folder_scan_service.run_folder_scan()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1.3 – NFO repair integration
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestNfoRepairIntegration:
|
|
||||||
"""Test NFO repair scan behavior - NFO service removed, now stub."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_repair_skipped(self, folder_scan_service, tmp_path):
|
|
||||||
"""NFO repair scan is skipped since NFO service removed."""
|
|
||||||
with patch.object(
|
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
|
||||||
), patch.object(
|
|
||||||
folder_scan_service,
|
|
||||||
"check_and_download_missing_posters",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
|
|
||||||
):
|
|
||||||
await folder_scan_service.run_folder_scan()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1.4 – Folder rename (removed)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestFolderRenameRemoved:
|
|
||||||
"""Folder rename validation was removed; scan continues to poster check."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_folder_rename_skipped_poster_check_runs(
|
|
||||||
self, folder_scan_service, tmp_path
|
|
||||||
):
|
|
||||||
"""Folder rename is skipped; scan continues to poster check."""
|
|
||||||
with patch.object(
|
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
|
||||||
), patch.object(
|
|
||||||
folder_scan_service,
|
|
||||||
"check_and_download_missing_posters",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"scanned": 5, "downloaded": 2, "skipped": 2, "errors": 1},
|
|
||||||
) as mock_poster:
|
|
||||||
await folder_scan_service.run_folder_scan()
|
|
||||||
mock_poster.assert_awaited_once()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1.5 – Poster check and download
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestPosterCheck:
|
|
||||||
"""Test check_and_download_missing_posters logic."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_no_anime_directory_returns_empty_stats(self, folder_scan_service):
|
|
||||||
"""Missing anime_directory → empty stats."""
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings"
|
|
||||||
) as mock_settings:
|
|
||||||
mock_settings.anime_directory = None
|
|
||||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
|
||||||
assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nonexistent_directory_returns_empty_stats(
|
|
||||||
self, folder_scan_service, tmp_path
|
|
||||||
):
|
|
||||||
"""Non-existent anime_directory → empty stats."""
|
|
||||||
non_existent = tmp_path / "missing"
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings"
|
|
||||||
) as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(non_existent)
|
|
||||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
|
||||||
assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_no_series_folders_returns_empty_stats(
|
|
||||||
self, folder_scan_service, tmp_path
|
|
||||||
):
|
|
||||||
"""Empty anime_directory → empty stats."""
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings"
|
|
||||||
) as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
|
||||||
assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_skips_folders_without_nfo(self, folder_scan_service, tmp_path):
|
|
||||||
"""Folders without tvshow.nfo are ignored."""
|
|
||||||
(tmp_path / "SomeShow").mkdir()
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings"
|
|
||||||
) as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
|
||||||
assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_valid_poster_skipped(self, folder_scan_service, tmp_path):
|
|
||||||
"""Existing poster.jpg ≥ 1 KB is skipped."""
|
|
||||||
series_dir = tmp_path / "Attack on Titan (2013)"
|
|
||||||
series_dir.mkdir()
|
|
||||||
(series_dir / "tvshow.nfo").write_text(
|
|
||||||
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
|
|
||||||
)
|
|
||||||
# Write a 2 KB poster
|
|
||||||
(series_dir / "poster.jpg").write_bytes(b"x" * 2048)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings"
|
|
||||||
) as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
|
||||||
|
|
||||||
assert stats["scanned"] == 1
|
|
||||||
assert stats["skipped"] == 1
|
|
||||||
assert stats["downloaded"] == 0
|
|
||||||
assert stats["errors"] == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_missing_poster_downloaded(self, folder_scan_service, tmp_path):
|
|
||||||
"""Missing poster triggers download when thumb URL exists."""
|
|
||||||
series_dir = tmp_path / "Attack on Titan (2013)"
|
|
||||||
series_dir.mkdir()
|
|
||||||
(series_dir / "tvshow.nfo").write_text(
|
|
||||||
"<tvshow>"
|
|
||||||
"<title>Attack on Titan</title><year>2013</year>"
|
|
||||||
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
|
|
||||||
"</tvshow>"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_downloader = AsyncMock()
|
|
||||||
mock_downloader.download_poster = AsyncMock(return_value=True)
|
|
||||||
mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
|
|
||||||
mock_downloader.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings"
|
|
||||||
) as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
mock_settings.nfo_download_poster = True
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.services.scheduler.folder_scan_service.ImageDownloader",
|
|
||||||
return_value=mock_downloader,
|
|
||||||
):
|
|
||||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
|
||||||
|
|
||||||
assert stats["scanned"] == 1
|
|
||||||
assert stats["downloaded"] == 1
|
|
||||||
assert stats["skipped"] == 0
|
|
||||||
assert stats["errors"] == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_no_thumb_url_skipped(self, folder_scan_service, tmp_path):
|
|
||||||
"""NFO without thumb URL → skipped."""
|
|
||||||
series_dir = tmp_path / "Attack on Titan (2013)"
|
|
||||||
series_dir.mkdir()
|
|
||||||
(series_dir / "tvshow.nfo").write_text(
|
|
||||||
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings"
|
|
||||||
) as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
mock_settings.nfo_download_poster = True
|
|
||||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
|
||||||
|
|
||||||
assert stats["scanned"] == 1
|
|
||||||
assert stats["skipped"] == 1
|
|
||||||
assert stats["downloaded"] == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_poster_download_disabled_by_setting(
|
|
||||||
self, folder_scan_service, tmp_path
|
|
||||||
):
|
|
||||||
"""nfo_download_poster=False → skipped even with valid thumb URL."""
|
|
||||||
series_dir = tmp_path / "Attack on Titan (2013)"
|
|
||||||
series_dir.mkdir()
|
|
||||||
(series_dir / "tvshow.nfo").write_text(
|
|
||||||
"<tvshow>"
|
|
||||||
"<title>Attack on Titan</title><year>2013</year>"
|
|
||||||
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
|
|
||||||
"</tvshow>"
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings"
|
|
||||||
) as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
mock_settings.nfo_download_poster = False
|
|
||||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
|
||||||
|
|
||||||
assert stats["scanned"] == 1
|
|
||||||
assert stats["skipped"] == 1
|
|
||||||
assert stats["downloaded"] == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_failure_counts_as_error(self, folder_scan_service, tmp_path):
|
|
||||||
"""Failed download increments errors."""
|
|
||||||
series_dir = tmp_path / "Attack on Titan (2013)"
|
|
||||||
series_dir.mkdir()
|
|
||||||
(series_dir / "tvshow.nfo").write_text(
|
|
||||||
"<tvshow>"
|
|
||||||
"<title>Attack on Titan</title><year>2013</year>"
|
|
||||||
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
|
|
||||||
"</tvshow>"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_downloader = AsyncMock()
|
|
||||||
mock_downloader.download_poster = AsyncMock(return_value=False)
|
|
||||||
mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
|
|
||||||
mock_downloader.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings"
|
|
||||||
) as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
mock_settings.nfo_download_poster = True
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.services.scheduler.folder_scan_service.ImageDownloader",
|
|
||||||
return_value=mock_downloader,
|
|
||||||
):
|
|
||||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
|
||||||
|
|
||||||
assert stats["scanned"] == 1
|
|
||||||
assert stats["errors"] == 1
|
|
||||||
assert stats["downloaded"] == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_exception_counts_as_error(self, folder_scan_service, tmp_path):
|
|
||||||
"""Exception during download increments errors."""
|
|
||||||
series_dir = tmp_path / "Attack on Titan (2013)"
|
|
||||||
series_dir.mkdir()
|
|
||||||
(series_dir / "tvshow.nfo").write_text(
|
|
||||||
"<tvshow>"
|
|
||||||
"<title>Attack on Titan</title><year>2013</year>"
|
|
||||||
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
|
|
||||||
"</tvshow>"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_downloader = AsyncMock()
|
|
||||||
mock_downloader.download_poster = AsyncMock(side_effect=RuntimeError("net"))
|
|
||||||
mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
|
|
||||||
mock_downloader.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings"
|
|
||||||
) as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
mock_settings.nfo_download_poster = True
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.services.scheduler.folder_scan_service.ImageDownloader",
|
|
||||||
return_value=mock_downloader,
|
|
||||||
):
|
|
||||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
|
||||||
|
|
||||||
assert stats["scanned"] == 1
|
|
||||||
assert stats["errors"] == 1
|
|
||||||
assert stats["downloaded"] == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_too_small_poster_re_downloaded(self, folder_scan_service, tmp_path):
|
|
||||||
"""Poster < 1 KB is treated as missing and re-downloaded."""
|
|
||||||
series_dir = tmp_path / "Attack on Titan (2013)"
|
|
||||||
series_dir.mkdir()
|
|
||||||
(series_dir / "tvshow.nfo").write_text(
|
|
||||||
"<tvshow>"
|
|
||||||
"<title>Attack on Titan</title><year>2013</year>"
|
|
||||||
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
|
|
||||||
"</tvshow>"
|
|
||||||
)
|
|
||||||
# Write a tiny 100-byte poster
|
|
||||||
(series_dir / "poster.jpg").write_bytes(b"x" * 100)
|
|
||||||
|
|
||||||
mock_downloader = AsyncMock()
|
|
||||||
mock_downloader.download_poster = AsyncMock(return_value=True)
|
|
||||||
mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
|
|
||||||
mock_downloader.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings"
|
|
||||||
) as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
mock_settings.nfo_download_poster = True
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.services.scheduler.folder_scan_service.ImageDownloader",
|
|
||||||
return_value=mock_downloader,
|
|
||||||
):
|
|
||||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
|
||||||
|
|
||||||
assert stats["scanned"] == 1
|
|
||||||
assert stats["downloaded"] == 1
|
|
||||||
assert stats["skipped"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestExtractPosterUrl:
|
|
||||||
"""Test _extract_poster_url_from_nfo static method."""
|
|
||||||
|
|
||||||
def test_extract_poster_url_with_aspect(self, tmp_path):
|
|
||||||
nfo = tmp_path / "tvshow.nfo"
|
|
||||||
nfo.write_text(
|
|
||||||
"<tvshow>"
|
|
||||||
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
|
|
||||||
"</tvshow>"
|
|
||||||
)
|
|
||||||
url = FolderScanService._extract_poster_url_from_nfo(nfo)
|
|
||||||
assert url == "https://example.com/poster.jpg"
|
|
||||||
|
|
||||||
def test_extract_first_thumb_fallback(self, tmp_path):
|
|
||||||
nfo = tmp_path / "tvshow.nfo"
|
|
||||||
nfo.write_text(
|
|
||||||
"<tvshow>"
|
|
||||||
'<thumb>https://example.com/fallback.jpg</thumb>'
|
|
||||||
"</tvshow>"
|
|
||||||
)
|
|
||||||
url = FolderScanService._extract_poster_url_from_nfo(nfo)
|
|
||||||
assert url == "https://example.com/fallback.jpg"
|
|
||||||
|
|
||||||
def test_no_thumb_returns_none(self, tmp_path):
|
|
||||||
nfo = tmp_path / "tvshow.nfo"
|
|
||||||
nfo.write_text("<tvshow><title>Test</title></tvshow>")
|
|
||||||
url = FolderScanService._extract_poster_url_from_nfo(nfo)
|
|
||||||
assert url is None
|
|
||||||
|
|
||||||
def test_missing_file_returns_none(self, tmp_path):
|
|
||||||
nfo = tmp_path / "tvshow.nfo"
|
|
||||||
url = FolderScanService._extract_poster_url_from_nfo(nfo)
|
|
||||||
assert url is None
|
|
||||||
|
|
||||||
def test_malformed_xml_returns_none(self, tmp_path):
|
|
||||||
nfo = tmp_path / "tvshow.nfo"
|
|
||||||
nfo.write_text("not xml")
|
|
||||||
url = FolderScanService._extract_poster_url_from_nfo(nfo)
|
|
||||||
assert url is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Semaphores
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSemaphores:
|
|
||||||
"""Verify module-level semaphores exist and have correct initial value."""
|
|
||||||
|
|
||||||
def test_tmdb_semaphore_value(self):
|
|
||||||
assert _TMDB_SEMAPHORE._value == 3
|
|
||||||
|
|
||||||
def test_poster_download_semaphore_value(self):
|
|
||||||
assert _POSTER_DOWNLOAD_SEMAPHORE._value == 3
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Full run_folder_scan integration
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestRunFolderScanFull:
|
|
||||||
"""End-to-end tests for run_folder_scan with mocked sub-tasks."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_full_scan_happy_path(self, folder_scan_service, tmp_path):
|
|
||||||
"""All sub-tasks succeed. NFO repair and folder rename are stubs."""
|
|
||||||
with patch.object(
|
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
|
||||||
), patch.object(
|
|
||||||
folder_scan_service,
|
|
||||||
"check_and_download_missing_posters",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"scanned": 3, "downloaded": 2, "skipped": 1, "errors": 0},
|
|
||||||
) as mock_poster:
|
|
||||||
await folder_scan_service.run_folder_scan()
|
|
||||||
mock_poster.assert_awaited_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_full_scan_all_stats_zero(self, folder_scan_service, tmp_path):
|
|
||||||
"""Empty library → all stats zero."""
|
|
||||||
with patch.object(
|
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
|
||||||
), patch.object(
|
|
||||||
folder_scan_service,
|
|
||||||
"check_and_download_missing_posters",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
|
|
||||||
):
|
|
||||||
await folder_scan_service.run_folder_scan()
|
|
||||||
@@ -113,34 +113,6 @@ class TestSchedulerConfigBackwardCompat:
|
|||||||
assert config.interval_minutes == 30
|
assert config.interval_minutes == 30
|
||||||
|
|
||||||
|
|
||||||
class TestSchedulerConfigFolderScanEnabled:
|
|
||||||
"""3.8 – folder_scan_enabled field (Task 1.1)."""
|
|
||||||
|
|
||||||
def test_default_folder_scan_enabled(self) -> None:
|
|
||||||
config = SchedulerConfig()
|
|
||||||
assert config.folder_scan_enabled is False
|
|
||||||
|
|
||||||
def test_set_folder_scan_enabled_true(self) -> None:
|
|
||||||
config = SchedulerConfig(folder_scan_enabled=True)
|
|
||||||
assert config.folder_scan_enabled is True
|
|
||||||
|
|
||||||
def test_set_folder_scan_enabled_false(self) -> None:
|
|
||||||
config = SchedulerConfig(folder_scan_enabled=False)
|
|
||||||
assert config.folder_scan_enabled is False
|
|
||||||
|
|
||||||
def test_backward_compat_missing_field(self) -> None:
|
|
||||||
"""Old configs without folder_scan_enabled load successfully."""
|
|
||||||
dumped = {
|
|
||||||
"enabled": True,
|
|
||||||
"interval_minutes": 60,
|
|
||||||
"schedule_time": "03:00",
|
|
||||||
"schedule_days": ALL_DAYS,
|
|
||||||
"auto_download_after_rescan": False,
|
|
||||||
}
|
|
||||||
config = SchedulerConfig(**dumped)
|
|
||||||
assert config.folder_scan_enabled is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestSchedulerConfigLegacyAliases:
|
class TestSchedulerConfigLegacyAliases:
|
||||||
"""3.10 – Legacy config key aliases (auto_download, folder_scan)."""
|
"""3.10 – Legacy config key aliases (auto_download, folder_scan)."""
|
||||||
|
|
||||||
@@ -148,27 +120,6 @@ class TestSchedulerConfigLegacyAliases:
|
|||||||
"""Legacy auto_download=true maps to auto_download_after_rescan=True."""
|
"""Legacy auto_download=true maps to auto_download_after_rescan=True."""
|
||||||
config = SchedulerConfig(auto_download=True)
|
config = SchedulerConfig(auto_download=True)
|
||||||
assert config.auto_download_after_rescan is True
|
assert config.auto_download_after_rescan is True
|
||||||
assert config.folder_scan_enabled is False
|
|
||||||
|
|
||||||
def test_legacy_auto_download_false(self) -> None:
|
|
||||||
config = SchedulerConfig(auto_download=False)
|
|
||||||
assert config.auto_download_after_rescan is False
|
|
||||||
|
|
||||||
def test_legacy_folder_scan_true(self) -> None:
|
|
||||||
"""Legacy folder_scan=true maps to folder_scan_enabled=True."""
|
|
||||||
config = SchedulerConfig(folder_scan=True)
|
|
||||||
assert config.folder_scan_enabled is True
|
|
||||||
assert config.auto_download_after_rescan is False
|
|
||||||
|
|
||||||
def test_legacy_folder_scan_false(self) -> None:
|
|
||||||
config = SchedulerConfig(folder_scan=False)
|
|
||||||
assert config.folder_scan_enabled is False
|
|
||||||
|
|
||||||
def test_legacy_both_set(self) -> None:
|
|
||||||
"""Both legacy keys can be set simultaneously."""
|
|
||||||
config = SchedulerConfig(auto_download=True, folder_scan=True)
|
|
||||||
assert config.auto_download_after_rescan is True
|
|
||||||
assert config.folder_scan_enabled is True
|
|
||||||
|
|
||||||
def test_explicit_primary_overrides_legacy(self) -> None:
|
def test_explicit_primary_overrides_legacy(self) -> None:
|
||||||
"""Primary field explicitly set to False still wins over legacy True.
|
"""Primary field explicitly set to False still wins over legacy True.
|
||||||
@@ -180,12 +131,9 @@ class TestSchedulerConfigLegacyAliases:
|
|||||||
config = SchedulerConfig(
|
config = SchedulerConfig(
|
||||||
auto_download=True,
|
auto_download=True,
|
||||||
auto_download_after_rescan=True,
|
auto_download_after_rescan=True,
|
||||||
folder_scan=True,
|
|
||||||
folder_scan_enabled=True,
|
|
||||||
)
|
)
|
||||||
# Both set to True — no conflict possible when both agree
|
# Both set to True — no conflict possible when both agree
|
||||||
assert config.auto_download_after_rescan is True
|
assert config.auto_download_after_rescan is True
|
||||||
assert config.folder_scan_enabled is True
|
|
||||||
|
|
||||||
def test_explicit_primary_false_wins_over_legacy_true(self) -> None:
|
def test_explicit_primary_false_wins_over_legacy_true(self) -> None:
|
||||||
"""Primary=False explicitly set wins over legacy=True.
|
"""Primary=False explicitly set wins over legacy=True.
|
||||||
@@ -214,11 +162,9 @@ class TestSchedulerConfigLegacyAliases:
|
|||||||
"schedule_time": "03:00",
|
"schedule_time": "03:00",
|
||||||
"schedule_days": ALL_DAYS,
|
"schedule_days": ALL_DAYS,
|
||||||
"auto_download": True,
|
"auto_download": True,
|
||||||
"folder_scan": True,
|
|
||||||
}
|
}
|
||||||
config = SchedulerConfig(**data)
|
config = SchedulerConfig(**data)
|
||||||
assert config.auto_download_after_rescan is True
|
assert config.auto_download_after_rescan is True
|
||||||
assert config.folder_scan_enabled is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestSchedulerConfigSerialisation:
|
class TestSchedulerConfigSerialisation:
|
||||||
@@ -231,7 +177,6 @@ class TestSchedulerConfigSerialisation:
|
|||||||
schedule_time="04:30",
|
schedule_time="04:30",
|
||||||
schedule_days=["mon", "wed", "fri"],
|
schedule_days=["mon", "wed", "fri"],
|
||||||
auto_download_after_rescan=True,
|
auto_download_after_rescan=True,
|
||||||
folder_scan_enabled=True,
|
|
||||||
)
|
)
|
||||||
dumped = original.model_dump()
|
dumped = original.model_dump()
|
||||||
restored = SchedulerConfig(**dumped)
|
restored = SchedulerConfig(**dumped)
|
||||||
@@ -247,13 +192,10 @@ class TestSchedulerConfigSerialisation:
|
|||||||
"""
|
"""
|
||||||
original = SchedulerConfig(
|
original = SchedulerConfig(
|
||||||
auto_download_after_rescan=True,
|
auto_download_after_rescan=True,
|
||||||
folder_scan_enabled=True,
|
|
||||||
)
|
)
|
||||||
dumped = original.model_dump()
|
dumped = original.model_dump()
|
||||||
# Alias fields must not appear when None
|
# Alias fields must not appear when None
|
||||||
assert "auto_download" not in dumped
|
assert "auto_download" not in dumped
|
||||||
assert "folder_scan" not in dumped
|
|
||||||
# Primary fields roundtrip correctly
|
# Primary fields roundtrip correctly
|
||||||
restored = SchedulerConfig(**dumped)
|
restored = SchedulerConfig(**dumped)
|
||||||
assert restored.auto_download_after_rescan is True
|
assert restored.auto_download_after_rescan is True
|
||||||
assert restored.folder_scan_enabled is True
|
|
||||||
|
|||||||
@@ -366,7 +366,6 @@ class TestGetStatus:
|
|||||||
schedule_time="04:00",
|
schedule_time="04:00",
|
||||||
schedule_days=["mon"],
|
schedule_days=["mon"],
|
||||||
auto_download_after_rescan=True,
|
auto_download_after_rescan=True,
|
||||||
folder_scan_enabled=True,
|
|
||||||
)
|
)
|
||||||
status = scheduler_service.get_status()
|
status = scheduler_service.get_status()
|
||||||
|
|
||||||
@@ -376,100 +375,13 @@ class TestGetStatus:
|
|||||||
assert "schedule_time" in status
|
assert "schedule_time" in status
|
||||||
assert "schedule_days" in status
|
assert "schedule_days" in status
|
||||||
assert "auto_download_after_rescan" in status
|
assert "auto_download_after_rescan" in status
|
||||||
assert "folder_scan_enabled" in status
|
|
||||||
assert status["schedule_time"] == "04:00"
|
assert status["schedule_time"] == "04:00"
|
||||||
assert status["schedule_days"] == ["mon"]
|
assert status["schedule_days"] == ["mon"]
|
||||||
assert status["auto_download_after_rescan"] is True
|
assert status["auto_download_after_rescan"] is True
|
||||||
assert status["folder_scan_enabled"] is True
|
|
||||||
assert status["is_running"] is False
|
assert status["is_running"] is False
|
||||||
assert status["next_run"] is None
|
assert status["next_run"] is None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 12.11 _perform_rescan() with folder_scan_enabled=True
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestPerformRescanFolderScan:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_folder_scan_called_when_enabled(self, scheduler_service):
|
|
||||||
scheduler_service._config = SchedulerConfig(
|
|
||||||
folder_scan_enabled=True,
|
|
||||||
schedule_time="03:00",
|
|
||||||
schedule_days=ALL_DAYS,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_anime = MagicMock()
|
|
||||||
mock_anime.rescan = AsyncMock()
|
|
||||||
mock_anime._cached_list_missing.return_value = []
|
|
||||||
|
|
||||||
mock_ws = MagicMock()
|
|
||||||
mock_ws.manager.broadcast = AsyncMock()
|
|
||||||
|
|
||||||
mock_folder_scan = AsyncMock()
|
|
||||||
|
|
||||||
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.scheduler.folder_scan_service.FolderScanService") as MockFSS:
|
|
||||||
MockFSS.return_value.run_folder_scan = mock_folder_scan
|
|
||||||
await scheduler_service._perform_rescan()
|
|
||||||
|
|
||||||
mock_folder_scan.assert_awaited_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_folder_scan_skipped_when_disabled(self, scheduler_service):
|
|
||||||
scheduler_service._config = SchedulerConfig(
|
|
||||||
folder_scan_enabled=False,
|
|
||||||
schedule_time="03:00",
|
|
||||||
schedule_days=ALL_DAYS,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_anime = MagicMock()
|
|
||||||
mock_anime.rescan = AsyncMock()
|
|
||||||
mock_anime._cached_list_missing.return_value = []
|
|
||||||
|
|
||||||
mock_ws = MagicMock()
|
|
||||||
mock_ws.manager.broadcast = AsyncMock()
|
|
||||||
|
|
||||||
mock_folder_scan = AsyncMock()
|
|
||||||
|
|
||||||
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.scheduler.folder_scan_service.FolderScanService") as MockFSS:
|
|
||||||
MockFSS.return_value.run_folder_scan = mock_folder_scan
|
|
||||||
await scheduler_service._perform_rescan()
|
|
||||||
|
|
||||||
mock_folder_scan.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_folder_scan_error_broadcasts_and_does_not_crash(self, scheduler_service):
|
|
||||||
scheduler_service._config = SchedulerConfig(
|
|
||||||
folder_scan_enabled=True,
|
|
||||||
schedule_time="03:00",
|
|
||||||
schedule_days=ALL_DAYS,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_anime = MagicMock()
|
|
||||||
mock_anime.rescan = AsyncMock()
|
|
||||||
mock_anime._cached_list_missing.return_value = []
|
|
||||||
|
|
||||||
mock_ws = MagicMock()
|
|
||||||
mock_ws.manager.broadcast = AsyncMock()
|
|
||||||
|
|
||||||
mock_folder_scan = AsyncMock(side_effect=RuntimeError("folder scan boom"))
|
|
||||||
|
|
||||||
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.scheduler.folder_scan_service.FolderScanService") as MockFSS:
|
|
||||||
MockFSS.return_value.run_folder_scan = mock_folder_scan
|
|
||||||
# Should NOT raise
|
|
||||||
await scheduler_service._perform_rescan()
|
|
||||||
|
|
||||||
mock_folder_scan.assert_awaited_once()
|
|
||||||
calls = [str(c) for c in mock_ws.manager.broadcast.call_args_list]
|
|
||||||
assert any("folder_scan_error" in c for c in calls)
|
|
||||||
assert scheduler_service._scan_in_progress is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Singleton helpers
|
# Singleton helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user