This commit is contained in:
2026-06-05 17:18:00 +02:00
parent 3d33626546
commit 8b21f1243f
13 changed files with 7 additions and 1034 deletions

View File

@@ -76,8 +76,6 @@ async def setup_auth(req: SetupRequest):
config.scheduler.schedule_days = req.scheduler_schedule_days config.scheduler.schedule_days = req.scheduler_schedule_days
if req.scheduler_auto_download_after_rescan is not None: if req.scheduler_auto_download_after_rescan is not None:
config.scheduler.auto_download_after_rescan = req.scheduler_auto_download_after_rescan config.scheduler.auto_download_after_rescan = req.scheduler_auto_download_after_rescan
if req.scheduler_folder_scan_enabled is not None:
config.scheduler.folder_scan_enabled = req.scheduler_folder_scan_enabled
# Update logging configuration # Update logging configuration
if req.logging_level: if req.logging_level:

View File

@@ -31,7 +31,6 @@ def _build_response(config: SchedulerConfig) -> Dict[str, Any]:
"schedule_time": config.schedule_time, "schedule_time": config.schedule_time,
"schedule_days": config.schedule_days, "schedule_days": config.schedule_days,
"auto_download_after_rescan": config.auto_download_after_rescan, "auto_download_after_rescan": config.auto_download_after_rescan,
"folder_scan_enabled": config.folder_scan_enabled,
}, },
"status": { "status": {
"is_running": runtime.get("is_running", False), "is_running": runtime.get("is_running", False),

View File

@@ -73,9 +73,6 @@ class SetupRequest(BaseModel):
scheduler_auto_download_after_rescan: Optional[bool] = Field( scheduler_auto_download_after_rescan: Optional[bool] = Field(
default=False, description="Auto-download missing episodes after rescan" default=False, description="Auto-download missing episodes after rescan"
) )
scheduler_folder_scan_enabled: Optional[bool] = Field(
default=False, description="Run folder maintenance during scheduled run"
)
# Logging configuration # Logging configuration
logging_level: Optional[str] = Field( logging_level: Optional[str] = Field(

View File

@@ -39,14 +39,8 @@ class SchedulerConfig(BaseModel):
description="Automatically queue and start downloads for all missing " description="Automatically queue and start downloads for all missing "
"episodes after a scheduled rescan completes.", "episodes after a scheduled rescan completes.",
) )
folder_scan_enabled: bool = Field(
default=False,
description="Run folder maintenance (NFO repair, folder renaming, "
"poster checks) during the scheduled run.",
)
# Legacy alias fields — read via Pydantic alias # Legacy alias fields — read via Pydantic alias
auto_download: Optional[bool] = Field(default=None, alias="auto_download") auto_download: Optional[bool] = Field(default=None, alias="auto_download")
folder_scan: Optional[bool] = Field(default=None, alias="folder_scan")
def __init__(self, **data): def __init__(self, **data):
super().__init__(**data) super().__init__(**data)
@@ -54,8 +48,6 @@ class SchedulerConfig(BaseModel):
# "key in data" checks for explicit presence (even False/None), not just truthiness. # "key in data" checks for explicit presence (even False/None), not just truthiness.
if self.auto_download is not None and "auto_download_after_rescan" not in data: if self.auto_download is not None and "auto_download_after_rescan" not in data:
object.__setattr__(self, "auto_download_after_rescan", self.auto_download) object.__setattr__(self, "auto_download_after_rescan", self.auto_download)
if self.folder_scan is not None and "folder_scan_enabled" not in data:
object.__setattr__(self, "folder_scan_enabled", self.folder_scan)
@field_validator("schedule_time") @field_validator("schedule_time")
@classmethod @classmethod

View File

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

View File

@@ -90,12 +90,11 @@ class SchedulerService:
return return
logger.info( logger.info(
"Scheduler config loaded: enabled=%s time=%s days=%s auto_download=%s folder_scan=%s", "Scheduler config loaded: enabled=%s time=%s days=%s auto_download=%s",
self._config.enabled, self._config.enabled,
self._config.schedule_time, self._config.schedule_time,
self._config.schedule_days, self._config.schedule_days,
self._config.auto_download_after_rescan, self._config.auto_download_after_rescan,
self._config.folder_scan_enabled,
) )
trigger = self._build_cron_trigger() trigger = self._build_cron_trigger()
@@ -197,12 +196,11 @@ class SchedulerService:
""" """
self._config = config self._config = config
logger.info( logger.info(
"Scheduler config reloaded: enabled=%s time=%s days=%s auto_download=%s folder_scan=%s", "Scheduler config reloaded: enabled=%s time=%s days=%s auto_download=%s",
config.enabled, config.enabled,
config.schedule_time, config.schedule_time,
config.schedule_days, config.schedule_days,
config.auto_download_after_rescan, config.auto_download_after_rescan,
config.folder_scan_enabled,
) )
if not self._scheduler or not self._scheduler.running: if not self._scheduler or not self._scheduler.running:
@@ -263,9 +261,6 @@ class SchedulerService:
"auto_download_after_rescan": ( "auto_download_after_rescan": (
self._config.auto_download_after_rescan if self._config else False self._config.auto_download_after_rescan if self._config else False
), ),
"folder_scan_enabled": (
self._config.folder_scan_enabled if self._config else False
),
"last_run": ( "last_run": (
self._last_scan_time.isoformat() self._last_scan_time.isoformat()
if self._last_scan_time if self._last_scan_time
@@ -389,14 +384,6 @@ class SchedulerService:
logger.error("Auto-download failed: %s", exc, exc_info=True) logger.error("Auto-download failed: %s", exc, exc_info=True)
await self._broadcast("auto_download_error", {"error": str(exc)}) await self._broadcast("auto_download_error", {"error": str(exc)})
# 3. Folder scan (if enabled)
if self._config and self._config.folder_scan_enabled:
try:
await self._run_folder_scan()
except Exception as exc:
logger.error("Folder scan failed: %s", exc, exc_info=True)
await self._broadcast("folder_scan_error", {"error": str(exc)})
self._last_scan_time = datetime.now(timezone.utc) self._last_scan_time = datetime.now(timezone.utc)
duration = (self._last_scan_time - scan_start).total_seconds() duration = (self._last_scan_time - scan_start).total_seconds()
@@ -494,14 +481,6 @@ class SchedulerService:
logger.info("Auto-download completed: queued_count=%d", queued_count) logger.info("Auto-download completed: queued_count=%d", queued_count)
return queued_count return queued_count
async def _run_folder_scan(self) -> None:
"""Run the folder scan maintenance task."""
from src.server.services.scheduler.folder_scan_service import FolderScanService
folder_scan_service = FolderScanService()
await folder_scan_service.run_folder_scan()
logger.info("Folder scan completed successfully")
async def _broadcast(self, event_type: str, data: dict) -> None: async def _broadcast(self, event_type: str, data: dict) -> None:
"""Broadcast a WebSocket event to all connected clients.""" """Broadcast a WebSocket event to all connected clients."""
try: try:

View File

@@ -1561,8 +1561,6 @@ class AniWorldApp {
document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled; document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled;
document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00'; document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00';
document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan; document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan;
const folderScanEl = document.getElementById('folder-scan-enabled');
if (folderScanEl) folderScanEl.checked = !!config.folder_scan_enabled;
// Update day-of-week checkboxes // Update day-of-week checkboxes
const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun']; const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun'];
@@ -1605,8 +1603,6 @@ class AniWorldApp {
const enabled = document.getElementById('scheduled-rescan-enabled').checked; const enabled = document.getElementById('scheduled-rescan-enabled').checked;
const scheduleTime = document.getElementById('scheduled-rescan-time').value || '03:00'; const scheduleTime = document.getElementById('scheduled-rescan-time').value || '03:00';
const autoDownload = document.getElementById('auto-download-after-rescan').checked; const autoDownload = document.getElementById('auto-download-after-rescan').checked;
const folderScanEl = document.getElementById('folder-scan-enabled');
const folderScan = folderScanEl ? folderScanEl.checked : false;
// Collect checked day-of-week values // Collect checked day-of-week values
const scheduleDays = ['mon','tue','wed','thu','fri','sat','sun'] const scheduleDays = ['mon','tue','wed','thu','fri','sat','sun']
@@ -1622,9 +1618,8 @@ class AniWorldApp {
enabled: enabled, enabled: enabled,
schedule_time: scheduleTime, schedule_time: scheduleTime,
schedule_days: scheduleDays, schedule_days: scheduleDays,
auto_download_after_rescan: autoDownload, auto_download_after_rescan: autoDownload
folder_scan_enabled: folderScan })
})
}); });
if (!response) return; if (!response) return;

View File

@@ -35,11 +35,6 @@ AniWorld.SchedulerConfig = (function() {
autoDownload.checked = config.auto_download_after_rescan || false; autoDownload.checked = config.auto_download_after_rescan || false;
} }
const folderScan = document.getElementById('folder-scan-enabled');
if (folderScan) {
folderScan.checked = config.folder_scan_enabled || false;
}
// Update schedule day checkboxes // Update schedule day checkboxes
const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun']; const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun'];
['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) { ['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) {
@@ -87,16 +82,12 @@ AniWorld.SchedulerConfig = (function() {
const autoDownloadEl = document.getElementById('auto-download-after-rescan'); const autoDownloadEl = document.getElementById('auto-download-after-rescan');
const autoDownload = autoDownloadEl ? autoDownloadEl.checked : false; const autoDownload = autoDownloadEl ? autoDownloadEl.checked : false;
const folderScanEl = document.getElementById('folder-scan-enabled');
const folderScan = folderScanEl ? folderScanEl.checked : false;
// POST directly to the scheduler config endpoint // POST directly to the scheduler config endpoint
const payload = { const payload = {
enabled: enabled, enabled: enabled,
schedule_time: scheduleTime, schedule_time: scheduleTime,
schedule_days: scheduleDays, schedule_days: scheduleDays,
auto_download_after_rescan: autoDownload, auto_download_after_rescan: autoDownload
folder_scan_enabled: folderScan
}; };
const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload); const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload);

View File

@@ -479,13 +479,6 @@
<span>Auto-download missing episodes after rescan</span> <span>Auto-download missing episodes after rescan</span>
</label> </label>
</div> </div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" id="scheduler_folder_scan" name="scheduler_folder_scan">
<span>Run folder maintenance (NFO repair, renaming, poster checks)</span>
</label>
<div class="form-help">Automatically repair NFOs, rename folders, and check posters during scheduled runs</div>
</div>
</div> </div>
</div> </div>
@@ -768,7 +761,6 @@
scheduler_schedule_time: document.getElementById('scheduler_schedule_time').value || '03:00', scheduler_schedule_time: document.getElementById('scheduler_schedule_time').value || '03:00',
scheduler_schedule_days: Array.from(document.querySelectorAll('.scheduler-day-setup-cb:checked')).map(cb => cb.value), scheduler_schedule_days: Array.from(document.querySelectorAll('.scheduler-day-setup-cb:checked')).map(cb => cb.value),
scheduler_auto_download_after_rescan: document.getElementById('scheduler_auto_download').checked, scheduler_auto_download_after_rescan: document.getElementById('scheduler_auto_download').checked,
scheduler_folder_scan_enabled: document.getElementById('scheduler_folder_scan').checked,
logging_level: document.getElementById('logging_level').value, logging_level: document.getElementById('logging_level').value,
logging_file: document.getElementById('logging_file').value.trim() || null, logging_file: document.getElementById('logging_file').value.trim() || null,
logging_max_bytes: document.getElementById('logging_max_bytes').value ? logging_max_bytes: document.getElementById('logging_max_bytes').value ?

View File

@@ -96,11 +96,11 @@ class TestConfigServiceLoadSave:
assert loaded_config.other == sample_config.other assert loaded_config.other == sample_config.other
def test_save_and_load_scheduler_flags_roundtrip(self, config_service): def test_save_and_load_scheduler_flags_roundtrip(self, config_service):
"""Scheduler auto_download_after_rescan and folder_scan_enabled must """Scheduler auto_download_after_rescan must
survive a full save/load roundtrip through ConfigService. survive a full save/load roundtrip through ConfigService.
Regression test for a bug where null legacy alias fields Regression test for a bug where null legacy alias fields
(auto_download=None, folder_scan=None) were written to config.json (auto_download=None) were written to config.json
on save. On reload the alias mapping was skipped (because the keys on save. On reload the alias mapping was skipped (because the keys
were present), causing the primary boolean fields to reset to False. were present), causing the primary boolean fields to reset to False.
""" """
@@ -108,7 +108,6 @@ class TestConfigServiceLoadSave:
scheduler=SchedulerConfig( scheduler=SchedulerConfig(
enabled=True, enabled=True,
auto_download_after_rescan=True, auto_download_after_rescan=True,
folder_scan_enabled=True,
) )
) )
config_service.save_config(original, create_backup=False) config_service.save_config(original, create_backup=False)
@@ -117,14 +116,11 @@ class TestConfigServiceLoadSave:
with open(config_service.config_path, "r", encoding="utf-8") as f: with open(config_service.config_path, "r", encoding="utf-8") as f:
raw = json.load(f) raw = json.load(f)
assert "auto_download" not in raw["scheduler"] assert "auto_download" not in raw["scheduler"]
assert "folder_scan" not in raw["scheduler"]
assert raw["scheduler"]["auto_download_after_rescan"] is True assert raw["scheduler"]["auto_download_after_rescan"] is True
assert raw["scheduler"]["folder_scan_enabled"] is True
# Verify loaded config preserves values # Verify loaded config preserves values
loaded = config_service.load_config() loaded = config_service.load_config()
assert loaded.scheduler.auto_download_after_rescan is True assert loaded.scheduler.auto_download_after_rescan is True
assert loaded.scheduler.folder_scan_enabled is True
def test_save_includes_version(self, config_service, sample_config): def test_save_includes_version(self, config_service, sample_config):
"""Test that saved config includes version field.""" """Test that saved config includes version field."""

View File

@@ -1,519 +0,0 @@
"""Unit tests for FolderScanService (Tasks 1.21.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()

View File

@@ -113,34 +113,6 @@ class TestSchedulerConfigBackwardCompat:
assert config.interval_minutes == 30 assert config.interval_minutes == 30
class TestSchedulerConfigFolderScanEnabled:
"""3.8 folder_scan_enabled field (Task 1.1)."""
def test_default_folder_scan_enabled(self) -> None:
config = SchedulerConfig()
assert config.folder_scan_enabled is False
def test_set_folder_scan_enabled_true(self) -> None:
config = SchedulerConfig(folder_scan_enabled=True)
assert config.folder_scan_enabled is True
def test_set_folder_scan_enabled_false(self) -> None:
config = SchedulerConfig(folder_scan_enabled=False)
assert config.folder_scan_enabled is False
def test_backward_compat_missing_field(self) -> None:
"""Old configs without folder_scan_enabled load successfully."""
dumped = {
"enabled": True,
"interval_minutes": 60,
"schedule_time": "03:00",
"schedule_days": ALL_DAYS,
"auto_download_after_rescan": False,
}
config = SchedulerConfig(**dumped)
assert config.folder_scan_enabled is False
class TestSchedulerConfigLegacyAliases: class TestSchedulerConfigLegacyAliases:
"""3.10 Legacy config key aliases (auto_download, folder_scan).""" """3.10 Legacy config key aliases (auto_download, folder_scan)."""
@@ -148,27 +120,6 @@ class TestSchedulerConfigLegacyAliases:
"""Legacy auto_download=true maps to auto_download_after_rescan=True.""" """Legacy auto_download=true maps to auto_download_after_rescan=True."""
config = SchedulerConfig(auto_download=True) config = SchedulerConfig(auto_download=True)
assert config.auto_download_after_rescan is True assert config.auto_download_after_rescan is True
assert config.folder_scan_enabled is False
def test_legacy_auto_download_false(self) -> None:
config = SchedulerConfig(auto_download=False)
assert config.auto_download_after_rescan is False
def test_legacy_folder_scan_true(self) -> None:
"""Legacy folder_scan=true maps to folder_scan_enabled=True."""
config = SchedulerConfig(folder_scan=True)
assert config.folder_scan_enabled is True
assert config.auto_download_after_rescan is False
def test_legacy_folder_scan_false(self) -> None:
config = SchedulerConfig(folder_scan=False)
assert config.folder_scan_enabled is False
def test_legacy_both_set(self) -> None:
"""Both legacy keys can be set simultaneously."""
config = SchedulerConfig(auto_download=True, folder_scan=True)
assert config.auto_download_after_rescan is True
assert config.folder_scan_enabled is True
def test_explicit_primary_overrides_legacy(self) -> None: def test_explicit_primary_overrides_legacy(self) -> None:
"""Primary field explicitly set to False still wins over legacy True. """Primary field explicitly set to False still wins over legacy True.
@@ -180,12 +131,9 @@ class TestSchedulerConfigLegacyAliases:
config = SchedulerConfig( config = SchedulerConfig(
auto_download=True, auto_download=True,
auto_download_after_rescan=True, auto_download_after_rescan=True,
folder_scan=True,
folder_scan_enabled=True,
) )
# Both set to True — no conflict possible when both agree # Both set to True — no conflict possible when both agree
assert config.auto_download_after_rescan is True assert config.auto_download_after_rescan is True
assert config.folder_scan_enabled is True
def test_explicit_primary_false_wins_over_legacy_true(self) -> None: def test_explicit_primary_false_wins_over_legacy_true(self) -> None:
"""Primary=False explicitly set wins over legacy=True. """Primary=False explicitly set wins over legacy=True.
@@ -214,11 +162,9 @@ class TestSchedulerConfigLegacyAliases:
"schedule_time": "03:00", "schedule_time": "03:00",
"schedule_days": ALL_DAYS, "schedule_days": ALL_DAYS,
"auto_download": True, "auto_download": True,
"folder_scan": True,
} }
config = SchedulerConfig(**data) config = SchedulerConfig(**data)
assert config.auto_download_after_rescan is True assert config.auto_download_after_rescan is True
assert config.folder_scan_enabled is True
class TestSchedulerConfigSerialisation: class TestSchedulerConfigSerialisation:
@@ -231,7 +177,6 @@ class TestSchedulerConfigSerialisation:
schedule_time="04:30", schedule_time="04:30",
schedule_days=["mon", "wed", "fri"], schedule_days=["mon", "wed", "fri"],
auto_download_after_rescan=True, auto_download_after_rescan=True,
folder_scan_enabled=True,
) )
dumped = original.model_dump() dumped = original.model_dump()
restored = SchedulerConfig(**dumped) restored = SchedulerConfig(**dumped)
@@ -247,13 +192,10 @@ class TestSchedulerConfigSerialisation:
""" """
original = SchedulerConfig( original = SchedulerConfig(
auto_download_after_rescan=True, auto_download_after_rescan=True,
folder_scan_enabled=True,
) )
dumped = original.model_dump() dumped = original.model_dump()
# Alias fields must not appear when None # Alias fields must not appear when None
assert "auto_download" not in dumped assert "auto_download" not in dumped
assert "folder_scan" not in dumped
# Primary fields roundtrip correctly # Primary fields roundtrip correctly
restored = SchedulerConfig(**dumped) restored = SchedulerConfig(**dumped)
assert restored.auto_download_after_rescan is True assert restored.auto_download_after_rescan is True
assert restored.folder_scan_enabled is True

View File

@@ -366,7 +366,6 @@ class TestGetStatus:
schedule_time="04:00", schedule_time="04:00",
schedule_days=["mon"], schedule_days=["mon"],
auto_download_after_rescan=True, auto_download_after_rescan=True,
folder_scan_enabled=True,
) )
status = scheduler_service.get_status() status = scheduler_service.get_status()
@@ -376,100 +375,13 @@ class TestGetStatus:
assert "schedule_time" in status assert "schedule_time" in status
assert "schedule_days" in status assert "schedule_days" in status
assert "auto_download_after_rescan" in status assert "auto_download_after_rescan" in status
assert "folder_scan_enabled" in status
assert status["schedule_time"] == "04:00" assert status["schedule_time"] == "04:00"
assert status["schedule_days"] == ["mon"] assert status["schedule_days"] == ["mon"]
assert status["auto_download_after_rescan"] is True assert status["auto_download_after_rescan"] is True
assert status["folder_scan_enabled"] is True
assert status["is_running"] is False assert status["is_running"] is False
assert status["next_run"] is None assert status["next_run"] is None
# ---------------------------------------------------------------------------
# 12.11 _perform_rescan() with folder_scan_enabled=True
# ---------------------------------------------------------------------------
class TestPerformRescanFolderScan:
@pytest.mark.asyncio
async def test_folder_scan_called_when_enabled(self, scheduler_service):
scheduler_service._config = SchedulerConfig(
folder_scan_enabled=True,
schedule_time="03:00",
schedule_days=ALL_DAYS,
)
mock_anime = MagicMock()
mock_anime.rescan = AsyncMock()
mock_anime._cached_list_missing.return_value = []
mock_ws = MagicMock()
mock_ws.manager.broadcast = AsyncMock()
mock_folder_scan = AsyncMock()
with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \
patch("src.server.services.scheduler.folder_scan_service.FolderScanService") as MockFSS:
MockFSS.return_value.run_folder_scan = mock_folder_scan
await scheduler_service._perform_rescan()
mock_folder_scan.assert_awaited_once()
@pytest.mark.asyncio
async def test_folder_scan_skipped_when_disabled(self, scheduler_service):
scheduler_service._config = SchedulerConfig(
folder_scan_enabled=False,
schedule_time="03:00",
schedule_days=ALL_DAYS,
)
mock_anime = MagicMock()
mock_anime.rescan = AsyncMock()
mock_anime._cached_list_missing.return_value = []
mock_ws = MagicMock()
mock_ws.manager.broadcast = AsyncMock()
mock_folder_scan = AsyncMock()
with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \
patch("src.server.services.scheduler.folder_scan_service.FolderScanService") as MockFSS:
MockFSS.return_value.run_folder_scan = mock_folder_scan
await scheduler_service._perform_rescan()
mock_folder_scan.assert_not_called()
@pytest.mark.asyncio
async def test_folder_scan_error_broadcasts_and_does_not_crash(self, scheduler_service):
scheduler_service._config = SchedulerConfig(
folder_scan_enabled=True,
schedule_time="03:00",
schedule_days=ALL_DAYS,
)
mock_anime = MagicMock()
mock_anime.rescan = AsyncMock()
mock_anime._cached_list_missing.return_value = []
mock_ws = MagicMock()
mock_ws.manager.broadcast = AsyncMock()
mock_folder_scan = AsyncMock(side_effect=RuntimeError("folder scan boom"))
with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \
patch("src.server.services.scheduler.folder_scan_service.FolderScanService") as MockFSS:
MockFSS.return_value.run_folder_scan = mock_folder_scan
# Should NOT raise
await scheduler_service._perform_rescan()
mock_folder_scan.assert_awaited_once()
calls = [str(c) for c in mock_ws.manager.broadcast.call_args_list]
assert any("folder_scan_error" in c for c in calls)
assert scheduler_service._scan_in_progress is False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Singleton helpers # Singleton helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------