diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 90c8409..d13a6b4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -294,8 +294,6 @@ The FastAPI lifespan function (`src/server/fastapi_app.py`) runs the following s +-- Cron-based library rescans configured +-- Optional: auto-download missing episodes after rescan +-- Optional: folder maintenance (NFO repair, renaming, poster checks) during scheduled runs - -10. NFO repair scan (queue incomplete tvshow.nfo files for background reload) ``` ### 12.2 Temp Folder Guarantee diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a022dff..543a6d9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -73,17 +73,16 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle that detects incomplete `tvshow.nfo` files and triggers TMDB re-fetch. Provides `parse_nfo_tags()`, `find_missing_tags()`, `nfo_needs_repair()`, and `NfoRepairService.repair_series()`. 13 required tags are checked. -- **`perform_nfo_repair_scan()` startup hook +- **`perform_nfo_repair_scan()` (`src/server/services/initialization_service.py`)**: New async function - called during application startup. Iterates every series directory, checks - whether `tvshow.nfo` is missing required tags using `nfo_needs_repair()`, and - either queues the series for background reload (when a `background_loader` is - provided) or calls `NfoRepairService.repair_series()` directly. Skips - gracefully when `tmdb_api_key` or `anime_directory` is not configured. -- **NFO repair wired into startup lifespan (`src/server/fastapi_app.py`)**: - `perform_nfo_repair_scan(background_loader)` is called at the end of the - FastAPI lifespan startup, after `perform_media_scan_if_needed`, ensuring - every existing series NFO is checked and repaired on each server start. + that iterates every series directory, checks whether `tvshow.nfo` is missing + required tags using `nfo_needs_repair()`, and queues the series for background + reload via `asyncio.create_task`. Skips gracefully when `tmdb_api_key` or + `anime_directory` is not configured. +- **NFO repair wired into scheduled folder scan (`src/server/services/folder_scan_service.py`)**: + `perform_nfo_repair_scan(background_loader=None)` is called during the + scheduled daily folder scan, keeping startup fast while ensuring regular + maintenance. ### Changed diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 55c3d0a..ec171a1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -174,7 +174,7 @@ Controls automatic cron-based library rescanning (powered by APScheduler). | `scheduler.schedule_time` | string | `"03:00"` | Daily run time in 24-h `HH:MM` format. | | `scheduler.schedule_days` | list[string] | `["mon","tue","wed","thu","fri","sat","sun"]` | Days of the week to run the scan. Empty list disables the cron job. | | `scheduler.auto_download_after_rescan` | bool | `false` | Automatically queue missing episodes for download after each rescan. | -| `scheduler.folder_scan_enabled` | bool | `false` | Run folder maintenance (NFO repair, folder renaming, poster checks) during scheduled runs. | +| `scheduler.folder_scan_enabled` | bool | `false` | Run folder maintenance (NFO repair, folder renaming, poster checks) during scheduled runs. **When enabled, series folders are automatically renamed to match the ` (<year>)` convention derived from their `tvshow.nfo` files.** | Valid day abbreviations: `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun`. @@ -218,6 +218,7 @@ Source: [src/server/models/config.py](../src/server/models/config.py#L15-L24) - Obtain a TMDB API key from https://www.themoviedb.org/settings/api - `auto_create` creates NFO files during the download process - `update_on_scan` refreshes metadata when scanning existing anime +- `download_poster` also controls whether the scheduled folder scan checks for and re-downloads missing or corrupted `poster.jpg` files (see [NFO_GUIDE.md](NFO_GUIDE.md#6-poster-check)) - Image downloads require valid `tmdb_api_key` - `TMDB_API_KEY` environment variable is optional when `nfo.tmdb_api_key` is configured in `data/config.json` - Larger image sizes (`w780`, `original`) consume more storage space diff --git a/docs/NFO_GUIDE.md b/docs/NFO_GUIDE.md index cb4dc52..18f522f 100644 --- a/docs/NFO_GUIDE.md +++ b/docs/NFO_GUIDE.md @@ -246,7 +246,84 @@ NFO files are created in the anime directory: --- -## 5. API Reference +## 5. Folder Naming Convention + +### 5.1 Expected Format + +After the daily folder scan (when **Update on library scan** is enabled), Aniworld validates every series folder against its `tvshow.nfo` metadata. If the folder name does not match the expected convention, it is automatically renamed. + +**Format:** + +``` +{title} ({year}) +``` + +**Examples:** + +| NFO `<title>` | NFO `<year>` | Expected Folder Name | +|---------------|--------------|----------------------| +| `Attack on Titan` | `2013` | `Attack on Titan (2013)` | +| `One Piece` | `1999` | `One Piece (1999)` | +| `Demon Slayer: Kimetsu no Yaiba` | `2019` | `Demon Slayer Kimetsu no Yaiba (2019)` | + +### 5.2 Sanitization Rules + +Illegal filesystem characters are removed or replaced to ensure cross-platform compatibility: + +- Removed: `< > : " / \ | ? *` and null bytes +- Control characters stripped +- Multiple spaces collapsed to one +- Leading/trailing dots and whitespace trimmed +- Maximum length: 200 characters (truncated at word boundary if possible) + +### 5.3 Skip Conditions + +A folder is **not** renamed when any of the following apply: + +- `tvshow.nfo` is missing `<title>` or `<year>` (or they are empty) +- The series has an **active or pending download** +- The target folder name already exists (duplicate) +- The resulting path would exceed the OS path-length limit +- The app lacks write permission to the anime directory + +All skipped and renamed actions are logged. + +--- + +## 6. Poster Check + +### 6.1 Overview + +During the daily folder scan, Aniworld checks every series folder for a valid `poster.jpg`. If the file is missing or smaller than 1 KB, the application attempts to re-download it from the URL stored in the series' `tvshow.nfo` file. + +### 6.2 How It Works + +1. **Scan** — After folder renaming, the scan iterates over all series folders that contain a `tvshow.nfo`. +2. **Validate** — For each folder, it checks whether `poster.jpg` exists and is at least 1 KB. +3. **Parse NFO** — If the poster is missing or too small, the scan reads `tvshow.nfo` and looks for a `<thumb aspect="poster">` (or any `<thumb>`) URL. +4. **Download** — If a URL is found, the poster is downloaded using `ImageDownloader` with a concurrency limit of 3 simultaneous downloads. +5. **Validate Download** — The downloaded image is validated with PIL to ensure it is not corrupted. + +### 6.3 Skip Conditions + +A folder is **not** processed for poster download when any of the following apply: + +- `tvshow.nfo` does not exist in the folder. +- `poster.jpg` already exists and is ≥ 1 KB. +- No `<thumb>` URL is found in the NFO (the NFO may have been created before thumb tags were added). +- The `nfo.download_poster` setting is `false` (poster checks are still performed, but downloads are skipped if the setting is disabled; see [CONFIGURATION.md](CONFIGURATION.md)). + +### 6.4 Logging + +Every poster check action is logged: + +- **INFO** — When a poster is successfully downloaded. +- **WARNING** — When a download fails or no URL is found. +- **ERROR** — When an unexpected exception occurs during download. + +--- + +## 7. API Reference ### 5.1 Check NFO Status diff --git a/src/server/services/folder_rename_service.py b/src/server/services/folder_rename_service.py new file mode 100644 index 0000000..0b39ee5 --- /dev/null +++ b/src/server/services/folder_rename_service.py @@ -0,0 +1,331 @@ +"""Folder rename service for validating and renaming series folders. + +After NFO repair, this service iterates over every subfolder in +``settings.anime_directory`` that contains a ``tvshow.nfo``. For each +folder it parses the NFO to extract ``<title>`` and ``<year>``, computes +the expected folder name ``f"{title} ({year})"``, sanitises it for +filesystem safety, and renames the folder if the current name differs. + +Database records (``AnimeSeries.folder``, ``Episode.file_path``, +``DownloadQueueItem.file_destination``) are updated atomically to +reflect the new paths. +""" +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +from lxml import etree + +from src.config.settings import settings +from src.server.database.connection import get_db_session +from src.server.database.service import ( + AnimeSeriesService, + DownloadQueueService, + EpisodeService, +) +from src.server.utils.dependencies import get_download_service +from src.server.utils.filesystem import sanitize_folder_name + +logger = logging.getLogger(__name__) + +# Characters that are invalid in filesystem paths across platforms +INVALID_PATH_CHARS = '<>:"/\\|?*\x00' + + +def _parse_nfo_title_and_year(nfo_path: Path) -> Tuple[Optional[str], Optional[str]]: + """Parse a tvshow.nfo and return (title, year) text values. + + Args: + nfo_path: Absolute path to the ``tvshow.nfo`` file. + + Returns: + Tuple of (title, year) where either may be ``None`` if missing + or empty. + """ + try: + tree = etree.parse(str(nfo_path)) + root = tree.getroot() + + title_elem = root.find("./title") + year_elem = root.find("./year") + + title = title_elem.text.strip() if title_elem is not None and title_elem.text and title_elem.text.strip() else None + year = year_elem.text.strip() if year_elem is not None and year_elem.text and year_elem.text.strip() else None + + return title, year + except etree.XMLSyntaxError as exc: + logger.warning("Malformed XML in %s: %s", nfo_path, exc) + return None, None + except Exception as exc: # pylint: disable=broad-except + logger.warning("Unexpected error parsing %s: %s", nfo_path, exc) + return None, None + + +def _compute_expected_folder_name(title: str, year: str) -> str: + """Compute the expected folder name from title and year. + + Args: + title: Series title from NFO. + year: Release year from NFO. + + Returns: + Sanitised folder name in the format ``"{title} ({year})"``. + """ + raw_name = f"{title} ({year})" + return sanitize_folder_name(raw_name) + + +def _is_series_being_downloaded(series_folder: str) -> bool: + """Check whether the given series has an active or pending download. + + Args: + series_folder: The series folder name (as stored in the DB). + + Returns: + ``True`` if the series appears in the active download or the + pending queue. + """ + try: + download_service = get_download_service() + active = download_service._active_download # pylint: disable=protected-access + if active and active.serie_folder == series_folder: + return True + for item in download_service._pending_queue: # pylint: disable=protected-access + if item.serie_folder == series_folder: + return True + return False + except Exception as exc: # pylint: disable=broad-except + logger.warning( + "Could not check download status for %s: %s", series_folder, exc + ) + # Safer to skip renaming if we can't verify download status. + return True + + +async def _update_database_paths( + old_folder: str, + new_folder: str, + anime_dir: Path, +) -> None: + """Update all database records that reference the old folder path. + + Updates: + - ``AnimeSeries.folder`` → ``new_folder`` + - ``Episode.file_path`` → adjusted to new folder + - ``DownloadQueueItem.file_destination`` → adjusted to new folder + + Args: + old_folder: Previous folder name. + new_folder: New folder name. + anime_dir: Root anime directory path. + """ + old_series_path = anime_dir / old_folder + new_series_path = anime_dir / new_folder + + async with get_db_session() as db: + # 1. Update AnimeSeries.folder + series = await AnimeSeriesService.get_by_key(db, old_folder) + if series is None: + # Fallback: try to find by folder name + all_series = await AnimeSeriesService.get_all(db) + for s in all_series: + if s.folder == old_folder: + series = s + break + + if series is None: + logger.warning( + "No database record found for folder '%s', skipping DB update", + old_folder, + ) + return + + await AnimeSeriesService.update(db, series.id, folder=new_folder) + logger.info( + "Updated AnimeSeries.folder: %s → %s (id=%s)", + old_folder, + new_folder, + series.id, + ) + + # 2. Update Episode.file_path for all episodes of this series + episodes = await EpisodeService.get_by_series(db, series.id) + for episode in episodes: + if episode.file_path: + old_file_path = Path(episode.file_path) + # Only update if the path is under the old series folder + try: + old_file_path.relative_to(old_series_path) + new_file_path = new_series_path / old_file_path.relative_to( + old_series_path + ) + episode.file_path = str(new_file_path) + logger.debug( + "Updated Episode.file_path: %s → %s", + old_file_path, + new_file_path, + ) + except ValueError: + # Path is not under old_series_path, skip + pass + + await db.flush() + + # 3. Update DownloadQueueItem.file_destination for pending items + queue_items = await DownloadQueueService.get_all(db, with_series=True) + for item in queue_items: + if item.series_id == series.id and item.file_destination: + old_dest = Path(item.file_destination) + try: + old_dest.relative_to(old_series_path) + new_dest = new_series_path / old_dest.relative_to( + old_series_path + ) + item.file_destination = str(new_dest) + logger.debug( + "Updated DownloadQueueItem.file_destination: %s → %s", + old_dest, + new_dest, + ) + except ValueError: + pass + + await db.flush() + logger.info( + "Database paths updated for series '%s' → '%s'", + old_folder, + new_folder, + ) + + +async def validate_and_rename_series_folders() -> Dict[str, int]: + """Validate and rename series folders to match NFO metadata. + + Iterates over every subfolder in ``settings.anime_directory`` that + contains a ``tvshow.nfo``. For each folder: + + 1. Parse the NFO to extract ``<title>`` and ``<year>``. + 2. Compute the expected folder name: ``f"{title} ({year})"``. + 3. Sanitise the expected name for filesystem safety. + 4. Compare with the current folder name. + 5. If different, rename the folder and update the database. + + Skips folders where title or year is missing/empty. Logs every + rename action. + + Returns: + Dictionary with counts: + - ``"scanned"``: total folders scanned + - ``"renamed"``: folders renamed + - ``"skipped"``: folders skipped (missing title/year) + - ``"errors"``: folders that caused an error + """ + if not settings.anime_directory: + logger.warning("Folder rename skipped — anime directory not configured") + return {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0} + + anime_dir = Path(settings.anime_directory) + if not anime_dir.is_dir(): + logger.warning( + "Folder rename skipped — anime directory not found: %s", anime_dir + ) + return {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0} + + stats = {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0} + + for series_dir in sorted(anime_dir.iterdir()): + if not series_dir.is_dir(): + continue + + nfo_path = series_dir / "tvshow.nfo" + if not nfo_path.exists(): + continue + + stats["scanned"] += 1 + + title, year = _parse_nfo_title_and_year(nfo_path) + if not title or not year: + logger.info( + "Skipping rename for '%s' — missing title or year in NFO", + series_dir.name, + ) + stats["skipped"] += 1 + continue + + expected_name = _compute_expected_folder_name(title, year) + current_name = series_dir.name + + if expected_name == current_name: + logger.debug( + "Folder name already correct: '%s'", current_name + ) + continue + + # Check for active downloads + if _is_series_being_downloaded(current_name): + logger.info( + "Skipping rename for '%s' — series has active or pending downloads", + current_name, + ) + stats["skipped"] += 1 + continue + + expected_path = anime_dir / expected_name + + # Check for duplicate target + if expected_path.exists(): + logger.warning( + "Cannot rename '%s' → '%s' — target already exists", + current_name, + expected_name, + ) + stats["errors"] += 1 + continue + + # Check path length limits + if len(str(expected_path)) > 4096: + logger.warning( + "Cannot rename '%s' → '%s' — path exceeds OS limit", + current_name, + expected_name, + ) + stats["errors"] += 1 + continue + + try: + series_dir.rename(expected_path) + logger.info( + "Renamed folder: '%s' → '%s'", current_name, expected_name + ) + stats["renamed"] += 1 + + # Update database records + await _update_database_paths(current_name, expected_name, anime_dir) + + except PermissionError as exc: + logger.error( + "Permission denied renaming '%s' → '%s': %s", + current_name, + expected_name, + exc, + ) + stats["errors"] += 1 + except OSError as exc: + logger.error( + "OS error renaming '%s' → '%s': %s", + current_name, + expected_name, + exc, + ) + stats["errors"] += 1 + + logger.info( + "Folder rename scan complete: scanned=%d, renamed=%d, skipped=%d, errors=%d", + stats["scanned"], + stats["renamed"], + stats["skipped"], + stats["errors"], + ) + return stats diff --git a/tests/integration/test_folder_rename_startup.py b/tests/integration/test_folder_rename_startup.py new file mode 100644 index 0000000..22f8b3a --- /dev/null +++ b/tests/integration/test_folder_rename_startup.py @@ -0,0 +1,109 @@ +"""Integration tests for folder rename service wiring. + +These tests verify that: +1. FolderScanService.run_folder_scan calls validate_and_rename_series_folders. +2. The rename logic is properly integrated into the scheduled folder scan. +""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +class TestFolderRenameScanCalledInFolderScan: + """Verify validate_and_rename_series_folders is invoked from FolderScanService.""" + + def test_validate_and_rename_imported_in_folder_scan_service(self): + """folder_scan_service.py imports validate_and_rename_series_folders.""" + import importlib + + source = importlib.util.find_spec( + "src.server.services.folder_scan_service" + ).origin + with open(source, "r", encoding="utf-8") as fh: + content = fh.read() + + assert "validate_and_rename_series_folders" in content, ( + "validate_and_rename_series_folders must be imported in folder_scan_service.py" + ) + + def test_validate_and_rename_called_in_run_folder_scan(self): + """validate_and_rename_series_folders must be called inside run_folder_scan.""" + import importlib + + source = importlib.util.find_spec( + "src.server.services.folder_scan_service" + ).origin + with open(source, "r", encoding="utf-8") as fh: + content = fh.read() + + run_folder_scan_pos = content.find("def run_folder_scan") + rename_call_pos = content.find("validate_and_rename_series_folders()") + + assert run_folder_scan_pos != -1, "run_folder_scan method not found" + assert rename_call_pos != -1, "validate_and_rename_series_folders call not found" + assert rename_call_pos > run_folder_scan_pos, ( + "validate_and_rename_series_folders must be called INSIDE run_folder_scan" + ) + + +class TestFolderRenameIntegration: + """Integration test: folder rename is triggered during folder scan.""" + + @pytest.mark.asyncio + async def test_folder_rename_runs_during_scan(self, tmp_path): + """When folder_scan_enabled is true, the scan renames mismatched folders.""" + from src.server.services.folder_scan_service import FolderScanService + + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + series_dir = anime_dir / "Attack on Titan" + series_dir.mkdir() + (series_dir / "tvshow.nfo").write_text( + "<tvshow><title>Attack on Titan2013" + ) + + mock_settings = MagicMock() + mock_settings.tmdb_api_key = "test-key" + mock_settings.anime_directory = str(anime_dir) + + with patch( + "src.config.settings.settings", mock_settings + ), patch( + "src.server.services.folder_rename_service.settings", mock_settings + ), patch( + "src.server.services.folder_scan_service.perform_nfo_repair_scan", + new_callable=AsyncMock, + ), patch( + "src.server.services.folder_rename_service._is_series_being_downloaded", + return_value=False, + ), patch( + "src.server.services.folder_rename_service._update_database_paths", + new_callable=AsyncMock, + ): + service = FolderScanService() + await service.run_folder_scan() + + assert not series_dir.exists() + assert (anime_dir / "Attack on Titan (2013)").is_dir() + + @pytest.mark.asyncio + async def test_folder_rename_skipped_when_prerequisites_not_met(self, tmp_path): + """If anime directory is missing, rename logic is skipped gracefully.""" + from src.server.services.folder_scan_service import FolderScanService + + mock_settings = MagicMock() + mock_settings.tmdb_api_key = "test-key" + mock_settings.anime_directory = str(tmp_path / "nonexistent") + + with patch( + "src.config.settings.settings", mock_settings + ), patch( + "src.server.services.folder_scan_service.perform_nfo_repair_scan", + new_callable=AsyncMock, + ), patch( + "src.server.services.folder_rename_service.validate_and_rename_series_folders" + ) as mock_rename: + service = FolderScanService() + await service.run_folder_scan() + + mock_rename.assert_not_called() diff --git a/tests/integration/test_nfo_repair_startup.py b/tests/integration/test_nfo_repair_startup.py index 05f5e2f..08f970b 100644 --- a/tests/integration/test_nfo_repair_startup.py +++ b/tests/integration/test_nfo_repair_startup.py @@ -1,14 +1,32 @@ -"""Integration tests verifying perform_nfo_repair_scan is wired into folder scan. +"""Integration tests verifying perform_nfo_repair_scan is wired into folder scan +and NOT called during FastAPI lifespan startup. These tests confirm that: 1. FolderScanService.run_folder_scan calls perform_nfo_repair_scan. -2. Series with incomplete NFO files are queued via asyncio.create_task. +2. perform_nfo_repair_scan is NOT imported or called in fastapi_app.py lifespan. +3. Series with incomplete NFO files are queued via asyncio.create_task. """ from unittest.mock import AsyncMock, MagicMock, call, patch import pytest +class TestNfoRepairScanNotCalledOnStartup: + """Verify perform_nfo_repair_scan is NOT invoked during FastAPI lifespan startup.""" + + def test_perform_nfo_repair_scan_not_imported_in_lifespan(self): + """fastapi_app.py lifespan must not import or call perform_nfo_repair_scan.""" + import importlib + + source = importlib.util.find_spec("src.server.fastapi_app").origin + with open(source, "r", encoding="utf-8") as fh: + content = fh.read() + + assert "perform_nfo_repair_scan" not in content, ( + "perform_nfo_repair_scan must NOT be imported or called in fastapi_app.py" + ) + + class TestNfoRepairScanCalledInFolderScan: """Verify perform_nfo_repair_scan is invoked from FolderScanService.""" diff --git a/tests/integration/test_poster_check_startup.py b/tests/integration/test_poster_check_startup.py new file mode 100644 index 0000000..cee148e --- /dev/null +++ b/tests/integration/test_poster_check_startup.py @@ -0,0 +1,294 @@ +"""Integration tests for poster check service wiring. + +These tests verify that: +1. FolderScanService.run_folder_scan calls check_and_download_missing_posters. +2. The poster check logic is properly integrated into the scheduled folder scan. +3. Missing posters are downloaded, valid posters are skipped, and errors are handled. +""" +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +class TestPosterCheckScanCalledInFolderScan: + """Verify check_and_download_missing_posters is invoked from FolderScanService.""" + + def test_check_and_download_missing_posters_imported_in_folder_scan_service(self): + """folder_scan_service.py imports check_and_download_missing_posters.""" + import importlib + + source = importlib.util.find_spec( + "src.server.services.folder_scan_service" + ).origin + with open(source, "r", encoding="utf-8") as fh: + content = fh.read() + + assert "check_and_download_missing_posters" in content, ( + "check_and_download_missing_posters must be imported in folder_scan_service.py" + ) + + def test_check_and_download_missing_posters_called_in_run_folder_scan(self): + """check_and_download_missing_posters must be called inside run_folder_scan.""" + import importlib + + source = importlib.util.find_spec( + "src.server.services.folder_scan_service" + ).origin + with open(source, "r", encoding="utf-8") as fh: + content = fh.read() + + run_folder_scan_pos = content.find("def run_folder_scan") + poster_call_pos = content.find("check_and_download_missing_posters()") + + assert run_folder_scan_pos != -1, "run_folder_scan method not found" + assert poster_call_pos != -1, "check_and_download_missing_posters call not found" + assert poster_call_pos > run_folder_scan_pos, ( + "check_and_download_missing_posters must be called INSIDE run_folder_scan" + ) + + +class TestPosterCheckIntegration: + """Integration test: poster check is triggered during folder scan.""" + + @pytest.mark.asyncio + async def test_poster_check_downloads_missing_poster(self, tmp_path): + """When poster.jpg is missing, the scan downloads it from the NFO thumb URL.""" + from src.server.services.folder_scan_service import FolderScanService + + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + series_dir = anime_dir / "Attack on Titan (2013)" + series_dir.mkdir() + (series_dir / "tvshow.nfo").write_text( + "\n" + "\n" + " Attack on Titan\n" + " 2013\n" + ' https://example.com/poster.jpg\n' + "\n" + ) + + mock_settings = MagicMock() + mock_settings.tmdb_api_key = "test-key" + mock_settings.anime_directory = str(anime_dir) + + call_log = [] + + class MockDownloader: + """Fake ImageDownloader that records calls.""" + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def download_poster(self, url, folder, skip_existing=True): + call_log.append({"url": url, "folder": folder, "skip_existing": skip_existing}) + return True + + with patch( + "src.config.settings.settings", mock_settings + ), patch( + "src.server.services.folder_scan_service.perform_nfo_repair_scan", + new_callable=AsyncMock, + ), patch( + "src.server.services.folder_rename_service.validate_and_rename_series_folders", + new_callable=AsyncMock, + return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, + ), patch( + "src.server.services.folder_scan_service.ImageDownloader", + new=MockDownloader, + ): + service = FolderScanService() + await service.run_folder_scan() + + assert len(call_log) == 1, f"Expected 1 download call, got {len(call_log)}" + assert call_log[0]["url"] == "https://example.com/poster.jpg" + assert call_log[0]["folder"] == series_dir + assert call_log[0]["skip_existing"] is False + + @pytest.mark.asyncio + async def test_poster_check_skips_valid_poster(self, tmp_path): + """When poster.jpg exists and is large enough, the scan skips it.""" + from src.server.services.folder_scan_service import FolderScanService + + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + series_dir = anime_dir / "Attack on Titan (2013)" + series_dir.mkdir() + (series_dir / "tvshow.nfo").write_text( + "" + "Attack on Titan" + "2013" + "https://example.com/poster.jpg" + "" + ) + # Create a valid poster.jpg (larger than 1 KB) + poster_path = series_dir / "poster.jpg" + poster_path.write_bytes(b"x" * 2048) + + mock_settings = MagicMock() + mock_settings.tmdb_api_key = "test-key" + mock_settings.anime_directory = str(anime_dir) + + with patch( + "src.config.settings.settings", mock_settings + ), patch( + "src.server.services.folder_scan_service.perform_nfo_repair_scan", + new_callable=AsyncMock, + ), patch( + "src.server.services.folder_rename_service.validate_and_rename_series_folders", + new_callable=AsyncMock, + return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, + ), patch( + "src.server.services.folder_scan_service.ImageDownloader" + ) as mock_downloader_cls: + service = FolderScanService() + await service.run_folder_scan() + + mock_downloader_cls.assert_not_called() + + @pytest.mark.asyncio + async def test_poster_check_skips_when_no_thumb_url(self, tmp_path): + """When NFO has no thumb URL, the scan skips the folder.""" + from src.server.services.folder_scan_service import FolderScanService + + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + series_dir = anime_dir / "Attack on Titan (2013)" + series_dir.mkdir() + (series_dir / "tvshow.nfo").write_text( + "" + "Attack on Titan" + "2013" + "" + ) + + mock_settings = MagicMock() + mock_settings.tmdb_api_key = "test-key" + mock_settings.anime_directory = str(anime_dir) + + with patch( + "src.config.settings.settings", mock_settings + ), patch( + "src.server.services.folder_scan_service.perform_nfo_repair_scan", + new_callable=AsyncMock, + ), patch( + "src.server.services.folder_rename_service.validate_and_rename_series_folders", + new_callable=AsyncMock, + return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, + ), patch( + "src.server.services.folder_scan_service.ImageDownloader" + ) as mock_downloader_cls: + service = FolderScanService() + await service.run_folder_scan() + + mock_downloader_cls.assert_not_called() + + @pytest.mark.asyncio + async def test_poster_check_skipped_when_prerequisites_not_met(self, tmp_path): + """If anime directory is missing, poster check logic is skipped gracefully.""" + from src.server.services.folder_scan_service import FolderScanService + + mock_settings = MagicMock() + mock_settings.tmdb_api_key = "test-key" + mock_settings.anime_directory = str(tmp_path / "nonexistent") + + with patch( + "src.config.settings.settings", mock_settings + ), patch( + "src.server.services.folder_scan_service.perform_nfo_repair_scan", + new_callable=AsyncMock, + ), patch( + "src.server.services.folder_rename_service.validate_and_rename_series_folders" + ) as mock_rename, patch( + "src.server.services.folder_scan_service.ImageDownloader" + ) as mock_downloader_cls: + service = FolderScanService() + await service.run_folder_scan() + + mock_downloader_cls.assert_not_called() + + +class TestPosterCheckSemaphore: + """Verify the poster download semaphore limits concurrency.""" + + def test_poster_download_semaphore_defined(self): + """_POSTER_DOWNLOAD_SEMAPHORE must be defined in folder_scan_service.py.""" + import importlib + + source = importlib.util.find_spec( + "src.server.services.folder_scan_service" + ).origin + with open(source, "r", encoding="utf-8") as fh: + content = fh.read() + + assert "_POSTER_DOWNLOAD_SEMAPHORE" in content, ( + "_POSTER_DOWNLOAD_SEMAPHORE must be defined in folder_scan_service.py" + ) + + @pytest.mark.asyncio + async def test_poster_download_uses_semaphore(self, tmp_path): + """Poster downloads are gated by the semaphore.""" + from src.server.services.folder_scan_service import ( + _POSTER_DOWNLOAD_SEMAPHORE, + FolderScanService, + ) + + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + + # Create multiple series folders + for i in range(5): + series_dir = anime_dir / f"Series {i}" + series_dir.mkdir() + (series_dir / "tvshow.nfo").write_text( + f"" + f"Series {i}" + f"202{i}" + f"https://example.com/poster{i}.jpg" + f"" + ) + + mock_settings = MagicMock() + mock_settings.tmdb_api_key = "test-key" + mock_settings.anime_directory = str(anime_dir) + + active_count = 0 + max_active = 0 + + async def tracked_download(*args, **kwargs): + nonlocal active_count, max_active + active_count += 1 + max_active = max(max_active, active_count) + await asyncio.sleep(0.05) + active_count -= 1 + return True + + with patch( + "src.config.settings.settings", mock_settings + ), patch( + "src.server.services.folder_scan_service.perform_nfo_repair_scan", + new_callable=AsyncMock, + ), patch( + "src.server.services.folder_rename_service.validate_and_rename_series_folders", + new_callable=AsyncMock, + return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, + ), patch( + "src.server.services.folder_scan_service.ImageDownloader" + ) as mock_downloader_cls: + mock_downloader = AsyncMock() + mock_downloader.download_poster = AsyncMock(side_effect=tracked_download) + mock_downloader_cls.return_value.__aenter__ = AsyncMock( + return_value=mock_downloader + ) + mock_downloader_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + service = FolderScanService() + await service.run_folder_scan() + + assert max_active <= 3, ( + f"Expected max concurrent downloads <= 3, got {max_active}" + ) diff --git a/tests/unit/test_folder_rename_service.py b/tests/unit/test_folder_rename_service.py new file mode 100644 index 0000000..870c0f0 --- /dev/null +++ b/tests/unit/test_folder_rename_service.py @@ -0,0 +1,383 @@ +"""Unit tests for folder_rename_service.py. + +These tests verify the core logic of the folder rename service in +isolation, using temporary directories and mocked dependencies. +""" +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.server.services.folder_rename_service import ( + _compute_expected_folder_name, + _is_series_being_downloaded, + _parse_nfo_title_and_year, + _update_database_paths, + validate_and_rename_series_folders, +) + + +class TestParseNfoTitleAndYear: + """Tests for _parse_nfo_title_and_year.""" + + def test_parses_title_and_year(self, tmp_path: Path) -> None: + nfo = tmp_path / "tvshow.nfo" + nfo.write_text( + "Attack on Titan2013" + ) + title, year = _parse_nfo_title_and_year(nfo) + assert title == "Attack on Titan" + assert year == "2013" + + def test_missing_title_returns_none(self, tmp_path: Path) -> None: + nfo = tmp_path / "tvshow.nfo" + nfo.write_text("2013") + title, year = _parse_nfo_title_and_year(nfo) + assert title is None + assert year == "2013" + + def test_missing_year_returns_none(self, tmp_path: Path) -> None: + nfo = tmp_path / "tvshow.nfo" + nfo.write_text("Attack on Titan") + title, year = _parse_nfo_title_and_year(nfo) + assert title == "Attack on Titan" + assert year is None + + def test_empty_title_returns_none(self, tmp_path: Path) -> None: + nfo = tmp_path / "tvshow.nfo" + nfo.write_text( + " 2013" + ) + title, year = _parse_nfo_title_and_year(nfo) + assert title is None + assert year == "2013" + + def test_malformed_xml_returns_none(self, tmp_path: Path) -> None: + nfo = tmp_path / "tvshow.nfo" + nfo.write_text("not xml at all") + title, year = _parse_nfo_title_and_year(nfo) + assert title is None + assert year is None + + +class TestComputeExpectedFolderName: + """Tests for _compute_expected_folder_name.""" + + def test_simple_title_and_year(self) -> None: + result = _compute_expected_folder_name("Attack on Titan", "2013") + assert result == "Attack on Titan (2013)" + + def test_sanitizes_invalid_chars(self) -> None: + result = _compute_expected_folder_name("Show: Subtitle", "2020") + assert result == "Show Subtitle (2020)" + + def test_sanitizes_slashes(self) -> None: + result = _compute_expected_folder_name("A / B", "2021") + assert result == "A B (2021)" + + +class TestIsSeriesBeingDownloaded: + """Tests for _is_series_being_downloaded.""" + + def test_no_active_download(self) -> None: + mock_service = MagicMock() + mock_service._active_download = None + mock_service._pending_queue = [] + with patch( + "src.server.services.folder_rename_service.get_download_service", + return_value=mock_service, + ): + assert _is_series_being_downloaded("Some Show") is False + + def test_active_download_matches(self) -> None: + mock_item = MagicMock() + mock_item.serie_folder = "Some Show" + mock_service = MagicMock() + mock_service._active_download = mock_item + mock_service._pending_queue = [] + with patch( + "src.server.services.folder_rename_service.get_download_service", + return_value=mock_service, + ): + assert _is_series_being_downloaded("Some Show") is True + + def test_pending_download_matches(self) -> None: + mock_item = MagicMock() + mock_item.serie_folder = "Some Show" + mock_service = MagicMock() + mock_service._active_download = None + mock_service._pending_queue = [mock_item] + with patch( + "src.server.services.folder_rename_service.get_download_service", + return_value=mock_service, + ): + assert _is_series_being_downloaded("Some Show") is True + + def test_exception_returns_true_for_safety(self) -> None: + with patch( + "src.server.services.folder_rename_service.get_download_service", + side_effect=RuntimeError("boom"), + ): + assert _is_series_being_downloaded("Some Show") is True + + +class TestUpdateDatabasePaths: + """Tests for _update_database_paths.""" + + @pytest.mark.asyncio + async def test_updates_series_folder(self, tmp_path: Path) -> None: + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + + mock_series = MagicMock() + mock_series.id = 1 + mock_series.folder = "Old Name" + + with patch( + "src.server.services.folder_rename_service.get_db_session" + ) as mock_get_db, patch( + "src.server.services.folder_rename_service.AnimeSeriesService" + ) as mock_series_svc, patch( + "src.server.services.folder_rename_service.EpisodeService" + ) as mock_episode_svc, patch( + "src.server.services.folder_rename_service.DownloadQueueService" + ) as mock_queue_svc: + + mock_db = AsyncMock() + mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db) + mock_get_db.return_value.__aexit__ = AsyncMock(return_value=False) + + mock_series_svc.get_by_key = AsyncMock(return_value=mock_series) + mock_series_svc.get_all = AsyncMock(return_value=[]) + mock_series_svc.update = AsyncMock(return_value=mock_series) + + mock_episode_svc.get_by_series = AsyncMock(return_value=[]) + mock_queue_svc.get_all = AsyncMock(return_value=[]) + + await _update_database_paths("Old Name", "New Name", anime_dir) + + mock_series_svc.update.assert_awaited_once_with( + mock_db, 1, folder="New Name" + ) + + @pytest.mark.asyncio + async def test_updates_episode_file_paths(self, tmp_path: Path) -> None: + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + old_path = anime_dir / "Old Name" / "S01E01.mkv" + new_path = anime_dir / "New Name" / "S01E01.mkv" + + mock_series = MagicMock() + mock_series.id = 1 + mock_series.folder = "Old Name" + + mock_episode = MagicMock() + mock_episode.file_path = str(old_path) + + with patch( + "src.server.services.folder_rename_service.get_db_session" + ) as mock_get_db, patch( + "src.server.services.folder_rename_service.AnimeSeriesService" + ) as mock_series_svc, patch( + "src.server.services.folder_rename_service.EpisodeService" + ) as mock_episode_svc, patch( + "src.server.services.folder_rename_service.DownloadQueueService" + ) as mock_queue_svc: + + mock_db = AsyncMock() + mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db) + mock_get_db.return_value.__aexit__ = AsyncMock(return_value=False) + + mock_series_svc.get_by_key = AsyncMock(return_value=mock_series) + mock_series_svc.get_all = AsyncMock(return_value=[]) + mock_series_svc.update = AsyncMock(return_value=mock_series) + + mock_episode_svc.get_by_series = AsyncMock(return_value=[mock_episode]) + mock_queue_svc.get_all = AsyncMock(return_value=[]) + + await _update_database_paths("Old Name", "New Name", anime_dir) + + assert mock_episode.file_path == str(new_path) + + +class TestValidateAndRenameSeriesFolders: + """Integration-style tests for validate_and_rename_series_folders.""" + + @pytest.mark.asyncio + async def test_no_anime_directory(self) -> None: + with patch( + "src.server.services.folder_rename_service.settings.anime_directory", + "", + ): + stats = await validate_and_rename_series_folders() + assert stats == {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0} + + @pytest.mark.asyncio + async def test_renames_folder_when_name_differs(self, tmp_path: Path) -> None: + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + series_dir = anime_dir / "Attack on Titan" + series_dir.mkdir() + (series_dir / "tvshow.nfo").write_text( + "Attack on Titan2013" + ) + + with patch( + "src.server.services.folder_rename_service.settings.anime_directory", + str(anime_dir), + ), patch( + "src.server.services.folder_rename_service._is_series_being_downloaded", + return_value=False, + ), patch( + "src.server.services.folder_rename_service._update_database_paths", + new_callable=AsyncMock, + ) as mock_update_db: + stats = await validate_and_rename_series_folders() + + assert stats["scanned"] == 1 + assert stats["renamed"] == 1 + assert stats["skipped"] == 0 + assert stats["errors"] == 0 + assert not series_dir.exists() + assert (anime_dir / "Attack on Titan (2013)").is_dir() + mock_update_db.assert_awaited_once() + + @pytest.mark.asyncio + async def test_skips_when_name_already_correct(self, tmp_path: Path) -> None: + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + series_dir = anime_dir / "Attack on Titan (2013)" + series_dir.mkdir() + (series_dir / "tvshow.nfo").write_text( + "Attack on Titan2013" + ) + + with patch( + "src.server.services.folder_rename_service.settings.anime_directory", + str(anime_dir), + ): + stats = await validate_and_rename_series_folders() + + assert stats["scanned"] == 1 + assert stats["renamed"] == 0 + assert stats["skipped"] == 0 + assert stats["errors"] == 0 + assert series_dir.is_dir() + + @pytest.mark.asyncio + async def test_skips_missing_title_or_year(self, tmp_path: Path) -> None: + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + series_dir = anime_dir / "Incomplete" + series_dir.mkdir() + (series_dir / "tvshow.nfo").write_text( + "Incomplete" + ) + + with patch( + "src.server.services.folder_rename_service.settings.anime_directory", + str(anime_dir), + ): + stats = await validate_and_rename_series_folders() + + assert stats["scanned"] == 1 + assert stats["renamed"] == 0 + assert stats["skipped"] == 1 + assert stats["errors"] == 0 + + @pytest.mark.asyncio + async def test_skips_when_download_active(self, tmp_path: Path) -> None: + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + series_dir = anime_dir / "Attack on Titan" + series_dir.mkdir() + (series_dir / "tvshow.nfo").write_text( + "Attack on Titan2013" + ) + + with patch( + "src.server.services.folder_rename_service.settings.anime_directory", + str(anime_dir), + ), patch( + "src.server.services.folder_rename_service._is_series_being_downloaded", + return_value=True, + ): + stats = await validate_and_rename_series_folders() + + assert stats["scanned"] == 1 + assert stats["renamed"] == 0 + assert stats["skipped"] == 1 + assert stats["errors"] == 0 + assert series_dir.is_dir() + + @pytest.mark.asyncio + async def test_errors_when_target_exists(self, tmp_path: Path) -> None: + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + series_dir = anime_dir / "Attack on Titan" + series_dir.mkdir() + (series_dir / "tvshow.nfo").write_text( + "Attack on Titan2013" + ) + # Pre-create the target folder to simulate a duplicate + (anime_dir / "Attack on Titan (2013)").mkdir() + + with patch( + "src.server.services.folder_rename_service.settings.anime_directory", + str(anime_dir), + ), patch( + "src.server.services.folder_rename_service._is_series_being_downloaded", + return_value=False, + ): + stats = await validate_and_rename_series_folders() + + assert stats["scanned"] == 1 + assert stats["renamed"] == 0 + assert stats["skipped"] == 0 + assert stats["errors"] == 1 + assert series_dir.is_dir() + + @pytest.mark.asyncio + async def test_counts_multiple_folders(self, tmp_path: Path) -> None: + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + + # Folder 1: needs rename + d1 = anime_dir / "Show A" + d1.mkdir() + (d1 / "tvshow.nfo").write_text( + "Show A2020" + ) + + # Folder 2: already correct + d2 = anime_dir / "Show B (2021)" + d2.mkdir() + (d2 / "tvshow.nfo").write_text( + "Show B2021" + ) + + # Folder 3: missing year + d3 = anime_dir / "Show C" + d3.mkdir() + (d3 / "tvshow.nfo").write_text("Show C") + + with patch( + "src.server.services.folder_rename_service.settings.anime_directory", + str(anime_dir), + ), patch( + "src.server.services.folder_rename_service._is_series_being_downloaded", + return_value=False, + ), patch( + "src.server.services.folder_rename_service._update_database_paths", + new_callable=AsyncMock, + ): + stats = await validate_and_rename_series_folders() + + assert stats["scanned"] == 3 + assert stats["renamed"] == 1 + assert stats["skipped"] == 1 + assert stats["errors"] == 0 + assert not d1.exists() + assert (anime_dir / "Show A (2020)").is_dir() + assert d2.is_dir() + assert d3.is_dir()