feat: remove startup NFO repair, update docs and tests

- Remove NFO repair scan step from ARCHITECTURE.md startup sequence
- Update CHANGELOG.md: rephrase perform_nfo_repair_scan as scheduled scan
- Add test verifying perform_nfo_repair_scan is NOT called in lifespan
- Keep existing folder scan wiring tests and unit tests intact
- NFO_GUIDE.md already correctly describes scheduled scan behavior
This commit is contained in:
2026-05-13 09:23:21 +02:00
parent eb0e6e8ccb
commit 756731cd5d
9 changed files with 1226 additions and 16 deletions

View File

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

View File

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

View File

@@ -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 `<title> (<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

View File

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

View File

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

View File

@@ -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 Titan</title><year>2013</year></tvshow>"
)
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()

View File

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

View File

@@ -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(
"<?xml version='1.0' encoding='UTF-8'?>\n"
"<tvshow>\n"
" <title>Attack on Titan</title>\n"
" <year>2013</year>\n"
' <thumb aspect="poster">https://example.com/poster.jpg</thumb>\n'
"</tvshow>\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(
"<tvshow>"
"<title>Attack on Titan</title>"
"<year>2013</year>"
"<thumb aspect='poster'>https://example.com/poster.jpg</thumb>"
"</tvshow>"
)
# 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(
"<tvshow>"
"<title>Attack on Titan</title>"
"<year>2013</year>"
"</tvshow>"
)
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"<tvshow>"
f"<title>Series {i}</title>"
f"<year>202{i}</year>"
f"<thumb aspect='poster'>https://example.com/poster{i}.jpg</thumb>"
f"</tvshow>"
)
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}"
)

View File

@@ -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(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
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("<tvshow><year>2013</year></tvshow>")
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("<tvshow><title>Attack on Titan</title></tvshow>")
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(
"<tvshow><title> </title><year>2013</year></tvshow>"
)
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(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
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(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
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(
"<tvshow><title>Incomplete</title></tvshow>"
)
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(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
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(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
# 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(
"<tvshow><title>Show A</title><year>2020</year></tvshow>"
)
# Folder 2: already correct
d2 = anime_dir / "Show B (2021)"
d2.mkdir()
(d2 / "tvshow.nfo").write_text(
"<tvshow><title>Show B</title><year>2021</year></tvshow>"
)
# Folder 3: missing year
d3 = anime_dir / "Show C"
d3.mkdir()
(d3 / "tvshow.nfo").write_text("<tvshow><title>Show C</title></tvshow>")
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()