- Add folder_scan_enabled boolean field (default false) to SchedulerConfig
- Update data/config.json example with new field
- Add checkbox to setup.html and include in JS payload
- Handle field in auth.py setup endpoint
- Expose field in scheduler API response
- Log and return field in scheduler_service.py
- Update docs/CONFIGURATION.md and docs/ARCHITECTURE.md
- Update index.html UI, app.js and scheduler-config.js handlers
- Verified backward compatibility: old configs load with default False
| `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. |
Valid day abbreviations: `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun`.
-`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`
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.
-`docs/ARCHITECTURE.md`: Add `folder_scan_service.py` to the services list.
factory=NFOServiceFactory()
nfo_service=factory.create()
nfo_path=awaitnfo_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
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
ifsettings.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.
logger.info("NFO service initialized successfully")
exceptValueError:
logger.info("NFO service not available — TMDB API key not configured")
exceptExceptionase:
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.
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.
-`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)
-`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")orNone# 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.
-`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.
- 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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.