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 `
()` 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 `` | NFO `` | 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 `` or `` (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 `` (or any ``) 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 `` 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 ```` and ````, 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 ```` and ````.
+ 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(
+ "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()