diff --git a/src/server/api/auth.py b/src/server/api/auth.py index ce0c26b..bc58536 100644 --- a/src/server/api/auth.py +++ b/src/server/api/auth.py @@ -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: diff --git a/src/server/api/scheduler.py b/src/server/api/scheduler.py index 09cafaf..12e3593 100644 --- a/src/server/api/scheduler.py +++ b/src/server/api/scheduler.py @@ -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), diff --git a/src/server/models/auth.py b/src/server/models/auth.py index 160be40..6881002 100644 --- a/src/server/models/auth.py +++ b/src/server/models/auth.py @@ -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( diff --git a/src/server/models/config.py b/src/server/models/config.py index 1120cf7..d6e5c85 100644 --- a/src/server/models/config.py +++ b/src/server/models/config.py @@ -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 diff --git a/src/server/services/scheduler/folder_scan_service.py b/src/server/services/scheduler/folder_scan_service.py deleted file mode 100644 index 5aad1e6..0000000 --- a/src/server/services/scheduler/folder_scan_service.py +++ /dev/null @@ -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 ```` - 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 ````; falls back to the first - ```` 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 diff --git a/src/server/services/scheduler/scheduler_service.py b/src/server/services/scheduler/scheduler_service.py index 302cca7..592b2b9 100644 --- a/src/server/services/scheduler/scheduler_service.py +++ b/src/server/services/scheduler/scheduler_service.py @@ -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: diff --git a/src/server/web/static/js/app.js b/src/server/web/static/js/app.js index 50ad81f..93e22e9 100644 --- a/src/server/web/static/js/app.js +++ b/src/server/web/static/js/app.js @@ -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; diff --git a/src/server/web/static/js/index/scheduler-config.js b/src/server/web/static/js/index/scheduler-config.js index 22bbeb6..a4c07dc 100644 --- a/src/server/web/static/js/index/scheduler-config.js +++ b/src/server/web/static/js/index/scheduler-config.js @@ -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); diff --git a/src/server/web/templates/setup.html b/src/server/web/templates/setup.html index b77bf00..b88b62d 100644 --- a/src/server/web/templates/setup.html +++ b/src/server/web/templates/setup.html @@ -479,13 +479,6 @@ Auto-download missing episodes after rescan -
- -
Automatically repair NFOs, rename folders, and check posters during scheduled runs
-
@@ -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 ? diff --git a/tests/unit/test_config_service.py b/tests/unit/test_config_service.py index 58ba037..18ca9d9 100644 --- a/tests/unit/test_config_service.py +++ b/tests/unit/test_config_service.py @@ -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.""" diff --git a/tests/unit/test_folder_scan_service.py b/tests/unit/test_folder_scan_service.py deleted file mode 100644 index 86490b7..0000000 --- a/tests/unit/test_folder_scan_service.py +++ /dev/null @@ -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( - "Attack on Titan2013" - ) - # 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( - "" - "Attack on Titan2013" - 'https://example.com/poster.jpg' - "" - ) - - 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( - "Attack on Titan2013" - ) - - 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( - "" - "Attack on Titan2013" - 'https://example.com/poster.jpg' - "" - ) - - 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( - "" - "Attack on Titan2013" - 'https://example.com/poster.jpg' - "" - ) - - 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( - "" - "Attack on Titan2013" - 'https://example.com/poster.jpg' - "" - ) - - 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( - "" - "Attack on Titan2013" - 'https://example.com/poster.jpg' - "" - ) - # 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( - "" - 'https://example.com/poster.jpg' - "" - ) - 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( - "" - 'https://example.com/fallback.jpg' - "" - ) - 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("Test") - 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() diff --git a/tests/unit/test_scheduler_config_model.py b/tests/unit/test_scheduler_config_model.py index bd16a03..3c61baa 100644 --- a/tests/unit/test_scheduler_config_model.py +++ b/tests/unit/test_scheduler_config_model.py @@ -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 diff --git a/tests/unit/test_scheduler_service.py b/tests/unit/test_scheduler_service.py index 28d491c..eb94682 100644 --- a/tests/unit/test_scheduler_service.py +++ b/tests/unit/test_scheduler_service.py @@ -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 # ---------------------------------------------------------------------------