Compare commits
6 Commits
079f1f99e3
...
ceac22fc34
| Author | SHA1 | Date | |
|---|---|---|---|
| ceac22fc34 | |||
| 9c0f7ce08d | |||
| 756731cd5d | |||
| eb0e6e8ccb | |||
| eb2fc3c5ab | |||
| c39ae9d0fc |
@@ -81,6 +81,7 @@ src/server/
|
||||
| +-- websocket_service.py# WebSocket broadcasting
|
||||
| +-- queue_repository.py # Database persistence
|
||||
| +-- nfo_service.py # NFO metadata management
|
||||
| +-- folder_scan_service.py # Daily folder maintenance scan
|
||||
+-- models/ # Pydantic models
|
||||
| +-- auth.py # Auth request/response models
|
||||
| +-- config.py # Configuration models
|
||||
@@ -290,8 +291,9 @@ The FastAPI lifespan function (`src/server/fastapi_app.py`) runs the following s
|
||||
8. Background loader service started
|
||||
|
||||
9. Scheduler service started
|
||||
|
||||
10. NFO repair scan (queue incomplete tvshow.nfo files for background reload)
|
||||
+-- Cron-based library rescans configured
|
||||
+-- Optional: auto-download missing episodes after rescan
|
||||
+-- Optional: folder maintenance (NFO repair, renaming, poster checks) during scheduled runs
|
||||
```
|
||||
|
||||
### 12.2 Temp Folder Guarantee
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -117,7 +117,8 @@ Location: `data/config.json`
|
||||
"interval_minutes": 60,
|
||||
"schedule_time": "03:00",
|
||||
"schedule_days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
|
||||
"auto_download_after_rescan": false
|
||||
"auto_download_after_rescan": false,
|
||||
"folder_scan_enabled": false
|
||||
},
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
@@ -173,6 +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. **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`.
|
||||
|
||||
@@ -216,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -675,21 +752,25 @@ The XML serialisation lives in `src/core/utils/nfo_generator.py`
|
||||
|
||||
## 11. Automatic NFO Repair
|
||||
|
||||
Every time the server starts, Aniworld scans all existing `tvshow.nfo` files and
|
||||
automatically repairs any that are missing required tags.
|
||||
NFO repair now runs as part of the scheduled daily folder scan rather than on every
|
||||
startup. When the scheduler triggers `FolderScanService.run_folder_scan()`, the first
|
||||
step is `perform_nfo_repair_scan(background_loader=None)`. Each incomplete NFO is
|
||||
queued as a background `asyncio` task, so the scan returns quickly while repairs
|
||||
continue asynchronously.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Scan** — `perform_nfo_repair_scan()` in
|
||||
`src/server/services/initialization_service.py` is called from the FastAPI
|
||||
lifespan after `perform_media_scan_if_needed()`.
|
||||
`src/server/services/initialization_service.py` is called from
|
||||
`FolderScanService.run_folder_scan()` (`src/server/services/folder_scan_service.py`).
|
||||
2. **Detect** — `nfo_needs_repair(nfo_path)` from
|
||||
`src/core/services/nfo_repair_service.py` parses each `tvshow.nfo` with
|
||||
`lxml` and checks for the 13 required tags listed below.
|
||||
3. **Repair** — Series whose NFO is incomplete are queued for background reload
|
||||
via `BackgroundLoaderService.add_series_loading_task()`. The background
|
||||
loader re-fetches metadata from TMDB and rewrites the NFO with all tags
|
||||
populated.
|
||||
via `asyncio.create_task`. Each task creates its own isolated
|
||||
:class:`NFOService` / :class:`TMDBClient` so concurrent tasks never share an
|
||||
``aiohttp`` session — this prevents "Connector is closed" errors when many repairs
|
||||
run in parallel. A semaphore caps TMDB concurrency at 3 to stay within rate limits.
|
||||
|
||||
### Tags Checked (13 required)
|
||||
|
||||
@@ -734,8 +815,8 @@ This calls `NFOService.update_tvshow_nfo()` directly and overwrites the existing
|
||||
| File | Purpose |
|
||||
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `src/core/services/nfo_repair_service.py` | `REQUIRED_TAGS`, `parse_nfo_tags`, `find_missing_tags`, `nfo_needs_repair`, `NfoRepairService` |
|
||||
| `src/server/services/initialization_service.py` | `perform_nfo_repair_scan` startup hook |
|
||||
| `src/server/fastapi_app.py` | Wires `perform_nfo_repair_scan` into the lifespan |
|
||||
| `src/server/services/initialization_service.py` | `perform_nfo_repair_scan` — invoked from `FolderScanService` |
|
||||
| `src/server/services/folder_scan_service.py` | Calls `perform_nfo_repair_scan` during the scheduled daily folder scan |
|
||||
|
||||
---
|
||||
|
||||
|
||||
278
docs/tasks.md
278
docs/tasks.md
@@ -1,174 +1,178 @@
|
||||
# Tasks — NFO Plot Missing Bug
|
||||
# Tasks
|
||||
|
||||
These tasks fix the root causes of `<plot>` being empty in `tvshow.nfo` after adding a series via the web UI.
|
||||
The bug does **not** appear after a server restart because the repair scan uses a different, correctly isolated code path.
|
||||
## 1. Scheduled Folder Scan
|
||||
|
||||
### Task 1.1: Add folder scan scheduler configuration
|
||||
|
||||
**Where is that found**
|
||||
- `src/server/models/config.py` (`SchedulerConfig`)
|
||||
- `data/config.json` (example/default config)
|
||||
- `src/server/web/templates/setup.html` (setup UI)
|
||||
- `src/server/api/auth.py` (config save endpoint, if it validates scheduler fields)
|
||||
|
||||
**Goal. How it should be**
|
||||
Add a new boolean field `folder_scan_enabled` (default `false`) to `SchedulerConfig`. When `true`, the scheduler will execute the folder maintenance routine during its scheduled run. Add the field to the setup page as a checkbox. Ensure existing configs without this field load successfully (Pydantic default handles this).
|
||||
|
||||
**Possible traps and issues**
|
||||
- Backward compatibility: old `data/config.json` files must load without errors. Pydantic defaults solve this, but verify by loading an old config.
|
||||
- The setup page JavaScript must include the new field in the payload sent to `/api/config`.
|
||||
- Do not confuse this with `auto_download_after_rescan` — this is a separate toggle.
|
||||
|
||||
**Docs changes needed**
|
||||
- `docs/CONFIGURATION.md`: Document the new `scheduler.folder_scan_enabled` option.
|
||||
- `docs/ARCHITECTURE.md`: Mention folder scan in the scheduler section.
|
||||
|
||||
**Why this is needed**
|
||||
Users need an opt-in toggle to enable automatic daily folder maintenance (NFO repair, folder renaming, poster checks) without forcing it on everyone.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Replace shared NFOService in BackgroundLoaderService with per-task instances
|
||||
### Task 1.2: Create FolderScanService skeleton
|
||||
|
||||
- [x] Completed
|
||||
**Where is that found**
|
||||
- New file: `src/server/services/folder_scan_service.py`
|
||||
- `src/server/services/scheduler_service.py` (to call it)
|
||||
|
||||
### Where
|
||||
`src/server/services/background_loader_service.py` — method `_load_nfo_and_images` (~line 555)
|
||||
**Goal. How it should be**
|
||||
Create a new `FolderScanService` class with a single async entry point `async def run_folder_scan(self) -> None`. The method should:
|
||||
1. Log start/completion with structlog.
|
||||
2. Check prerequisites (`settings.anime_directory` exists, `settings.tmdb_api_key` is set).
|
||||
3. Skip gracefully with a warning log if prerequisites are missing.
|
||||
4. Use a module-level semaphore (similar to `_NFO_REPAIR_SEMAPHORE`) to limit concurrent TMDB operations to 3.
|
||||
|
||||
```python
|
||||
nfo_path = await self.series_app.nfo_service.create_tvshow_nfo(
|
||||
serie_name=task.name,
|
||||
serie_folder=task.folder,
|
||||
year=task.year,
|
||||
...
|
||||
)
|
||||
```
|
||||
Keep the implementation empty for the sub-tasks (1.3–1.5) to fill in. Just add the skeleton and the semaphore.
|
||||
|
||||
### Goal
|
||||
Create a fresh, isolated `NFOService` (with its own `TMDBClient` and `aiohttp` session) for every background loading task, exactly the same way `_repair_one_series` in `initialization_service.py` already does it.
|
||||
Each task must own its client so that closing the session at the end of one task never kills an in-flight request inside another task.
|
||||
**Possible traps and issues**
|
||||
- Circular imports: `folder_scan_service.py` will import from `initialization_service`, `config.settings`, etc. Keep imports inside methods or at the bottom if circular issues arise.
|
||||
- The service should follow the singleton pattern like `SchedulerService` and `DownloadService` if it holds state, or be stateless. For simplicity, make it a plain class instantiated per call or a module-level function set.
|
||||
- Exception handling: any unhandled exception in the scheduled task should be caught and logged so it doesn't crash the scheduler.
|
||||
|
||||
### How it should look
|
||||
```python
|
||||
from src.core.services.nfo_factory import NFOServiceFactory
|
||||
**Docs changes needed**
|
||||
- `docs/ARCHITECTURE.md`: Add `folder_scan_service.py` to the services list.
|
||||
|
||||
factory = NFOServiceFactory()
|
||||
nfo_service = factory.create()
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
serie_name=task.name,
|
||||
serie_folder=task.folder,
|
||||
year=task.year,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
### Possible traps and issues
|
||||
- `NFOServiceFactory.create()` raises `ValueError` if no TMDB API key is available. Wrap in try/except and fall back gracefully (same behaviour as now when `nfo_service` is `None`).
|
||||
- The factory reads the API key from `settings` first, then from `config.json`. Do not pass the key explicitly so the fallback chain stays intact.
|
||||
- Each new `NFOService` opens its own `aiohttp` connector. Make sure to call `await nfo_service.close()` in a `finally` block to avoid connector leaks.
|
||||
|
||||
### Docs changes needed
|
||||
None — this is an internal implementation detail.
|
||||
|
||||
### Why this is needed
|
||||
Up to 5 background workers share one `NFOService`/`TMDBClient` instance. The `async with self.tmdb_client:` context manager inside `create_tvshow_nfo` calls `close()` on `__aexit__`, setting `session = None`. When Worker B exits its context while Worker A is still inside `_enrich_details_with_fallback` trying the `en-US` fallback request, that request throws "Connector is closed". The exception is silently swallowed, both `en-US` and `ja-JP` fallbacks fail, `details["overview"]` stays empty, and `plot` is written as an empty element.
|
||||
**Why this is needed**
|
||||
Encapsulates the new daily maintenance logic in its own module, keeping `scheduler_service.py` clean and allowing the folder scan to be tested independently.
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — Guard NFOService init in SeriesApp on factory fallback, not just env var
|
||||
### Task 1.3: Integrate NFO repair into folder scan
|
||||
|
||||
- [x] Completed
|
||||
**Where is that found**
|
||||
- `src/server/services/folder_scan_service.py`
|
||||
- `src/server/services/initialization_service.py` (`perform_nfo_repair_scan`)
|
||||
|
||||
### Where
|
||||
`src/core/SeriesApp.py` — `__init__` method (~line 175)
|
||||
**Goal. How it should be**
|
||||
Inside `FolderScanService.run_folder_scan()`, call `perform_nfo_repair_scan(background_loader=None)` as the first step. Reuse the existing function exactly — do not copy its logic. Log a message before and after the call.
|
||||
|
||||
```python
|
||||
self.nfo_service: Optional[NFOService] = None
|
||||
if settings.tmdb_api_key: # ← checks env var ONLY
|
||||
factory = get_nfo_factory()
|
||||
self.nfo_service = factory.create()
|
||||
```
|
||||
**Possible traps and issues**
|
||||
- `perform_nfo_repair_scan` spawns `asyncio.create_task` for each repair. When called from the scheduler, these background tasks will still run after `run_folder_scan` returns. This is fine, but log that repairs are queued.
|
||||
- The function already handles missing `tmdb_api_key` and `anime_directory`, so the caller doesn't need to double-check, but the skeleton from Task 1.2 already checks prerequisites.
|
||||
- `perform_nfo_repair_scan` imports `nfo_needs_repair` and `NfoRepairService` inside the function, so no heavy import-time dependencies.
|
||||
|
||||
### Goal
|
||||
The guard condition should be equivalent to what `NFOServiceFactory.create()` itself checks: whether the key is available from *any* source (env var or `config.json`). Replace the guard with a try/create pattern so that `nfo_service` is initialised whenever the factory would succeed.
|
||||
**Docs changes needed**
|
||||
- `docs/NFO_GUIDE.md`: Update the "Automatic NFO Repair" section to state that repair now runs as part of the scheduled folder scan instead of every startup.
|
||||
|
||||
### How it should look
|
||||
```python
|
||||
self.nfo_service: Optional[NFOService] = None
|
||||
try:
|
||||
from src.core.services.nfo_factory import get_nfo_factory
|
||||
factory = get_nfo_factory()
|
||||
self.nfo_service = factory.create()
|
||||
logger.info("NFO service initialized successfully")
|
||||
except ValueError:
|
||||
logger.info("NFO service not available — TMDB API key not configured")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to initialize NFO service: %s", e)
|
||||
```
|
||||
|
||||
### Possible traps and issues
|
||||
- This changes the condition from "env var set" to "factory can produce a service". The factory already has a safe fallback and raises `ValueError` when no key exists — so the `except ValueError` path is the normal "not configured" case, not an error.
|
||||
- `SeriesApp` is used in tests with `settings.tmdb_api_key = None`. Those tests must not be affected; the `except ValueError` branch keeps behaviour identical.
|
||||
- `series_app.nfo_service` is still `None` when not configured — downstream code that checks `if self.series_app.nfo_service:` remains correct.
|
||||
|
||||
### Docs changes needed
|
||||
`docs/CONFIGURATION.md` — note that `TMDB_API_KEY` env var is not required if `nfo.tmdb_api_key` is set in `config.json`.
|
||||
|
||||
### Why this is needed
|
||||
If the TMDB API key is configured only via `config.json` (not the `TMDB_API_KEY` env var), `settings.tmdb_api_key` is `None` and the guard prevents `nfo_service` from ever being created. The background loader then skips NFO creation completely (`nfo_service` is `None`). The repair scan at startup uses `NFOServiceFactory` directly (reads config.json) so it does create the NFO — which is exactly why restart works but add does not.
|
||||
**Why this is needed**
|
||||
Reuses the existing, tested NFO repair logic. Moves NFO repair from startup blocking to scheduled background maintenance.
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — Remove non-reentrant `async with self.tmdb_client:` from NFOService public methods
|
||||
### Task 1.4: Validate and rename series folders
|
||||
|
||||
- [x] Completed
|
||||
**Where is that found**
|
||||
- `src/server/services/folder_scan_service.py`
|
||||
- `src/core/services/nfo_repair_service.py` (for `parse_nfo_tags` or similar NFO parsing)
|
||||
- `src/server/database/models.py` / `src/server/database/system_settings_service.py` (if folder paths are stored in DB)
|
||||
|
||||
### Where
|
||||
`src/core/services/nfo_service.py` — `create_tvshow_nfo` (~line 151) and `update_tvshow_nfo` (~line 265)
|
||||
**Goal. How it should be**
|
||||
After NFO repair, iterate over every subfolder in `settings.anime_directory` that contains a `tvshow.nfo`. For each folder:
|
||||
1. Parse the NFO to extract `<title>` and `<year>` text values.
|
||||
2. Compute the expected folder name: `f"{title} ({year})"`.
|
||||
3. Sanitize the expected name for filesystem safety (remove/replace illegal characters like `/`, `\`, `:`, etc.).
|
||||
4. Compare with the current folder name (`series_dir.name`).
|
||||
5. If different, rename the folder using `series_dir.rename(expected_path)`.
|
||||
6. If the series path is stored in the database (check `anime_service` or DB models), update the database record to point to the new path.
|
||||
|
||||
```python
|
||||
async with self.tmdb_client:
|
||||
details = await self.tmdb_client.get_tv_show_details(...)
|
||||
...
|
||||
```
|
||||
Skip folders where title or year is missing/empty. Log every rename action.
|
||||
|
||||
### Goal
|
||||
The `TMDBClient.__aenter__` / `__aexit__` open and **close** the session, making any concurrent call to the same client instance fail. Because Task 1 creates a fresh instance per call, this context manager becomes redundant. Change both methods to use `_ensure_session()` at the start and `close()` in a `finally` block, or simply call `await self.tmdb_client._ensure_session()` once and close after all requests. This makes the lifetime explicit and prevents double-close if the caller already manages it.
|
||||
**Possible traps and issues**
|
||||
- **Database path consistency**: If `Series` or `Episode` models store absolute or relative paths, renaming the folder on disk without updating the DB will break downloads, NFO updates, and the web UI. Must verify whether paths are stored in the DB and update them.
|
||||
- **Active downloads**: A series currently being downloaded should not be renamed. Check the download queue or lock status before renaming. If no lock mechanism exists, this is a major trap — document it.
|
||||
- **Filesystem permissions**: The app may not have write permission to the anime directory. Catch `PermissionError` and `OSError` and log gracefully.
|
||||
- **Special characters**: Titles like `"A / B"` or `"Show: Subtitle"` contain characters illegal in folder names. Define a sanitization function (e.g., replace `/` with `-`, remove trailing dots on Windows, etc.).
|
||||
- **Duplicate names**: Two different series could sanitize to the same name. Check if target path already exists before renaming.
|
||||
- **Path length limits**: Very long titles might exceed OS path limits.
|
||||
|
||||
### How it should look
|
||||
```python
|
||||
async def create_tvshow_nfo(self, ...) -> Path:
|
||||
try:
|
||||
await self.tmdb_client._ensure_session()
|
||||
search_results = await self.tmdb_client.search_tv_show(search_name)
|
||||
...
|
||||
finally:
|
||||
await self.tmdb_client.close()
|
||||
```
|
||||
**Docs changes needed**
|
||||
- `docs/NFO_GUIDE.md`: Add a section "Folder Naming Convention" explaining the `<title> (<year>)` format.
|
||||
- `docs/CONFIGURATION.md`: Mention that enabling folder scan will rename folders.
|
||||
|
||||
### Possible traps and issues
|
||||
- `TMDBClient.close()` is idempotent (checks `session.closed` before closing), so calling it in `finally` is safe even if the try block never opened a session.
|
||||
- After Task 1 every `NFOService` is short-lived (one call), so `finally: close()` effectively replaces the context manager with no behaviour change.
|
||||
- Do not remove the `__aenter__`/`__aexit__` from `TMDBClient` itself — other callers (e.g. tests, CLI) may still use it as a context manager.
|
||||
- `update_tvshow_nfo` has the same pattern; fix both methods.
|
||||
|
||||
### Docs changes needed
|
||||
None — internal implementation detail.
|
||||
|
||||
### Why this is needed
|
||||
Even after Task 1 fixes the shared-instance problem, the `async with self.tmdb_client:` pattern is fragile by design: `__aexit__` calls `close()`, which would break any hypothetical future reuse. Removing the implicit close makes the session lifetime explicit and eliminates the root mechanism that caused the original bug.
|
||||
**Why this is needed**
|
||||
Enforces a consistent, predictable folder naming scheme across the library, making it easier for media center apps (Kodi, Jellyfin, Plex) to match metadata.
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — Add `en-US` search fallback so `search_overview` is never empty
|
||||
### Task 1.5: Check and download missing poster.jpg
|
||||
|
||||
### Where
|
||||
`src/core/services/nfo_service.py` — `create_tvshow_nfo` (~line 178) and `_enrich_details_with_fallback` (~line 395)
|
||||
**Where is that found**
|
||||
- `src/server/services/folder_scan_service.py`
|
||||
- `src/core/utils/image_downloader.py` (`ImageDownloader`)
|
||||
- `src/core/services/nfo_service.py` or `src/core/services/nfo_repair_service.py` (to get poster URL from NFO or TMDB)
|
||||
|
||||
```python
|
||||
search_overview = tv_show.get("overview") or None # always None for anime — de-DE search returns ""
|
||||
```
|
||||
**Goal. How it should be**
|
||||
After folder renaming, iterate over series folders again (or combine with Task 1.4 loop). For each folder:
|
||||
1. Check if `poster.jpg` exists and has a size ≥ `ImageDownloader.min_file_size` (1 KB by default).
|
||||
2. If missing or too small:
|
||||
a. Parse `tvshow.nfo` for `<thumb aspect="poster">` or `<thumb>` URL.
|
||||
b. If no URL in NFO, skip (do not query TMDB again to keep tasks small; the NFO should already have it after repair).
|
||||
c. Use `ImageDownloader` (with context manager) to download the image to `series_dir / "poster.jpg"`.
|
||||
d. Validate the downloaded image with `ImageDownloader._validate_image` (or similar existing validation).
|
||||
3. Use the existing `_NFO_REPAIR_SEMAPHORE` or a new `POSTER_DOWNLOAD_SEMAPHORE` to limit concurrent downloads to 3.
|
||||
|
||||
### Goal
|
||||
When the German `search_tv_show` result has an empty `overview`, perform a second search in `en-US` to obtain a non-empty overview as the last-resort fallback text. Store this as `search_overview` so `_enrich_details_with_fallback` can use it even if all language-specific detail requests fail.
|
||||
**Possible traps and issues**
|
||||
- **TMDB rate limiting**: Even downloading images hits TMDB CDN. The semaphore limits concurrency.
|
||||
- **Invalid images**: A download might produce a 0-byte or corrupted file. `ImageDownloader` already validates with PIL; reuse that.
|
||||
- **NFO without thumb URL**: If the NFO was created before thumb tags were added, there may be no URL. In that case, skip and log. A future task could query TMDB directly.
|
||||
- **Write permissions**: Same as Task 1.4.
|
||||
- **Async session sharing**: `ImageDownloader` manages its own `aiohttp` session. Use `async with ImageDownloader() as downloader:` to ensure cleanup.
|
||||
|
||||
### How it should look
|
||||
```python
|
||||
search_overview = tv_show.get("overview") or None
|
||||
if not search_overview:
|
||||
try:
|
||||
en_results = await self.tmdb_client.search_tv_show(search_name, language="en-US")
|
||||
en_match = self._find_best_match(en_results.get("results", []), search_name, year)
|
||||
search_overview = en_match.get("overview") or None
|
||||
except Exception:
|
||||
pass # best-effort only
|
||||
```
|
||||
**Docs changes needed**
|
||||
- `docs/NFO_GUIDE.md`: Add "Poster Check" subsection under folder scan.
|
||||
- `docs/CONFIGURATION.md`: Mention that `nfo.download_poster` setting also affects scheduled poster checks.
|
||||
|
||||
### Possible traps and issues
|
||||
- This adds one extra TMDB request per series when the German overview is empty. It is best-effort and must be wrapped in a broad `except` so it never blocks NFO creation.
|
||||
- The TMDB search endpoint rate-limit is generous; one extra request per add is negligible.
|
||||
- `_find_best_match` can raise `TMDBAPIError` if the result list is empty — catch both `TMDBAPIError` and `Exception`.
|
||||
- `update_tvshow_nfo` calls `_enrich_details_with_fallback` without `search_overview`. This is acceptable because the detail request with `en-US` fallback covers it; the search overview is only a last resort for the create path.
|
||||
**Why this is needed**
|
||||
Ensures every series has artwork, which is required by most media center front-ends for a polished library view.
|
||||
|
||||
### Docs changes needed
|
||||
None — transparent improvement.
|
||||
---
|
||||
|
||||
### Why this is needed
|
||||
Most anime have no German translation on TMDB. The `de-DE` search result returns `overview: ""`. The current code stores this as `search_overview = None` so the last-resort fallback in `_enrich_details_with_fallback` never fires. Combined with session contention (Task 1), the detail-level `en-US` fallback also fails, leaving `plot` empty. This task ensures that at least the search-level `en-US` overview is available as a safety net.
|
||||
## 2. Remove startup NFO repair
|
||||
|
||||
### Task 2.1: Remove perform_nfo_repair_scan from startup lifespan
|
||||
|
||||
**Where is that found**
|
||||
- `src/server/fastapi_app.py` (lifespan startup block, lines ~245 and ~319)
|
||||
- `src/server/services/initialization_service.py` (keep the function, just remove the call site)
|
||||
- `tests/integration/test_nfo_repair_startup.py`
|
||||
- `tests/unit/test_initialization_service.py` (tests that call `perform_nfo_repair_scan` directly can stay, but integration tests verifying startup wiring must change)
|
||||
|
||||
**Goal. How it should be**
|
||||
1. In `src/server/fastapi_app.py`, remove the import of `perform_nfo_repair_scan` from the `initialization_service` import block.
|
||||
2. Remove the line `await perform_nfo_repair_scan(background_loader)` from the lifespan startup sequence.
|
||||
3. Update `tests/integration/test_nfo_repair_startup.py`:
|
||||
- Remove or modify `test_perform_nfo_repair_scan_imported_in_lifespan` and `test_perform_nfo_repair_scan_called_after_media_scan` since the startup wiring is gone.
|
||||
- Replace with a test that verifies `perform_nfo_repair_scan` is NOT called during startup (or simply delete the file if it has no other purpose).
|
||||
4. `tests/unit/test_initialization_service.py` tests for `perform_nfo_repair_scan` can remain because they test the function itself, not the startup wiring.
|
||||
|
||||
**Possible traps and issues**
|
||||
- **Test failures**: `test_nfo_repair_startup.py` will fail immediately after the code change. It must be updated in the same PR.
|
||||
- **Documentation drift**: `docs/NFO_GUIDE.md`, `docs/CHANGELOG.md`, and `docs/ARCHITECTURE.md` all describe the startup NFO repair behavior. If docs are not updated, users will expect repair on every start.
|
||||
- **Background loader parameter**: The `background_loader` variable was created partly for `perform_nfo_repair_scan`. After removal, check if `background_loader` is still needed for other startup steps (yes — `perform_media_scan_if_needed` uses it). Do not remove `background_loader` entirely.
|
||||
- **Import cleanup**: Ensure no unused imports remain in `fastapi_app.py` after removal.
|
||||
|
||||
**Docs changes needed**
|
||||
- `docs/NFO_GUIDE.md`: Update section 11 "Automatic NFO Repair" to remove startup references and state it runs via scheduler.
|
||||
- `docs/CHANGELOG.md`: Add an entry under "Changed" or "Removed" noting that startup NFO repair is replaced by scheduled folder scan.
|
||||
- `docs/ARCHITECTURE.md`: Update the startup sequence description.
|
||||
|
||||
**Why this is needed**
|
||||
Running `perform_nfo_repair_scan` on every startup slows down server restarts, especially for large libraries. Moving it to a scheduled task keeps startup fast while still ensuring regular maintenance.
|
||||
|
||||
@@ -76,6 +76,8 @@ async def setup_auth(req: SetupRequest):
|
||||
config.scheduler.schedule_days = req.scheduler_schedule_days
|
||||
if req.scheduler_auto_download_after_rescan is not None:
|
||||
config.scheduler.auto_download_after_rescan = req.scheduler_auto_download_after_rescan
|
||||
if req.scheduler_folder_scan_enabled is not None:
|
||||
config.scheduler.folder_scan_enabled = req.scheduler_folder_scan_enabled
|
||||
|
||||
# Update logging configuration
|
||||
if req.logging_level:
|
||||
|
||||
@@ -31,6 +31,7 @@ def _build_response(config: SchedulerConfig) -> Dict[str, Any]:
|
||||
"schedule_time": config.schedule_time,
|
||||
"schedule_days": config.schedule_days,
|
||||
"auto_download_after_rescan": config.auto_download_after_rescan,
|
||||
"folder_scan_enabled": config.folder_scan_enabled,
|
||||
},
|
||||
"status": {
|
||||
"is_running": runtime.get("is_running", False),
|
||||
|
||||
@@ -242,7 +242,6 @@ async def lifespan(_application: FastAPI):
|
||||
from src.server.services.initialization_service import (
|
||||
perform_initial_setup,
|
||||
perform_media_scan_if_needed,
|
||||
perform_nfo_repair_scan,
|
||||
perform_nfo_scan_if_needed,
|
||||
)
|
||||
|
||||
@@ -313,10 +312,6 @@ async def lifespan(_application: FastAPI):
|
||||
|
||||
# Run media scan only on first run
|
||||
await perform_media_scan_if_needed(background_loader)
|
||||
|
||||
# Scan every series NFO on startup and repair any that are
|
||||
# missing required tags by queuing them for background reload
|
||||
await perform_nfo_repair_scan(background_loader)
|
||||
else:
|
||||
logger.info(
|
||||
"Download service initialization skipped - "
|
||||
|
||||
@@ -73,6 +73,9 @@ class SetupRequest(BaseModel):
|
||||
scheduler_auto_download_after_rescan: Optional[bool] = Field(
|
||||
default=False, description="Auto-download missing episodes after rescan"
|
||||
)
|
||||
scheduler_folder_scan_enabled: Optional[bool] = Field(
|
||||
default=False, description="Run folder maintenance during scheduled run"
|
||||
)
|
||||
|
||||
# Logging configuration
|
||||
logging_level: Optional[str] = Field(
|
||||
|
||||
@@ -39,6 +39,11 @@ class SchedulerConfig(BaseModel):
|
||||
description="Automatically queue and start downloads for all missing "
|
||||
"episodes after a scheduled rescan completes.",
|
||||
)
|
||||
folder_scan_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Run folder maintenance (NFO repair, folder renaming, "
|
||||
"poster checks) during the scheduled run.",
|
||||
)
|
||||
|
||||
@field_validator("schedule_time")
|
||||
@classmethod
|
||||
|
||||
331
src/server/services/folder_rename_service.py
Normal file
331
src/server/services/folder_rename_service.py
Normal 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
|
||||
282
src/server/services/folder_scan_service.py
Normal file
282
src/server/services/folder_scan_service.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""Folder scan service for daily maintenance tasks.
|
||||
|
||||
Encapsulates the daily folder-scan logic (orphaned-file detection,
|
||||
metadata refresh, and missing-episode queuing) so that the scheduler
|
||||
remains clean and the scan can be tested independently.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from lxml import etree
|
||||
|
||||
from src.core.utils.image_downloader import ImageDownloader
|
||||
from src.server.services.initialization_service import perform_nfo_repair_scan
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Module-level semaphore to limit concurrent TMDB operations to 3.
|
||||
_TMDB_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
|
||||
|
||||
# Semaphore to limit concurrent poster image downloads to 3.
|
||||
_POSTER_DOWNLOAD_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
|
||||
|
||||
|
||||
class FolderScanServiceError(Exception):
|
||||
"""Service-level exception for folder-scan operations."""
|
||||
|
||||
|
||||
class FolderScanService:
|
||||
"""Performs daily maintenance scans over the anime library folder.
|
||||
|
||||
The service is intentionally stateless; a new instance can be created
|
||||
for every scheduled invocation or test case.
|
||||
"""
|
||||
|
||||
async def run_folder_scan(self) -> None:
|
||||
"""Execute the daily folder scan.
|
||||
|
||||
Checks prerequisites, logs progress, and delegates to sub-task
|
||||
helpers. Any unhandled exception is caught and logged so the
|
||||
scheduler task never crashes.
|
||||
"""
|
||||
logger.info("Folder scan started")
|
||||
|
||||
try:
|
||||
if not self._prerequisites_met():
|
||||
return
|
||||
|
||||
# 1.3 — Repair incomplete NFO files in the background.
|
||||
logger.info("Starting NFO repair scan as part of folder scan")
|
||||
await perform_nfo_repair_scan(background_loader=None)
|
||||
logger.info("NFO repair scan queued; repairs will continue in background")
|
||||
|
||||
# 1.4 — Validate and rename series folders after NFO repair.
|
||||
logger.info("Starting folder rename validation")
|
||||
from src.server.services.folder_rename_service import (
|
||||
validate_and_rename_series_folders,
|
||||
)
|
||||
|
||||
rename_stats = await validate_and_rename_series_folders()
|
||||
logger.info(
|
||||
"Folder rename validation complete",
|
||||
scanned=rename_stats["scanned"],
|
||||
renamed=rename_stats["renamed"],
|
||||
skipped=rename_stats["skipped"],
|
||||
errors=rename_stats["errors"],
|
||||
)
|
||||
|
||||
# 1.5 — Check and download missing poster.jpg files.
|
||||
logger.info("Starting poster check")
|
||||
poster_stats = await self.check_and_download_missing_posters()
|
||||
logger.info(
|
||||
"Poster check complete",
|
||||
scanned=poster_stats["scanned"],
|
||||
downloaded=poster_stats["downloaded"],
|
||||
skipped=poster_stats["skipped"],
|
||||
errors=poster_stats["errors"],
|
||||
)
|
||||
|
||||
logger.info("Folder scan completed")
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
logger.error("Folder scan failed", error=str(exc), exc_info=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Poster check helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def check_and_download_missing_posters(self) -> dict[str, int]:
|
||||
"""Iterate over series folders and download missing poster.jpg files.
|
||||
|
||||
For each folder containing a ``tvshow.nfo``:
|
||||
1. Check if ``poster.jpg`` exists and is at least
|
||||
:attr:`ImageDownloader.min_file_size` bytes.
|
||||
2. If missing or too small, parse ``tvshow.nfo`` for a ``<thumb>``
|
||||
URL (preferring ``aspect="poster"``).
|
||||
3. Download the image via :class:`ImageDownloader` under a
|
||||
semaphore that limits concurrency to 3.
|
||||
|
||||
Returns:
|
||||
Dictionary with counts:
|
||||
- ``"scanned"``: total folders scanned
|
||||
- ``"downloaded"``: posters successfully downloaded
|
||||
- ``"skipped"``: folders skipped (no NFO, no thumb URL,
|
||||
or poster already valid)
|
||||
- ``"errors"``: folders that caused a download error
|
||||
"""
|
||||
from src.config.settings import settings # noqa: PLC0415
|
||||
|
||||
stats = {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
if not settings.anime_directory:
|
||||
logger.warning("Poster check skipped — anime directory not configured")
|
||||
return stats
|
||||
|
||||
anime_dir = Path(settings.anime_directory)
|
||||
if not anime_dir.is_dir():
|
||||
logger.warning(
|
||||
"Poster check skipped — anime directory not found: %s", anime_dir
|
||||
)
|
||||
return stats
|
||||
|
||||
# Gather all series directories that contain a tvshow.nfo
|
||||
series_dirs = [
|
||||
d for d in anime_dir.iterdir()
|
||||
if d.is_dir() and (d / "tvshow.nfo").exists()
|
||||
]
|
||||
|
||||
if not series_dirs:
|
||||
logger.debug("No series folders found for poster check")
|
||||
return stats
|
||||
|
||||
# Process each series folder concurrently with semaphore
|
||||
tasks = [
|
||||
self._check_and_download_poster(series_dir, stats)
|
||||
for series_dir in series_dirs
|
||||
]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
return stats
|
||||
|
||||
async def _check_and_download_poster(
|
||||
self, series_dir: Path, stats: dict[str, int]
|
||||
) -> None:
|
||||
"""Check and download poster for a single series folder.
|
||||
|
||||
Args:
|
||||
series_dir: Path to the series folder.
|
||||
stats: Mutable stats dictionary to update.
|
||||
"""
|
||||
stats["scanned"] += 1
|
||||
poster_path = series_dir / "poster.jpg"
|
||||
|
||||
# Check if poster already exists and is large enough
|
||||
if poster_path.exists():
|
||||
try:
|
||||
# Default min_file_size from ImageDownloader is 1024 bytes (1 KB)
|
||||
if poster_path.stat().st_size >= 1024:
|
||||
logger.debug(
|
||||
"Poster already valid for '%s'", series_dir.name
|
||||
)
|
||||
stats["skipped"] += 1
|
||||
return
|
||||
except OSError:
|
||||
pass # Fall through to re-download
|
||||
|
||||
# Parse NFO for thumb URL
|
||||
nfo_path = series_dir / "tvshow.nfo"
|
||||
poster_url = self._extract_poster_url_from_nfo(nfo_path)
|
||||
|
||||
if not poster_url:
|
||||
logger.info(
|
||||
"No poster URL found in NFO for '%s', skipping",
|
||||
series_dir.name,
|
||||
)
|
||||
stats["skipped"] += 1
|
||||
return
|
||||
|
||||
# Respect the nfo_download_poster setting
|
||||
from src.config.settings import settings as app_settings # noqa: PLC0415
|
||||
|
||||
if not app_settings.nfo_download_poster:
|
||||
logger.debug(
|
||||
"Poster download disabled by nfo_download_poster setting for '%s'",
|
||||
series_dir.name,
|
||||
)
|
||||
stats["skipped"] += 1
|
||||
return
|
||||
|
||||
# Download poster with semaphore
|
||||
async with _POSTER_DOWNLOAD_SEMAPHORE:
|
||||
try:
|
||||
async with ImageDownloader() as downloader:
|
||||
success = await downloader.download_poster(
|
||||
poster_url, series_dir, skip_existing=False
|
||||
)
|
||||
if success:
|
||||
logger.info(
|
||||
"Downloaded poster for '%s'", series_dir.name
|
||||
)
|
||||
stats["downloaded"] += 1
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to download poster for '%s'", series_dir.name
|
||||
)
|
||||
stats["errors"] += 1
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.error(
|
||||
"Error downloading poster for '%s': %s",
|
||||
series_dir.name,
|
||||
exc,
|
||||
)
|
||||
stats["errors"] += 1
|
||||
|
||||
@staticmethod
|
||||
def _extract_poster_url_from_nfo(nfo_path: Path) -> Optional[str]:
|
||||
"""Parse tvshow.nfo and extract the poster thumb URL.
|
||||
|
||||
Prefers ``<thumb aspect="poster">``; falls back to the first
|
||||
``<thumb>`` element if no aspect attribute is present.
|
||||
|
||||
Args:
|
||||
nfo_path: Absolute path to the ``tvshow.nfo`` file.
|
||||
|
||||
Returns:
|
||||
The poster URL string, or ``None`` if not found.
|
||||
"""
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
tree = etree.parse(str(nfo_path))
|
||||
root = tree.getroot()
|
||||
|
||||
# Prefer thumb with aspect="poster"
|
||||
for thumb in root.findall(".//thumb"):
|
||||
if thumb.get("aspect") == "poster" and thumb.text:
|
||||
return thumb.text.strip()
|
||||
|
||||
# Fallback to first thumb with text
|
||||
for thumb in root.findall(".//thumb"):
|
||||
if thumb.text:
|
||||
return thumb.text.strip()
|
||||
|
||||
return None
|
||||
except etree.XMLSyntaxError:
|
||||
logger.warning("Malformed XML in %s", nfo_path)
|
||||
return None
|
||||
except Exception: # pylint: disable=broad-except
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _prerequisites_met(self) -> bool:
|
||||
"""Verify that the environment is ready for a folder scan.
|
||||
|
||||
Returns:
|
||||
True when ``settings.anime_directory`` exists and
|
||||
``settings.tmdb_api_key`` is configured.
|
||||
"""
|
||||
from src.config.settings import settings # noqa: PLC0415
|
||||
|
||||
if not settings.tmdb_api_key:
|
||||
logger.warning("Folder scan skipped — TMDB API key not configured")
|
||||
return False
|
||||
|
||||
if not settings.anime_directory:
|
||||
logger.warning("Folder scan skipped — anime directory not configured")
|
||||
return False
|
||||
|
||||
anime_dir = Path(settings.anime_directory)
|
||||
if not anime_dir.is_dir():
|
||||
logger.warning(
|
||||
"Folder scan skipped — anime directory not found: %s", anime_dir
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -417,10 +417,10 @@ async def _repair_one_series(series_dir: Path, series_name: str) -> None:
|
||||
async def perform_nfo_repair_scan(background_loader=None) -> None:
|
||||
"""Scan all series folders and repair incomplete tvshow.nfo files.
|
||||
|
||||
Runs on every application startup (not guarded by a run-once DB flag).
|
||||
Checks each subfolder of ``settings.anime_directory`` for a ``tvshow.nfo``
|
||||
and calls ``_repair_one_series`` for every file with absent or empty
|
||||
required tags.
|
||||
Called from ``FolderScanService.run_folder_scan()`` during the scheduled
|
||||
daily folder scan (not on every startup). Checks each subfolder of
|
||||
``settings.anime_directory`` for a ``tvshow.nfo`` and calls
|
||||
``_repair_one_series`` for every file with absent or empty required tags.
|
||||
|
||||
Each repair task creates its own isolated :class:`NFOService` /
|
||||
:class:`TMDBClient` so concurrent tasks never share an ``aiohttp``
|
||||
|
||||
@@ -145,6 +145,7 @@ class SchedulerService:
|
||||
schedule_time=config.schedule_time,
|
||||
schedule_days=config.schedule_days,
|
||||
auto_download=config.auto_download_after_rescan,
|
||||
folder_scan=config.folder_scan_enabled,
|
||||
)
|
||||
|
||||
if not self._scheduler or not self._scheduler.running:
|
||||
@@ -204,6 +205,9 @@ class SchedulerService:
|
||||
"auto_download_after_rescan": (
|
||||
self._config.auto_download_after_rescan if self._config else False
|
||||
),
|
||||
"folder_scan_enabled": (
|
||||
self._config.folder_scan_enabled if self._config else False
|
||||
),
|
||||
"last_run": self._last_scan_time.isoformat() if self._last_scan_time else None,
|
||||
"next_run": next_run,
|
||||
"scan_in_progress": self._scan_in_progress,
|
||||
@@ -352,6 +356,28 @@ class SchedulerService:
|
||||
else:
|
||||
logger.debug("Auto-download after rescan is disabled — skipping")
|
||||
|
||||
# Folder scan (daily maintenance)
|
||||
if self._config and self._config.folder_scan_enabled:
|
||||
logger.info("Folder scan is enabled — starting")
|
||||
try:
|
||||
from src.server.services.folder_scan_service import ( # noqa: PLC0415
|
||||
FolderScanService,
|
||||
)
|
||||
|
||||
folder_scan_service = FolderScanService()
|
||||
await folder_scan_service.run_folder_scan()
|
||||
except Exception as fs_exc: # pylint: disable=broad-exception-caught
|
||||
logger.error(
|
||||
"Folder scan failed",
|
||||
error=str(fs_exc),
|
||||
exc_info=True,
|
||||
)
|
||||
await self._broadcast(
|
||||
"folder_scan_error", {"error": str(fs_exc)}
|
||||
)
|
||||
else:
|
||||
logger.debug("Folder scan is disabled — skipping")
|
||||
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
logger.error("Scheduled rescan failed", error=str(exc), exc_info=True)
|
||||
await self._broadcast(
|
||||
|
||||
@@ -1561,6 +1561,8 @@ class AniWorldApp {
|
||||
document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled;
|
||||
document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00';
|
||||
document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan;
|
||||
const folderScanEl = document.getElementById('folder-scan-enabled');
|
||||
if (folderScanEl) folderScanEl.checked = !!config.folder_scan_enabled;
|
||||
|
||||
// Update day-of-week checkboxes
|
||||
const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun'];
|
||||
@@ -1603,6 +1605,8 @@ class AniWorldApp {
|
||||
const enabled = document.getElementById('scheduled-rescan-enabled').checked;
|
||||
const scheduleTime = document.getElementById('scheduled-rescan-time').value || '03:00';
|
||||
const autoDownload = document.getElementById('auto-download-after-rescan').checked;
|
||||
const folderScanEl = document.getElementById('folder-scan-enabled');
|
||||
const folderScan = folderScanEl ? folderScanEl.checked : false;
|
||||
|
||||
// Collect checked day-of-week values
|
||||
const scheduleDays = ['mon','tue','wed','thu','fri','sat','sun']
|
||||
@@ -1618,7 +1622,8 @@ class AniWorldApp {
|
||||
enabled: enabled,
|
||||
schedule_time: scheduleTime,
|
||||
schedule_days: scheduleDays,
|
||||
auto_download_after_rescan: autoDownload
|
||||
auto_download_after_rescan: autoDownload,
|
||||
folder_scan_enabled: folderScan
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ AniWorld.SchedulerConfig = (function() {
|
||||
autoDownload.checked = config.auto_download_after_rescan || false;
|
||||
}
|
||||
|
||||
const folderScan = document.getElementById('folder-scan-enabled');
|
||||
if (folderScan) {
|
||||
folderScan.checked = config.folder_scan_enabled || false;
|
||||
}
|
||||
|
||||
// Update schedule day checkboxes
|
||||
const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun'];
|
||||
['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) {
|
||||
@@ -82,12 +87,16 @@ AniWorld.SchedulerConfig = (function() {
|
||||
const autoDownloadEl = document.getElementById('auto-download-after-rescan');
|
||||
const autoDownload = autoDownloadEl ? autoDownloadEl.checked : false;
|
||||
|
||||
const folderScanEl = document.getElementById('folder-scan-enabled');
|
||||
const folderScan = folderScanEl ? folderScanEl.checked : false;
|
||||
|
||||
// POST directly to the scheduler config endpoint
|
||||
const payload = {
|
||||
enabled: enabled,
|
||||
schedule_time: scheduleTime,
|
||||
schedule_days: scheduleDays,
|
||||
auto_download_after_rescan: autoDownload
|
||||
auto_download_after_rescan: autoDownload,
|
||||
folder_scan_enabled: folderScan
|
||||
};
|
||||
|
||||
const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload);
|
||||
|
||||
@@ -309,6 +309,17 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="folder-scan-enabled">
|
||||
<span class="checkbox-custom"></span>
|
||||
<span data-text="folder-scan-enabled">Run folder maintenance (NFO repair, renaming, poster checks)</span>
|
||||
</label>
|
||||
<small class="config-hint" data-text="folder-scan-hint">
|
||||
Automatically repair NFOs, rename folders, and check posters during scheduled runs.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="config-item scheduler-status" id="scheduler-status">
|
||||
<div class="scheduler-info">
|
||||
|
||||
@@ -479,6 +479,13 @@
|
||||
<span>Auto-download missing episodes after rescan</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-checkbox">
|
||||
<input type="checkbox" id="scheduler_folder_scan" name="scheduler_folder_scan">
|
||||
<span>Run folder maintenance (NFO repair, renaming, poster checks)</span>
|
||||
</label>
|
||||
<div class="form-help">Automatically repair NFOs, rename folders, and check posters during scheduled runs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -761,6 +768,7 @@
|
||||
scheduler_schedule_time: document.getElementById('scheduler_schedule_time').value || '03:00',
|
||||
scheduler_schedule_days: Array.from(document.querySelectorAll('.scheduler-day-setup-cb:checked')).map(cb => cb.value),
|
||||
scheduler_auto_download_after_rescan: document.getElementById('scheduler_auto_download').checked,
|
||||
scheduler_folder_scan_enabled: document.getElementById('scheduler_folder_scan').checked,
|
||||
logging_level: document.getElementById('logging_level').value,
|
||||
logging_file: document.getElementById('logging_file').value.trim() || null,
|
||||
logging_max_bytes: document.getElementById('logging_max_bytes').value ?
|
||||
|
||||
@@ -91,6 +91,13 @@ def _setup_loader_mocks(loader_service):
|
||||
loader_service._broadcast_status = AsyncMock()
|
||||
|
||||
|
||||
def _mock_nfo_factory(mock_nfo_service):
|
||||
"""Create a mock NFO factory that returns the given mock service."""
|
||||
mock_factory = MagicMock()
|
||||
mock_factory.create = MagicMock(return_value=mock_nfo_service)
|
||||
return mock_factory
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_anime_loads_nfo_only_for_new_anime(
|
||||
temp_anime_dir,
|
||||
@@ -112,49 +119,55 @@ async def test_add_anime_loads_nfo_only_for_new_anime(
|
||||
)
|
||||
_setup_loader_mocks(loader_service)
|
||||
|
||||
await loader_service.start()
|
||||
# Set up mock NFO service via factory
|
||||
mock_nfo_service = AsyncMock()
|
||||
mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/New Anime (2024)/tvshow.nfo")
|
||||
mock_factory = _mock_nfo_factory(mock_nfo_service)
|
||||
|
||||
try:
|
||||
new_anime_key = "new-anime"
|
||||
new_anime_folder = "New Anime (2024)"
|
||||
new_anime_name = "New Anime"
|
||||
new_anime_year = 2024
|
||||
with patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory):
|
||||
await loader_service.start()
|
||||
|
||||
new_anime_dir = Path(temp_anime_dir) / new_anime_folder
|
||||
new_anime_dir.mkdir()
|
||||
try:
|
||||
new_anime_key = "new-anime"
|
||||
new_anime_folder = "New Anime (2024)"
|
||||
new_anime_name = "New Anime"
|
||||
new_anime_year = 2024
|
||||
|
||||
await loader_service.add_series_loading_task(
|
||||
key=new_anime_key,
|
||||
folder=new_anime_folder,
|
||||
name=new_anime_name,
|
||||
year=new_anime_year,
|
||||
)
|
||||
new_anime_dir = Path(temp_anime_dir) / new_anime_folder
|
||||
new_anime_dir.mkdir()
|
||||
|
||||
await asyncio.sleep(1.0)
|
||||
await loader_service.add_series_loading_task(
|
||||
key=new_anime_key,
|
||||
folder=new_anime_folder,
|
||||
name=new_anime_name,
|
||||
year=new_anime_year,
|
||||
)
|
||||
|
||||
assert mock_series_app.nfo_service.create_tvshow_nfo.call_count == 1
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
call_args = mock_series_app.nfo_service.create_tvshow_nfo.call_args
|
||||
assert call_args is not None
|
||||
assert mock_nfo_service.create_tvshow_nfo.call_count == 1
|
||||
|
||||
kwargs = call_args.kwargs
|
||||
assert kwargs["serie_name"] == new_anime_name
|
||||
assert kwargs["serie_folder"] == new_anime_folder
|
||||
assert kwargs["year"] == new_anime_year
|
||||
assert kwargs["download_poster"] is True
|
||||
assert kwargs["download_logo"] is True
|
||||
assert kwargs["download_fanart"] is True
|
||||
call_args = mock_nfo_service.create_tvshow_nfo.call_args
|
||||
assert call_args is not None
|
||||
|
||||
all_calls = mock_series_app.nfo_service.create_tvshow_nfo.call_args_list
|
||||
for call_obj in all_calls:
|
||||
call_kwargs = call_obj.kwargs
|
||||
assert call_kwargs["serie_name"] != "Existing Anime 1"
|
||||
assert call_kwargs["serie_name"] != "Existing Anime 2"
|
||||
assert call_kwargs["serie_folder"] != "Existing Anime 1"
|
||||
assert call_kwargs["serie_folder"] != "Existing Anime 2"
|
||||
kwargs = call_args.kwargs
|
||||
assert kwargs["serie_name"] == new_anime_name
|
||||
assert kwargs["serie_folder"] == new_anime_folder
|
||||
assert kwargs["year"] == new_anime_year
|
||||
assert kwargs["download_poster"] is True
|
||||
assert kwargs["download_logo"] is True
|
||||
assert kwargs["download_fanart"] is True
|
||||
|
||||
finally:
|
||||
await loader_service.stop()
|
||||
all_calls = mock_nfo_service.create_tvshow_nfo.call_args_list
|
||||
for call_obj in all_calls:
|
||||
call_kwargs = call_obj.kwargs
|
||||
assert call_kwargs["serie_name"] != "Existing Anime 1"
|
||||
assert call_kwargs["serie_name"] != "Existing Anime 2"
|
||||
assert call_kwargs["serie_folder"] != "Existing Anime 1"
|
||||
assert call_kwargs["serie_folder"] != "Existing Anime 2"
|
||||
|
||||
finally:
|
||||
await loader_service.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -216,48 +229,54 @@ async def test_multiple_anime_added_each_loads_independently(
|
||||
)
|
||||
_setup_loader_mocks(loader_service)
|
||||
|
||||
await loader_service.start()
|
||||
# Set up mock NFO service via factory
|
||||
mock_nfo_service = AsyncMock()
|
||||
mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/tvshow.nfo")
|
||||
mock_factory = _mock_nfo_factory(mock_nfo_service)
|
||||
|
||||
try:
|
||||
anime_to_add = [
|
||||
("anime-a", "Anime A (2024)", "Anime A", 2024),
|
||||
("anime-b", "Anime B (2023)", "Anime B", 2023),
|
||||
("anime-c", "Anime C (2025)", "Anime C", 2025),
|
||||
]
|
||||
with patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory):
|
||||
await loader_service.start()
|
||||
|
||||
for key, folder, name, year in anime_to_add:
|
||||
anime_dir = Path(temp_anime_dir) / folder
|
||||
anime_dir.mkdir()
|
||||
try:
|
||||
anime_to_add = [
|
||||
("anime-a", "Anime A (2024)", "Anime A", 2024),
|
||||
("anime-b", "Anime B (2023)", "Anime B", 2023),
|
||||
("anime-c", "Anime C (2025)", "Anime C", 2025),
|
||||
]
|
||||
|
||||
await loader_service.add_series_loading_task(
|
||||
key=key,
|
||||
folder=folder,
|
||||
name=name,
|
||||
year=year,
|
||||
)
|
||||
for key, folder, name, year in anime_to_add:
|
||||
anime_dir = Path(temp_anime_dir) / folder
|
||||
anime_dir.mkdir()
|
||||
|
||||
await asyncio.sleep(2.0)
|
||||
await loader_service.add_series_loading_task(
|
||||
key=key,
|
||||
folder=folder,
|
||||
name=name,
|
||||
year=year,
|
||||
)
|
||||
|
||||
assert mock_series_app.nfo_service.create_tvshow_nfo.call_count == 3
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
all_calls = mock_series_app.nfo_service.create_tvshow_nfo.call_args_list
|
||||
assert mock_nfo_service.create_tvshow_nfo.call_count == 3
|
||||
|
||||
called_names = [call_obj.kwargs["serie_name"] for call_obj in all_calls]
|
||||
called_folders = [call_obj.kwargs["serie_folder"] for call_obj in all_calls]
|
||||
all_calls = mock_nfo_service.create_tvshow_nfo.call_args_list
|
||||
|
||||
assert "Anime A" in called_names
|
||||
assert "Anime B" in called_names
|
||||
assert "Anime C" in called_names
|
||||
called_names = [call_obj.kwargs["serie_name"] for call_obj in all_calls]
|
||||
called_folders = [call_obj.kwargs["serie_folder"] for call_obj in all_calls]
|
||||
|
||||
assert "Anime A (2024)" in called_folders
|
||||
assert "Anime B (2023)" in called_folders
|
||||
assert "Anime C (2025)" in called_folders
|
||||
assert "Anime A" in called_names
|
||||
assert "Anime B" in called_names
|
||||
assert "Anime C" in called_names
|
||||
|
||||
assert "Existing Anime 1" not in called_names
|
||||
assert "Existing Anime 2" not in called_names
|
||||
assert "Anime A (2024)" in called_folders
|
||||
assert "Anime B (2023)" in called_folders
|
||||
assert "Anime C (2025)" in called_folders
|
||||
|
||||
finally:
|
||||
await loader_service.stop()
|
||||
assert "Existing Anime 1" not in called_names
|
||||
assert "Existing Anime 2" not in called_names
|
||||
|
||||
finally:
|
||||
await loader_service.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -275,38 +294,44 @@ async def test_nfo_service_receives_correct_parameters(
|
||||
)
|
||||
_setup_loader_mocks(loader_service)
|
||||
|
||||
await loader_service.start()
|
||||
# Set up mock NFO service via factory
|
||||
mock_nfo_service = AsyncMock()
|
||||
mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/Test Anime Series (2024)/tvshow.nfo")
|
||||
mock_factory = _mock_nfo_factory(mock_nfo_service)
|
||||
|
||||
try:
|
||||
test_key = "test-anime-key"
|
||||
test_folder = "Test Anime Series (2024)"
|
||||
test_name = "Test Anime Series"
|
||||
test_year = 2024
|
||||
with patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory):
|
||||
await loader_service.start()
|
||||
|
||||
anime_dir = Path(temp_anime_dir) / test_folder
|
||||
anime_dir.mkdir()
|
||||
try:
|
||||
test_key = "test-anime-key"
|
||||
test_folder = "Test Anime Series (2024)"
|
||||
test_name = "Test Anime Series"
|
||||
test_year = 2024
|
||||
|
||||
await loader_service.add_series_loading_task(
|
||||
key=test_key,
|
||||
folder=test_folder,
|
||||
name=test_name,
|
||||
year=test_year,
|
||||
)
|
||||
anime_dir = Path(temp_anime_dir) / test_folder
|
||||
anime_dir.mkdir()
|
||||
|
||||
await asyncio.sleep(1.0)
|
||||
await loader_service.add_series_loading_task(
|
||||
key=test_key,
|
||||
folder=test_folder,
|
||||
name=test_name,
|
||||
year=test_year,
|
||||
)
|
||||
|
||||
assert mock_series_app.nfo_service.create_tvshow_nfo.call_count == 1
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
call_kwargs = mock_series_app.nfo_service.create_tvshow_nfo.call_args.kwargs
|
||||
assert mock_nfo_service.create_tvshow_nfo.call_count == 1
|
||||
|
||||
assert call_kwargs["serie_name"] == test_name
|
||||
assert call_kwargs["serie_folder"] == test_folder
|
||||
assert call_kwargs["year"] == test_year
|
||||
assert call_kwargs["download_poster"] is True
|
||||
assert call_kwargs["download_logo"] is True
|
||||
assert call_kwargs["download_fanart"] is True
|
||||
call_kwargs = mock_nfo_service.create_tvshow_nfo.call_args.kwargs
|
||||
|
||||
assert "Existing Anime" not in str(call_kwargs)
|
||||
assert call_kwargs["serie_name"] == test_name
|
||||
assert call_kwargs["serie_folder"] == test_folder
|
||||
assert call_kwargs["year"] == test_year
|
||||
assert call_kwargs["download_poster"] is True
|
||||
assert call_kwargs["download_logo"] is True
|
||||
assert call_kwargs["download_fanart"] is True
|
||||
|
||||
finally:
|
||||
await loader_service.stop()
|
||||
assert "Existing Anime" not in str(call_kwargs)
|
||||
|
||||
finally:
|
||||
await loader_service.stop()
|
||||
|
||||
109
tests/integration/test_folder_rename_startup.py
Normal file
109
tests/integration/test_folder_rename_startup.py
Normal 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()
|
||||
@@ -1,46 +1,63 @@
|
||||
"""Integration tests verifying perform_nfo_repair_scan is wired into app startup.
|
||||
"""Integration tests verifying perform_nfo_repair_scan is wired into folder scan
|
||||
and NOT called during FastAPI lifespan startup.
|
||||
|
||||
These tests confirm that:
|
||||
1. The lifespan calls perform_nfo_repair_scan after perform_media_scan_if_needed.
|
||||
2. Series with incomplete NFO files are queued via the background_loader.
|
||||
1. FolderScanService.run_folder_scan calls perform_nfo_repair_scan.
|
||||
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 TestNfoRepairScanCalledOnStartup:
|
||||
"""Verify perform_nfo_repair_scan is invoked during the FastAPI lifespan."""
|
||||
class TestNfoRepairScanNotCalledOnStartup:
|
||||
"""Verify perform_nfo_repair_scan is NOT invoked during FastAPI lifespan startup."""
|
||||
|
||||
def test_perform_nfo_repair_scan_imported_in_lifespan(self):
|
||||
"""fastapi_app.py lifespan imports perform_nfo_repair_scan."""
|
||||
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
|
||||
|
||||
import src.server.fastapi_app as app_module
|
||||
|
||||
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."""
|
||||
|
||||
def test_perform_nfo_repair_scan_imported_in_folder_scan_service(self):
|
||||
"""folder_scan_service.py imports perform_nfo_repair_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()
|
||||
|
||||
assert "perform_nfo_repair_scan" in content, (
|
||||
"perform_nfo_repair_scan must be imported and called in fastapi_app.py"
|
||||
"perform_nfo_repair_scan must be imported in folder_scan_service.py"
|
||||
)
|
||||
|
||||
def test_perform_nfo_repair_scan_called_after_media_scan(self):
|
||||
"""perform_nfo_repair_scan must appear after perform_media_scan_if_needed."""
|
||||
def test_perform_nfo_repair_scan_called_in_run_folder_scan(self):
|
||||
"""perform_nfo_repair_scan must be called inside run_folder_scan."""
|
||||
import importlib
|
||||
|
||||
source = importlib.util.find_spec("src.server.fastapi_app").origin
|
||||
source = importlib.util.find_spec("src.server.services.folder_scan_service").origin
|
||||
with open(source, "r", encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
|
||||
media_scan_pos = content.find("perform_media_scan_if_needed(background_loader)")
|
||||
repair_scan_pos = content.find("perform_nfo_repair_scan(background_loader)")
|
||||
run_folder_scan_pos = content.find("def run_folder_scan")
|
||||
# Find the call inside the method body (after the import line)
|
||||
repair_scan_call_pos = content.find("await perform_nfo_repair_scan(background_loader=None)")
|
||||
|
||||
assert media_scan_pos != -1, "perform_media_scan_if_needed call not found"
|
||||
assert repair_scan_pos != -1, "perform_nfo_repair_scan call not found"
|
||||
assert repair_scan_pos > media_scan_pos, (
|
||||
"perform_nfo_repair_scan must be called AFTER perform_media_scan_if_needed"
|
||||
assert run_folder_scan_pos != -1, "run_folder_scan method not found"
|
||||
assert repair_scan_call_pos != -1, "perform_nfo_repair_scan call not found"
|
||||
assert repair_scan_call_pos > run_folder_scan_pos, (
|
||||
"perform_nfo_repair_scan must be called INSIDE run_folder_scan"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -96,6 +96,8 @@ class TestCompleteNFOWorkflow:
|
||||
mock_tmdb = Mock()
|
||||
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
|
||||
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_tmdb._ensure_session = AsyncMock()
|
||||
mock_tmdb.close = AsyncMock()
|
||||
mock_tmdb.search_tv_show = AsyncMock(return_value={"results": [mock_tmdb_show]})
|
||||
mock_tmdb.get_tv_show = AsyncMock(return_value=mock_tmdb_show)
|
||||
mock_tmdb.get_tv_show_details = AsyncMock(return_value=mock_tmdb_show)
|
||||
@@ -158,6 +160,8 @@ class TestCompleteNFOWorkflow:
|
||||
mock_tmdb = Mock()
|
||||
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
|
||||
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_tmdb._ensure_session = AsyncMock()
|
||||
mock_tmdb.close = AsyncMock()
|
||||
mock_tmdb.search_tv_show = AsyncMock(
|
||||
return_value={"results": [{
|
||||
"id": 999,
|
||||
@@ -208,6 +212,8 @@ class TestCompleteNFOWorkflow:
|
||||
mock_tmdb = Mock()
|
||||
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
|
||||
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_tmdb._ensure_session = AsyncMock()
|
||||
mock_tmdb.close = AsyncMock()
|
||||
mock_tmdb.search_tv_show = AsyncMock(
|
||||
side_effect=TMDBAPIError("API error")
|
||||
)
|
||||
@@ -253,6 +259,8 @@ class TestCompleteNFOWorkflow:
|
||||
mock_tmdb = Mock()
|
||||
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
|
||||
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_tmdb._ensure_session = AsyncMock()
|
||||
mock_tmdb.close = AsyncMock()
|
||||
mock_tmdb.search_tv_show = AsyncMock(
|
||||
return_value={"results": [{
|
||||
"id": 999,
|
||||
@@ -307,16 +315,22 @@ class TestCompleteNFOWorkflow:
|
||||
mock_tmdb = Mock()
|
||||
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
|
||||
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_tmdb._ensure_session = AsyncMock()
|
||||
mock_tmdb.close = AsyncMock()
|
||||
mock_tmdb.search_tv_show = AsyncMock(
|
||||
side_effect=[
|
||||
{"results": [{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"}]},
|
||||
{"results": [{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"}]},
|
||||
{"results": [{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"}]},
|
||||
{"results": [{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"}]},
|
||||
]
|
||||
)
|
||||
mock_tmdb.get_tv_show_details = AsyncMock(
|
||||
side_effect=[
|
||||
{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"},
|
||||
{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"},
|
||||
{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"},
|
||||
{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"},
|
||||
]
|
||||
)
|
||||
mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []})
|
||||
@@ -366,6 +380,8 @@ class TestNFOWorkflowWithDownloads:
|
||||
mock_tmdb = Mock()
|
||||
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
|
||||
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_tmdb._ensure_session = AsyncMock()
|
||||
mock_tmdb.close = AsyncMock()
|
||||
mock_tmdb.search_tv_show = AsyncMock(
|
||||
return_value={"results": [{
|
||||
"id": 999,
|
||||
|
||||
294
tests/integration/test_poster_check_startup.py
Normal file
294
tests/integration/test_poster_check_startup.py
Normal 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}"
|
||||
)
|
||||
@@ -520,19 +520,25 @@ class TestLoadNfoAndImages:
|
||||
mock_db = AsyncMock()
|
||||
mock_series = MagicMock()
|
||||
mock_series.has_nfo = False
|
||||
|
||||
|
||||
task = SeriesLoadingTask(
|
||||
key="test",
|
||||
folder="test_folder",
|
||||
name="Test Series",
|
||||
year=2020
|
||||
)
|
||||
|
||||
with patch("src.server.database.service.AnimeSeriesService") as mock_service_class:
|
||||
|
||||
mock_nfo_service = AsyncMock()
|
||||
mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/test_folder/tvshow.nfo")
|
||||
mock_factory = MagicMock()
|
||||
mock_factory.create = MagicMock(return_value=mock_nfo_service)
|
||||
|
||||
with patch("src.server.database.service.AnimeSeriesService") as mock_service_class, \
|
||||
patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory):
|
||||
mock_service_class.get_by_key = AsyncMock(return_value=mock_series)
|
||||
|
||||
|
||||
result = await background_loader_service._load_nfo_and_images(task, mock_db)
|
||||
|
||||
|
||||
assert result is True
|
||||
assert task.progress["nfo"] is True
|
||||
assert task.progress["logo"] is True
|
||||
|
||||
@@ -263,8 +263,9 @@ class TestWithErrorRecoveryDecorator:
|
||||
fail_once()
|
||||
# Should have logged a warning with context
|
||||
mock_logger.warning.assert_called()
|
||||
logged_msg = mock_logger.warning.call_args[0][0]
|
||||
assert "my_context" in logged_msg
|
||||
# context is a %s arg, so check all positional call args
|
||||
logged_args = mock_logger.warning.call_args[0]
|
||||
assert any("my_context" in str(arg) for arg in logged_args)
|
||||
|
||||
def test_retryable_error_is_retried(self):
|
||||
"""RetryableError (standard Exception subclass) is retried."""
|
||||
|
||||
383
tests/unit/test_folder_rename_service.py
Normal file
383
tests/unit/test_folder_rename_service.py
Normal 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()
|
||||
607
tests/unit/test_folder_scan_service.py
Normal file
607
tests/unit/test_folder_scan_service.py
Normal file
@@ -0,0 +1,607 @@
|
||||
"""Unit tests for FolderScanService (Tasks 1.2–1.5).
|
||||
|
||||
Covers:
|
||||
- Prerequisites checking (TMDB key, anime directory)
|
||||
- NFO repair integration (Task 1.3)
|
||||
- Folder rename validation (Task 1.4)
|
||||
- Poster check and download (Task 1.5)
|
||||
- Exception handling and semaphore usage
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.services.folder_scan_service import (
|
||||
_POSTER_DOWNLOAD_SEMAPHORE,
|
||||
_TMDB_SEMAPHORE,
|
||||
FolderScanService,
|
||||
FolderScanServiceError,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def folder_scan_service() -> FolderScanService:
|
||||
"""Return a fresh FolderScanService instance."""
|
||||
return FolderScanService()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings(tmp_path: Path):
|
||||
"""Return a mock settings object with valid prerequisites."""
|
||||
mock = MagicMock()
|
||||
mock.tmdb_api_key = "test-api-key"
|
||||
mock.anime_directory = str(tmp_path)
|
||||
mock.nfo_download_poster = True
|
||||
return mock
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1.2 – Skeleton / prerequisites
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPrerequisites:
|
||||
"""Test _prerequisites_met checks."""
|
||||
|
||||
def test_prerequisites_met(self, folder_scan_service, tmp_path):
|
||||
"""All prerequisites present → True."""
|
||||
with patch(
|
||||
"src.config.settings.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.tmdb_api_key = "key"
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
assert folder_scan_service._prerequisites_met() is True
|
||||
|
||||
def test_missing_tmdb_key(self, folder_scan_service, tmp_path):
|
||||
"""Missing TMDB API key → False."""
|
||||
with patch(
|
||||
"src.config.settings.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.tmdb_api_key = None
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
assert folder_scan_service._prerequisites_met() is False
|
||||
|
||||
def test_missing_anime_directory(self, folder_scan_service):
|
||||
"""Missing anime_directory → False."""
|
||||
with patch(
|
||||
"src.config.settings.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.tmdb_api_key = "key"
|
||||
mock_settings.anime_directory = None
|
||||
assert folder_scan_service._prerequisites_met() is False
|
||||
|
||||
def test_anime_directory_not_found(self, folder_scan_service, tmp_path):
|
||||
"""anime_directory points to non-existent path → False."""
|
||||
non_existent = tmp_path / "does_not_exist"
|
||||
with patch(
|
||||
"src.config.settings.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.tmdb_api_key = "key"
|
||||
mock_settings.anime_directory = str(non_existent)
|
||||
assert folder_scan_service._prerequisites_met() is False
|
||||
|
||||
|
||||
class TestRunFolderScanPrerequisites:
|
||||
"""Test run_folder_scan skips when prerequisites not met."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_prerequisites_missing(self, folder_scan_service):
|
||||
"""If _prerequisites_met returns False, scan exits early."""
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=False
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan"
|
||||
) as mock_repair:
|
||||
await folder_scan_service.run_folder_scan()
|
||||
mock_repair.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logs_start_and_completion(self, folder_scan_service, tmp_path):
|
||||
"""Scan logs start and completion when prerequisites are met."""
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=True
|
||||
), patch(
|
||||
"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.object(
|
||||
folder_scan_service,
|
||||
"check_and_download_missing_posters",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
|
||||
):
|
||||
# Should not raise
|
||||
await folder_scan_service.run_folder_scan()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catches_unhandled_exceptions(self, folder_scan_service):
|
||||
"""Unhandled exceptions are caught and logged, not re-raised."""
|
||||
with patch.object(
|
||||
folder_scan_service,
|
||||
"_prerequisites_met",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
# Must NOT raise
|
||||
await folder_scan_service.run_folder_scan()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1.3 – NFO repair integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNfoRepairIntegration:
|
||||
"""Test perform_nfo_repair_scan is called inside run_folder_scan."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calls_perform_nfo_repair_scan(self, folder_scan_service, tmp_path):
|
||||
"""run_folder_scan must call perform_nfo_repair_scan."""
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=True
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_repair, 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.object(
|
||||
folder_scan_service,
|
||||
"check_and_download_missing_posters",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
|
||||
):
|
||||
await folder_scan_service.run_folder_scan()
|
||||
mock_repair.assert_awaited_once_with(background_loader=None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nfo_repair_failure_does_not_crash_scan(
|
||||
self, folder_scan_service, tmp_path
|
||||
):
|
||||
"""If perform_nfo_repair_scan raises, the broad except catches it
|
||||
and the scan stops — remaining steps are NOT invoked."""
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=True
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("repair failed"),
|
||||
) as mock_repair, 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},
|
||||
) as mock_rename, patch.object(
|
||||
folder_scan_service,
|
||||
"check_and_download_missing_posters",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
|
||||
):
|
||||
await folder_scan_service.run_folder_scan()
|
||||
mock_repair.assert_awaited_once()
|
||||
# Broad except stops the scan; rename/poster are skipped
|
||||
mock_rename.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1.4 – Folder rename integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFolderRenameIntegration:
|
||||
"""Test validate_and_rename_series_folders is called and stats logged."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calls_folder_rename_service(self, folder_scan_service, tmp_path):
|
||||
"""run_folder_scan must call validate_and_rename_series_folders."""
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=True
|
||||
), 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": 5, "renamed": 2, "skipped": 2, "errors": 1},
|
||||
) as mock_rename, patch.object(
|
||||
folder_scan_service,
|
||||
"check_and_download_missing_posters",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
|
||||
):
|
||||
await folder_scan_service.run_folder_scan()
|
||||
mock_rename.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_folder_rename_failure_does_not_crash_scan(
|
||||
self, folder_scan_service, tmp_path
|
||||
):
|
||||
"""If validate_and_rename_series_folders raises, the broad except
|
||||
catches it and the scan stops — poster check is NOT invoked."""
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=True
|
||||
), 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,
|
||||
side_effect=RuntimeError("rename failed"),
|
||||
), patch.object(
|
||||
folder_scan_service,
|
||||
"check_and_download_missing_posters",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
|
||||
) as mock_poster:
|
||||
await folder_scan_service.run_folder_scan()
|
||||
# Broad except stops the scan; poster check is skipped
|
||||
mock_poster.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1.5 – Poster check and download
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPosterCheck:
|
||||
"""Test check_and_download_missing_posters logic."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_anime_directory_returns_empty_stats(self, folder_scan_service):
|
||||
"""Missing anime_directory → empty stats."""
|
||||
with patch(
|
||||
"src.config.settings.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.anime_directory = None
|
||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||
assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonexistent_directory_returns_empty_stats(
|
||||
self, folder_scan_service, tmp_path
|
||||
):
|
||||
"""Non-existent anime_directory → empty stats."""
|
||||
non_existent = tmp_path / "missing"
|
||||
with patch(
|
||||
"src.config.settings.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.anime_directory = str(non_existent)
|
||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||
assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_series_folders_returns_empty_stats(
|
||||
self, folder_scan_service, tmp_path
|
||||
):
|
||||
"""Empty anime_directory → empty stats."""
|
||||
with patch(
|
||||
"src.config.settings.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||
assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_folders_without_nfo(self, folder_scan_service, tmp_path):
|
||||
"""Folders without tvshow.nfo are ignored."""
|
||||
(tmp_path / "SomeShow").mkdir()
|
||||
with patch(
|
||||
"src.config.settings.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||
assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_poster_skipped(self, folder_scan_service, tmp_path):
|
||||
"""Existing poster.jpg ≥ 1 KB is skipped."""
|
||||
series_dir = tmp_path / "Attack on Titan (2013)"
|
||||
series_dir.mkdir()
|
||||
(series_dir / "tvshow.nfo").write_text(
|
||||
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
|
||||
)
|
||||
# Write a 2 KB poster
|
||||
(series_dir / "poster.jpg").write_bytes(b"x" * 2048)
|
||||
|
||||
with patch(
|
||||
"src.config.settings.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||
|
||||
assert stats["scanned"] == 1
|
||||
assert stats["skipped"] == 1
|
||||
assert stats["downloaded"] == 0
|
||||
assert stats["errors"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_poster_downloaded(self, folder_scan_service, tmp_path):
|
||||
"""Missing poster triggers download when thumb URL exists."""
|
||||
series_dir = tmp_path / "Attack on Titan (2013)"
|
||||
series_dir.mkdir()
|
||||
(series_dir / "tvshow.nfo").write_text(
|
||||
"<tvshow>"
|
||||
"<title>Attack on Titan</title><year>2013</year>"
|
||||
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
|
||||
"</tvshow>"
|
||||
)
|
||||
|
||||
mock_downloader = AsyncMock()
|
||||
mock_downloader.download_poster = AsyncMock(return_value=True)
|
||||
mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
|
||||
mock_downloader.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"src.config.settings.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
mock_settings.nfo_download_poster = True
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_scan_service.ImageDownloader",
|
||||
return_value=mock_downloader,
|
||||
):
|
||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||
|
||||
assert stats["scanned"] == 1
|
||||
assert stats["downloaded"] == 1
|
||||
assert stats["skipped"] == 0
|
||||
assert stats["errors"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_thumb_url_skipped(self, folder_scan_service, tmp_path):
|
||||
"""NFO without thumb URL → skipped."""
|
||||
series_dir = tmp_path / "Attack on Titan (2013)"
|
||||
series_dir.mkdir()
|
||||
(series_dir / "tvshow.nfo").write_text(
|
||||
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.config.settings.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
mock_settings.nfo_download_poster = True
|
||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||
|
||||
assert stats["scanned"] == 1
|
||||
assert stats["skipped"] == 1
|
||||
assert stats["downloaded"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poster_download_disabled_by_setting(
|
||||
self, folder_scan_service, tmp_path
|
||||
):
|
||||
"""nfo_download_poster=False → skipped even with valid thumb URL."""
|
||||
series_dir = tmp_path / "Attack on Titan (2013)"
|
||||
series_dir.mkdir()
|
||||
(series_dir / "tvshow.nfo").write_text(
|
||||
"<tvshow>"
|
||||
"<title>Attack on Titan</title><year>2013</year>"
|
||||
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
|
||||
"</tvshow>"
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.config.settings.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
mock_settings.nfo_download_poster = False
|
||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||
|
||||
assert stats["scanned"] == 1
|
||||
assert stats["skipped"] == 1
|
||||
assert stats["downloaded"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_failure_counts_as_error(self, folder_scan_service, tmp_path):
|
||||
"""Failed download increments errors."""
|
||||
series_dir = tmp_path / "Attack on Titan (2013)"
|
||||
series_dir.mkdir()
|
||||
(series_dir / "tvshow.nfo").write_text(
|
||||
"<tvshow>"
|
||||
"<title>Attack on Titan</title><year>2013</year>"
|
||||
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
|
||||
"</tvshow>"
|
||||
)
|
||||
|
||||
mock_downloader = AsyncMock()
|
||||
mock_downloader.download_poster = AsyncMock(return_value=False)
|
||||
mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
|
||||
mock_downloader.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"src.config.settings.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
mock_settings.nfo_download_poster = True
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_scan_service.ImageDownloader",
|
||||
return_value=mock_downloader,
|
||||
):
|
||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||
|
||||
assert stats["scanned"] == 1
|
||||
assert stats["errors"] == 1
|
||||
assert stats["downloaded"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_exception_counts_as_error(self, folder_scan_service, tmp_path):
|
||||
"""Exception during download increments errors."""
|
||||
series_dir = tmp_path / "Attack on Titan (2013)"
|
||||
series_dir.mkdir()
|
||||
(series_dir / "tvshow.nfo").write_text(
|
||||
"<tvshow>"
|
||||
"<title>Attack on Titan</title><year>2013</year>"
|
||||
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
|
||||
"</tvshow>"
|
||||
)
|
||||
|
||||
mock_downloader = AsyncMock()
|
||||
mock_downloader.download_poster = AsyncMock(side_effect=RuntimeError("net"))
|
||||
mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
|
||||
mock_downloader.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"src.config.settings.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
mock_settings.nfo_download_poster = True
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_scan_service.ImageDownloader",
|
||||
return_value=mock_downloader,
|
||||
):
|
||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||
|
||||
assert stats["scanned"] == 1
|
||||
assert stats["errors"] == 1
|
||||
assert stats["downloaded"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_too_small_poster_re_downloaded(self, folder_scan_service, tmp_path):
|
||||
"""Poster < 1 KB is treated as missing and re-downloaded."""
|
||||
series_dir = tmp_path / "Attack on Titan (2013)"
|
||||
series_dir.mkdir()
|
||||
(series_dir / "tvshow.nfo").write_text(
|
||||
"<tvshow>"
|
||||
"<title>Attack on Titan</title><year>2013</year>"
|
||||
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
|
||||
"</tvshow>"
|
||||
)
|
||||
# Write a tiny 100-byte poster
|
||||
(series_dir / "poster.jpg").write_bytes(b"x" * 100)
|
||||
|
||||
mock_downloader = AsyncMock()
|
||||
mock_downloader.download_poster = AsyncMock(return_value=True)
|
||||
mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
|
||||
mock_downloader.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"src.config.settings.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
mock_settings.nfo_download_poster = True
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_scan_service.ImageDownloader",
|
||||
return_value=mock_downloader,
|
||||
):
|
||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||
|
||||
assert stats["scanned"] == 1
|
||||
assert stats["downloaded"] == 1
|
||||
assert stats["skipped"] == 0
|
||||
|
||||
|
||||
class TestExtractPosterUrl:
|
||||
"""Test _extract_poster_url_from_nfo static method."""
|
||||
|
||||
def test_extract_poster_url_with_aspect(self, tmp_path):
|
||||
nfo = tmp_path / "tvshow.nfo"
|
||||
nfo.write_text(
|
||||
"<tvshow>"
|
||||
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
|
||||
"</tvshow>"
|
||||
)
|
||||
url = FolderScanService._extract_poster_url_from_nfo(nfo)
|
||||
assert url == "https://example.com/poster.jpg"
|
||||
|
||||
def test_extract_first_thumb_fallback(self, tmp_path):
|
||||
nfo = tmp_path / "tvshow.nfo"
|
||||
nfo.write_text(
|
||||
"<tvshow>"
|
||||
'<thumb>https://example.com/fallback.jpg</thumb>'
|
||||
"</tvshow>"
|
||||
)
|
||||
url = FolderScanService._extract_poster_url_from_nfo(nfo)
|
||||
assert url == "https://example.com/fallback.jpg"
|
||||
|
||||
def test_no_thumb_returns_none(self, tmp_path):
|
||||
nfo = tmp_path / "tvshow.nfo"
|
||||
nfo.write_text("<tvshow><title>Test</title></tvshow>")
|
||||
url = FolderScanService._extract_poster_url_from_nfo(nfo)
|
||||
assert url is None
|
||||
|
||||
def test_missing_file_returns_none(self, tmp_path):
|
||||
nfo = tmp_path / "tvshow.nfo"
|
||||
url = FolderScanService._extract_poster_url_from_nfo(nfo)
|
||||
assert url is None
|
||||
|
||||
def test_malformed_xml_returns_none(self, tmp_path):
|
||||
nfo = tmp_path / "tvshow.nfo"
|
||||
nfo.write_text("not xml")
|
||||
url = FolderScanService._extract_poster_url_from_nfo(nfo)
|
||||
assert url is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Semaphores
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSemaphores:
|
||||
"""Verify module-level semaphores exist and have correct initial value."""
|
||||
|
||||
def test_tmdb_semaphore_value(self):
|
||||
assert _TMDB_SEMAPHORE._value == 3
|
||||
|
||||
def test_poster_download_semaphore_value(self):
|
||||
assert _POSTER_DOWNLOAD_SEMAPHORE._value == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full run_folder_scan integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunFolderScanFull:
|
||||
"""End-to-end tests for run_folder_scan with mocked sub-tasks."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_scan_happy_path(self, folder_scan_service, tmp_path):
|
||||
"""All sub-tasks succeed."""
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=True
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_repair, patch(
|
||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 3, "renamed": 1, "skipped": 1, "errors": 1},
|
||||
) as mock_rename, patch.object(
|
||||
folder_scan_service,
|
||||
"check_and_download_missing_posters",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 3, "downloaded": 2, "skipped": 1, "errors": 0},
|
||||
) as mock_poster:
|
||||
await folder_scan_service.run_folder_scan()
|
||||
|
||||
mock_repair.assert_awaited_once_with(background_loader=None)
|
||||
mock_rename.assert_awaited_once()
|
||||
mock_poster.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_scan_all_stats_zero(self, folder_scan_service, tmp_path):
|
||||
"""Empty library → all stats zero."""
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=True
|
||||
), patch(
|
||||
"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.object(
|
||||
folder_scan_service,
|
||||
"check_and_download_missing_posters",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
|
||||
):
|
||||
await folder_scan_service.run_folder_scan()
|
||||
@@ -113,8 +113,36 @@ class TestSchedulerConfigBackwardCompat:
|
||||
assert config.interval_minutes == 30
|
||||
|
||||
|
||||
class TestSchedulerConfigFolderScanEnabled:
|
||||
"""3.8 – folder_scan_enabled field (Task 1.1)."""
|
||||
|
||||
def test_default_folder_scan_enabled(self) -> None:
|
||||
config = SchedulerConfig()
|
||||
assert config.folder_scan_enabled is False
|
||||
|
||||
def test_set_folder_scan_enabled_true(self) -> None:
|
||||
config = SchedulerConfig(folder_scan_enabled=True)
|
||||
assert config.folder_scan_enabled is True
|
||||
|
||||
def test_set_folder_scan_enabled_false(self) -> None:
|
||||
config = SchedulerConfig(folder_scan_enabled=False)
|
||||
assert config.folder_scan_enabled is False
|
||||
|
||||
def test_backward_compat_missing_field(self) -> None:
|
||||
"""Old configs without folder_scan_enabled load successfully."""
|
||||
dumped = {
|
||||
"enabled": True,
|
||||
"interval_minutes": 60,
|
||||
"schedule_time": "03:00",
|
||||
"schedule_days": ALL_DAYS,
|
||||
"auto_download_after_rescan": False,
|
||||
}
|
||||
config = SchedulerConfig(**dumped)
|
||||
assert config.folder_scan_enabled is False
|
||||
|
||||
|
||||
class TestSchedulerConfigSerialisation:
|
||||
"""3.8 – Serialisation roundtrip."""
|
||||
"""3.9 – Serialisation roundtrip."""
|
||||
|
||||
def test_roundtrip(self) -> None:
|
||||
original = SchedulerConfig(
|
||||
@@ -123,6 +151,7 @@ class TestSchedulerConfigSerialisation:
|
||||
schedule_time="04:30",
|
||||
schedule_days=["mon", "wed", "fri"],
|
||||
auto_download_after_rescan=True,
|
||||
folder_scan_enabled=True,
|
||||
)
|
||||
dumped = original.model_dump()
|
||||
restored = SchedulerConfig(**dumped)
|
||||
|
||||
@@ -9,16 +9,16 @@ Covers:
|
||||
- Error handling and edge cases
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch, call
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, call, patch
|
||||
|
||||
import pytest
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from src.server.models.config import AppConfig, SchedulerConfig
|
||||
from src.server.services.scheduler_service import (
|
||||
_JOB_ID,
|
||||
SchedulerService,
|
||||
SchedulerServiceError,
|
||||
_JOB_ID,
|
||||
get_scheduler_service,
|
||||
reset_scheduler_service,
|
||||
)
|
||||
@@ -364,6 +364,7 @@ class TestGetStatus:
|
||||
schedule_time="04:00",
|
||||
schedule_days=["mon"],
|
||||
auto_download_after_rescan=True,
|
||||
folder_scan_enabled=True,
|
||||
)
|
||||
status = scheduler_service.get_status()
|
||||
|
||||
@@ -373,13 +374,100 @@ class TestGetStatus:
|
||||
assert "schedule_time" in status
|
||||
assert "schedule_days" in status
|
||||
assert "auto_download_after_rescan" in status
|
||||
assert "folder_scan_enabled" in status
|
||||
assert status["schedule_time"] == "04:00"
|
||||
assert status["schedule_days"] == ["mon"]
|
||||
assert status["auto_download_after_rescan"] is True
|
||||
assert status["folder_scan_enabled"] is True
|
||||
assert status["is_running"] is False
|
||||
assert status["next_run"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 12.11 _perform_rescan() with folder_scan_enabled=True
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPerformRescanFolderScan:
|
||||
@pytest.mark.asyncio
|
||||
async def test_folder_scan_called_when_enabled(self, scheduler_service):
|
||||
scheduler_service._config = SchedulerConfig(
|
||||
folder_scan_enabled=True,
|
||||
schedule_time="03:00",
|
||||
schedule_days=ALL_DAYS,
|
||||
)
|
||||
|
||||
mock_anime = MagicMock()
|
||||
mock_anime.rescan = AsyncMock()
|
||||
mock_anime._cached_list_missing.return_value = []
|
||||
|
||||
mock_ws = MagicMock()
|
||||
mock_ws.manager.broadcast = AsyncMock()
|
||||
|
||||
mock_folder_scan = AsyncMock()
|
||||
|
||||
with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
|
||||
patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \
|
||||
patch("src.server.services.folder_scan_service.FolderScanService") as MockFSS:
|
||||
MockFSS.return_value.run_folder_scan = mock_folder_scan
|
||||
await scheduler_service._perform_rescan()
|
||||
|
||||
mock_folder_scan.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_folder_scan_skipped_when_disabled(self, scheduler_service):
|
||||
scheduler_service._config = SchedulerConfig(
|
||||
folder_scan_enabled=False,
|
||||
schedule_time="03:00",
|
||||
schedule_days=ALL_DAYS,
|
||||
)
|
||||
|
||||
mock_anime = MagicMock()
|
||||
mock_anime.rescan = AsyncMock()
|
||||
mock_anime._cached_list_missing.return_value = []
|
||||
|
||||
mock_ws = MagicMock()
|
||||
mock_ws.manager.broadcast = AsyncMock()
|
||||
|
||||
mock_folder_scan = AsyncMock()
|
||||
|
||||
with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
|
||||
patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \
|
||||
patch("src.server.services.folder_scan_service.FolderScanService") as MockFSS:
|
||||
MockFSS.return_value.run_folder_scan = mock_folder_scan
|
||||
await scheduler_service._perform_rescan()
|
||||
|
||||
mock_folder_scan.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_folder_scan_error_broadcasts_and_does_not_crash(self, scheduler_service):
|
||||
scheduler_service._config = SchedulerConfig(
|
||||
folder_scan_enabled=True,
|
||||
schedule_time="03:00",
|
||||
schedule_days=ALL_DAYS,
|
||||
)
|
||||
|
||||
mock_anime = MagicMock()
|
||||
mock_anime.rescan = AsyncMock()
|
||||
mock_anime._cached_list_missing.return_value = []
|
||||
|
||||
mock_ws = MagicMock()
|
||||
mock_ws.manager.broadcast = AsyncMock()
|
||||
|
||||
mock_folder_scan = AsyncMock(side_effect=RuntimeError("folder scan boom"))
|
||||
|
||||
with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
|
||||
patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \
|
||||
patch("src.server.services.folder_scan_service.FolderScanService") as MockFSS:
|
||||
MockFSS.return_value.run_folder_scan = mock_folder_scan
|
||||
# Should NOT raise
|
||||
await scheduler_service._perform_rescan()
|
||||
|
||||
mock_folder_scan.assert_awaited_once()
|
||||
calls = [str(c) for c in mock_ws.manager.broadcast.call_args_list]
|
||||
assert any("folder_scan_error" in c for c in calls)
|
||||
assert scheduler_service._scan_in_progress is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -10,8 +10,12 @@ async def test_system_settings_integration():
|
||||
"""Test SystemSettings service with actual database operations."""
|
||||
# Initialize database
|
||||
await init_db()
|
||||
|
||||
# Test get_or_create (should create on first call)
|
||||
|
||||
# Reset all flags to a known-clean state before the test
|
||||
async with get_db_session() as db:
|
||||
await SystemSettingsService.reset_all_scans(db)
|
||||
|
||||
# Test get_or_create (should return record with all flags False after reset)
|
||||
async with get_db_session() as db:
|
||||
settings = await SystemSettingsService.get_or_create(db)
|
||||
assert settings is not None
|
||||
|
||||
4
tvshow.nfo.bad
Normal file
4
tvshow.nfo.bad
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tvshow>
|
||||
<title>Attack on Titan</title>
|
||||
</tvshow>
|
||||
19
tvshow.nfo.good
Normal file
19
tvshow.nfo.good
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tvshow>
|
||||
<title>Attack on Titan</title>
|
||||
<originaltitle>進撃の巨人</originaltitle>
|
||||
<year>2013</year>
|
||||
<plot>After his hometown is destroyed and his mother is killed, young Eren Yeager vows to cleanse the earth of the giant humanoid Titans that have brought humanity to the brink of extinction.</plot>
|
||||
<runtime>24</runtime>
|
||||
<premiered>2013-04-07</premiered>
|
||||
<status>Ended</status>
|
||||
<imdbid>tt2560140</imdbid>
|
||||
<genre>Action</genre>
|
||||
<studio>Wit Studio</studio>
|
||||
<country>Japan</country>
|
||||
<actor>
|
||||
<name>Yuki Kaji</name>
|
||||
<role>Eren Yeager</role>
|
||||
</actor>
|
||||
<watched>false</watched>
|
||||
</tvshow>
|
||||
Reference in New Issue
Block a user