backzup
This commit is contained in:
@@ -76,8 +76,6 @@ async def setup_auth(req: SetupRequest):
|
||||
config.scheduler.schedule_days = req.scheduler_schedule_days
|
||||
if req.scheduler_auto_download_after_rescan is not None:
|
||||
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
|
||||
if req.logging_level:
|
||||
|
||||
@@ -31,7 +31,6 @@ def _build_response(config: SchedulerConfig) -> Dict[str, Any]:
|
||||
"schedule_time": config.schedule_time,
|
||||
"schedule_days": config.schedule_days,
|
||||
"auto_download_after_rescan": config.auto_download_after_rescan,
|
||||
"folder_scan_enabled": config.folder_scan_enabled,
|
||||
},
|
||||
"status": {
|
||||
"is_running": runtime.get("is_running", False),
|
||||
|
||||
@@ -73,9 +73,6 @@ class SetupRequest(BaseModel):
|
||||
scheduler_auto_download_after_rescan: Optional[bool] = Field(
|
||||
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_level: Optional[str] = Field(
|
||||
|
||||
@@ -39,14 +39,8 @@ class SchedulerConfig(BaseModel):
|
||||
description="Automatically queue and start downloads for all missing "
|
||||
"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
|
||||
auto_download: Optional[bool] = Field(default=None, alias="auto_download")
|
||||
folder_scan: Optional[bool] = Field(default=None, alias="folder_scan")
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
@@ -54,8 +48,6 @@ class SchedulerConfig(BaseModel):
|
||||
# "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:
|
||||
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")
|
||||
@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
|
||||
|
||||
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.schedule_time,
|
||||
self._config.schedule_days,
|
||||
self._config.auto_download_after_rescan,
|
||||
self._config.folder_scan_enabled,
|
||||
)
|
||||
|
||||
trigger = self._build_cron_trigger()
|
||||
@@ -197,12 +196,11 @@ class SchedulerService:
|
||||
"""
|
||||
self._config = config
|
||||
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.schedule_time,
|
||||
config.schedule_days,
|
||||
config.auto_download_after_rescan,
|
||||
config.folder_scan_enabled,
|
||||
)
|
||||
|
||||
if not self._scheduler or not self._scheduler.running:
|
||||
@@ -263,9 +261,6 @@ class SchedulerService:
|
||||
"auto_download_after_rescan": (
|
||||
self._config.auto_download_after_rescan if self._config else False
|
||||
),
|
||||
"folder_scan_enabled": (
|
||||
self._config.folder_scan_enabled if self._config else False
|
||||
),
|
||||
"last_run": (
|
||||
self._last_scan_time.isoformat()
|
||||
if self._last_scan_time
|
||||
@@ -389,14 +384,6 @@ class SchedulerService:
|
||||
logger.error("Auto-download failed: %s", exc, exc_info=True)
|
||||
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)
|
||||
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)
|
||||
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:
|
||||
"""Broadcast a WebSocket event to all connected clients."""
|
||||
try:
|
||||
|
||||
@@ -1561,8 +1561,6 @@ class AniWorldApp {
|
||||
document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled;
|
||||
document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00';
|
||||
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
|
||||
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 scheduleTime = document.getElementById('scheduled-rescan-time').value || '03:00';
|
||||
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
|
||||
const scheduleDays = ['mon','tue','wed','thu','fri','sat','sun']
|
||||
@@ -1622,9 +1618,8 @@ class AniWorldApp {
|
||||
enabled: enabled,
|
||||
schedule_time: scheduleTime,
|
||||
schedule_days: scheduleDays,
|
||||
auto_download_after_rescan: autoDownload,
|
||||
folder_scan_enabled: folderScan
|
||||
})
|
||||
auto_download_after_rescan: autoDownload
|
||||
})
|
||||
});
|
||||
|
||||
if (!response) return;
|
||||
|
||||
@@ -35,11 +35,6 @@ AniWorld.SchedulerConfig = (function() {
|
||||
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
|
||||
const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun'];
|
||||
['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 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
|
||||
const payload = {
|
||||
enabled: enabled,
|
||||
schedule_time: scheduleTime,
|
||||
schedule_days: scheduleDays,
|
||||
auto_download_after_rescan: autoDownload,
|
||||
folder_scan_enabled: folderScan
|
||||
auto_download_after_rescan: autoDownload
|
||||
};
|
||||
|
||||
const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload);
|
||||
|
||||
@@ -479,13 +479,6 @@
|
||||
<span>Auto-download missing episodes after rescan</span>
|
||||
</label>
|
||||
</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>
|
||||
|
||||
@@ -768,7 +761,6 @@
|
||||
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_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_file: document.getElementById('logging_file').value.trim() || null,
|
||||
logging_max_bytes: document.getElementById('logging_max_bytes').value ?
|
||||
|
||||
@@ -96,11 +96,11 @@ class TestConfigServiceLoadSave:
|
||||
assert loaded_config.other == sample_config.other
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
were present), causing the primary boolean fields to reset to False.
|
||||
"""
|
||||
@@ -108,7 +108,6 @@ class TestConfigServiceLoadSave:
|
||||
scheduler=SchedulerConfig(
|
||||
enabled=True,
|
||||
auto_download_after_rescan=True,
|
||||
folder_scan_enabled=True,
|
||||
)
|
||||
)
|
||||
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:
|
||||
raw = json.load(f)
|
||||
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"]["folder_scan_enabled"] is True
|
||||
|
||||
# Verify loaded config preserves values
|
||||
loaded = config_service.load_config()
|
||||
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):
|
||||
"""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
|
||||
|
||||
|
||||
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:
|
||||
"""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."""
|
||||
config = SchedulerConfig(auto_download=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:
|
||||
"""Primary field explicitly set to False still wins over legacy True.
|
||||
@@ -180,12 +131,9 @@ class TestSchedulerConfigLegacyAliases:
|
||||
config = SchedulerConfig(
|
||||
auto_download=True,
|
||||
auto_download_after_rescan=True,
|
||||
folder_scan=True,
|
||||
folder_scan_enabled=True,
|
||||
)
|
||||
# Both set to True — no conflict possible when both agree
|
||||
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:
|
||||
"""Primary=False explicitly set wins over legacy=True.
|
||||
@@ -214,11 +162,9 @@ class TestSchedulerConfigLegacyAliases:
|
||||
"schedule_time": "03:00",
|
||||
"schedule_days": ALL_DAYS,
|
||||
"auto_download": True,
|
||||
"folder_scan": True,
|
||||
}
|
||||
config = SchedulerConfig(**data)
|
||||
assert config.auto_download_after_rescan is True
|
||||
assert config.folder_scan_enabled is True
|
||||
|
||||
|
||||
class TestSchedulerConfigSerialisation:
|
||||
@@ -231,7 +177,6 @@ class TestSchedulerConfigSerialisation:
|
||||
schedule_time="04:30",
|
||||
schedule_days=["mon", "wed", "fri"],
|
||||
auto_download_after_rescan=True,
|
||||
folder_scan_enabled=True,
|
||||
)
|
||||
dumped = original.model_dump()
|
||||
restored = SchedulerConfig(**dumped)
|
||||
@@ -247,13 +192,10 @@ class TestSchedulerConfigSerialisation:
|
||||
"""
|
||||
original = SchedulerConfig(
|
||||
auto_download_after_rescan=True,
|
||||
folder_scan_enabled=True,
|
||||
)
|
||||
dumped = original.model_dump()
|
||||
# Alias fields must not appear when None
|
||||
assert "auto_download" not in dumped
|
||||
assert "folder_scan" not in dumped
|
||||
# Primary fields roundtrip correctly
|
||||
restored = SchedulerConfig(**dumped)
|
||||
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_days=["mon"],
|
||||
auto_download_after_rescan=True,
|
||||
folder_scan_enabled=True,
|
||||
)
|
||||
status = scheduler_service.get_status()
|
||||
|
||||
@@ -376,100 +375,13 @@ class TestGetStatus:
|
||||
assert "schedule_time" in status
|
||||
assert "schedule_days" in status
|
||||
assert "auto_download_after_rescan" in status
|
||||
assert "folder_scan_enabled" in status
|
||||
assert status["schedule_time"] == "04:00"
|
||||
assert status["schedule_days"] == ["mon"]
|
||||
assert status["auto_download_after_rescan"] is True
|
||||
assert status["folder_scan_enabled"] is True
|
||||
assert status["is_running"] is False
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user