Compare commits
174 Commits
v1.3.4
...
d3cbb60c00
| Author | SHA1 | Date | |
|---|---|---|---|
| d3cbb60c00 | |||
| 10ef590242 | |||
| e7628ac44c | |||
| f89e403a17 | |||
| 5f46d2e802 | |||
| a384072901 | |||
| d99636e9c7 | |||
| 47bd393a57 | |||
| 58adf05325 | |||
| a05e8a7b07 | |||
| 6a1f8c282a | |||
| 008873f1af | |||
| e2f0e187d0 | |||
| 9a3a2cbdcb | |||
| 1d06f8a00a | |||
| 34c1469517 | |||
| 097138376a | |||
| 04175a2bd6 | |||
| f7b24c3929 | |||
| 3a6b6dfd9e | |||
| 5e3a68163e | |||
| b845744c9d | |||
| 40d44d8b94 | |||
| 59b77bf833 | |||
| f29fd72b2d | |||
| 5ac58da650 | |||
| 5534191268 | |||
| db13b39b2b | |||
| 706aa37f18 | |||
| 10b5ca42f5 | |||
| fd8d9636cb | |||
| c6d9615731 | |||
| 61b539db6f | |||
| 7900994f78 | |||
| aeffb882dc | |||
| ac02dfd5c6 | |||
| d360e3f7a8 | |||
| 9da66872f6 | |||
| 9b6702c5fb | |||
| 8de563955a | |||
| 49f39d6b77 | |||
| 1f6a119bcc | |||
| 3fc1311d50 | |||
| 0be86771e1 | |||
| 07890aa464 | |||
| 2e723087d9 | |||
| e5a5a6009a | |||
| 4687a06374 | |||
| ad2320dbbb | |||
| c73b74c0db | |||
| eea9272de4 | |||
| c8f0c6fcb1 | |||
| c6d787c2c7 | |||
| 30c0499869 | |||
| 8b98c6d84e | |||
| e7a623a0d1 | |||
| 978e6ef200 | |||
| fd84a18b30 | |||
| 6c502e2014 | |||
| b4027be385 | |||
| a5e1c5b14f | |||
| 66acb45607 | |||
| 2cf008bcf8 | |||
| cece8fcb30 | |||
| 7e4aeb22db | |||
| 3cfea2e3b3 | |||
| d940460355 | |||
| 313bd1ebf3 | |||
| 46d34efecb | |||
| df0d54cc34 | |||
| 1f3eddf554 | |||
| d00e80e240 | |||
| 5028d4ea27 | |||
| e7d5df3a90 | |||
| 881da35dfd | |||
| 8fb24ff46a | |||
| ae368a0d8e | |||
| c6f01ca985 | |||
| ea59db302d | |||
| 6e9c2b853a | |||
| 42f4f0f5d7 | |||
| a6e103889f | |||
| b5e2ba4ac4 | |||
| 572aa0fc78 | |||
| be3e180137 | |||
| 107158eb04 | |||
| ad1aace0f5 | |||
| eabce18e41 | |||
| e050f6fa2d | |||
| a8e54876e3 | |||
| 7a1b2e565e | |||
| 6dc3cda810 | |||
| 75084b3941 | |||
| de330dc146 | |||
| 4731fd644a | |||
| 9d52ff0c45 | |||
| ee5d719f37 | |||
| cbc44491e7 | |||
| e319cfecb8 | |||
| 4f61ded92a | |||
| d6082b5cf6 | |||
| e76cd3a708 | |||
| 08f7f7453c | |||
| 023ddd182f | |||
| 288b03cbb4 | |||
| f73cc530c3 | |||
| 4b835a2439 | |||
| 7c1dccfe64 | |||
| e0be00dce6 | |||
| 14f7b2f28a | |||
| de250bdd37 | |||
| b800158648 | |||
| 4e0c66ea9e | |||
| 07c311c1cd | |||
| cf00c9f7c5 | |||
| f3042206a8 | |||
| 657e7f9bf5 | |||
| fd3ec5df83 | |||
| 275aeb4544 | |||
| be7b210959 | |||
| 486c5440f2 | |||
| 4076b9dd43 | |||
| df93e8a81f | |||
| 576d9f7a7b | |||
| af93daeddc | |||
| a05795bb35 | |||
| d22df947e4 | |||
| 8bb8c6aa64 | |||
| 109d3c8ac9 | |||
| 6a934db8ac | |||
| ac7302b1dd | |||
| ac5ee3bb27 | |||
| a9084202e3 | |||
| be9f2a4c0c | |||
| 53fe09351f | |||
| dc7d9ee5f7 | |||
| da3cae2812 | |||
| 2876cef24b | |||
| 6a402623c4 | |||
| ebfbec1225 | |||
| 01e4dec8d7 | |||
| ecef21eec4 | |||
| d9738ffb78 | |||
| 6aec2a1733 | |||
| 84487d7571 | |||
| e02d65778f | |||
| 45d259bab2 | |||
| 7b8de8d988 | |||
| 18d10b44b5 | |||
| 5c2be3f7c4 | |||
| 2c47713339 | |||
| e74b04c1ee | |||
| 8b21f1243f | |||
| 3d33626546 | |||
| 7d9f80a0c6 | |||
| 25dc66fec3 | |||
| 2be7b692b9 | |||
| 2b5c969a83 | |||
| 830f6b4c93 | |||
| 5526ab884a | |||
| 09d454d4c0 | |||
| 13504c3172 | |||
| 82493d41ea | |||
| 274f773988 | |||
| 21af502184 | |||
| 97caaf0d18 | |||
| dc5d6506bc | |||
| dbaf80e941 | |||
| 4fc597c5de | |||
| a77bb371df | |||
| 420d10bb34 | |||
| e29918488c | |||
| 9c3f03d610 | |||
| 9d64241230 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -84,3 +84,13 @@ tmp/
|
|||||||
*.tmp
|
*.tmp
|
||||||
.coverage
|
.coverage
|
||||||
.venv/bin/dotenv
|
.venv/bin/dotenv
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
tests/results/*
|
||||||
|
test-results/*
|
||||||
|
robot_results/*
|
||||||
|
test_output/*
|
||||||
|
test_results/*
|
||||||
|
tests/robot/output/*
|
||||||
|
tests/robot/results/*
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v1.3.4
|
v1.5.2
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
|
- LOG_LEVEL=DEBUG
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
- app-logs:/app/logs
|
- app-logs:/app/logs
|
||||||
|
|||||||
@@ -826,14 +826,32 @@ Source: [src/server/api/config.py](../src/server/api/config.py#L189-L247)
|
|||||||
|
|
||||||
Prefix: `/api/nfo`
|
Prefix: `/api/nfo`
|
||||||
|
|
||||||
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L1-L684)
|
Source: [src/server/api/nfo.py](../src/server/api/nfo.py)
|
||||||
|
|
||||||
These endpoints manage tvshow.nfo metadata files and associated media (poster, logo, fanart) for anime series. NFO files use Kodi/XBMC format and are scraped from TMDB API.
|
These endpoints manage tvshow.nfo metadata files for anime series. The
|
||||||
|
per-anime settings page (replacing the old "NFO Diagnostics" UI) lives
|
||||||
|
at `/api/anime/{key}/settings` — see section **7. Anime Settings
|
||||||
|
Endpoints** below.
|
||||||
|
|
||||||
**Prerequisites:**
|
**Prerequisites:**
|
||||||
|
|
||||||
- TMDB API key must be configured in settings
|
- TMDB API key must be configured in settings
|
||||||
- NFO service returns 503 if API key not configured
|
- NFO endpoints return 503 if the API key is missing
|
||||||
|
|
||||||
|
### Anime Settings — New Endpoints
|
||||||
|
|
||||||
|
For the UI-driven settings page (renamed from NFO Diagnostics), the
|
||||||
|
following endpoints replace the older `/api/nfo/{serie_id}/*` flow:
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/anime/{key}/settings` | Return all editable fields for a series |
|
||||||
|
| PUT | `/api/anime/{key}/settings` | Update name/folder/tmdb_id/tvdb_id/site, optionally regenerate tvshow.nfo |
|
||||||
|
| POST | `/api/anime/{key}/regenerate-nfo` | Regenerate tvshow.nfo using TMDB |
|
||||||
|
|
||||||
|
See [AnimeSettingsResponse](../src/server/models/anime.py) for the
|
||||||
|
response shape, and [section 7](#7-anime-settings-endpoints-new) for
|
||||||
|
full documentation.
|
||||||
|
|
||||||
### GET /api/nfo/{serie_id}/check
|
### GET /api/nfo/{serie_id}/check
|
||||||
|
|
||||||
@@ -1594,3 +1612,109 @@ GET /api/anime?page=2&per_page=50
|
|||||||
```
|
```
|
||||||
|
|
||||||
Source: [src/server/api/anime.py](../src/server/api/anime.py#L180-L220)
|
Source: [src/server/api/anime.py](../src/server/api/anime.py#L180-L220)
|
||||||
|
|
||||||
|
## 7. Anime Settings Endpoints (New)
|
||||||
|
|
||||||
|
Replaces the old "NFO Diagnostics" page with a per-anime settings UI
|
||||||
|
that views and edits anime metadata directly in the database.
|
||||||
|
|
||||||
|
Source: [src/server/api/anime.py](../src/server/api/anime.py)
|
||||||
|
|
||||||
|
### GET /api/anime/{anime_key}/settings
|
||||||
|
|
||||||
|
Return the full editable settings payload for a single anime series.
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
- `anime_key` (string): Series unique key (e.g., `attack-on-titan`)
|
||||||
|
|
||||||
|
**Response (200 OK):** [`AnimeSettingsResponse`](../src/server/models/anime.py)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "attack-on-titan",
|
||||||
|
"name": "Attack on Titan",
|
||||||
|
"site": "aniworld.to",
|
||||||
|
"folder": "Attack on Titan (2013)",
|
||||||
|
"year": 2013,
|
||||||
|
"tmdb_id": 1429,
|
||||||
|
"tvdb_id": 789,
|
||||||
|
"has_nfo": true,
|
||||||
|
"nfo_path": "/anime/Attack on Titan (2013)/tvshow.nfo",
|
||||||
|
"nfo_created_at": "2026-01-15T10:30:00+00:00",
|
||||||
|
"nfo_updated_at": "2026-01-15T10:30:00+00:00",
|
||||||
|
"loading_status": "completed",
|
||||||
|
"episode_count": 25,
|
||||||
|
"missing_episode_count": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
- `401 Unauthorized` — Not authenticated.
|
||||||
|
- `404 Not Found` — Series with the given key does not exist.
|
||||||
|
|
||||||
|
### PUT /api/anime/{anime_key}/settings
|
||||||
|
|
||||||
|
Update editable fields for a single anime series. Optional flags
|
||||||
|
control whether the on-disk folder is renamed and whether
|
||||||
|
`tvshow.nfo` is regenerated.
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Request Body** ([`AnimeSettingsUpdateRequest`](../src/server/models/anime.py)):
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `name` | string | no | Display name (1–500 chars) |
|
||||||
|
| `folder` | string | no | Filesystem folder name |
|
||||||
|
| `tmdb_id` | int | no | TMDB ID (positive integer, max 10 digits) |
|
||||||
|
| `tvdb_id` | int | no | TVDB ID (positive integer, max 10 digits) |
|
||||||
|
| `site` | string | no | Provider site URL |
|
||||||
|
| `apply_to_nfo` | bool | no | If true, regenerate `tvshow.nfo` with the new values (requires `tmdb_id`) |
|
||||||
|
| `rename_disk` | bool | no | If true and `folder` changed, rename the folder on disk |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT "http://127.0.0.1:8000/api/anime/attack-on-titan/settings" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tmdb_id": 9999, "apply_to_nfo": true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200 OK):** Updated [`AnimeSettingsResponse`](../src/server/models/anime.py).
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
- `400 Bad Request` — `apply_to_nfo=true` but the series has no `tmdb_id`.
|
||||||
|
- `401 Unauthorized` — Not authenticated.
|
||||||
|
- `404 Not Found` — Series with the given key does not exist.
|
||||||
|
- `422 Unprocessable Entity` — Validation failure (empty name, invalid
|
||||||
|
folder, non-positive `tmdb_id`/`tvdb_id`, oversized id, path traversal).
|
||||||
|
|
||||||
|
### POST /api/anime/{anime_key}/regenerate-nfo
|
||||||
|
|
||||||
|
Regenerate `tvshow.nfo` for a single anime using TMDB.
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Response (200 OK):** [`AnimeSettingsRegenerateNfoResponse`](../src/server/models/anime.py)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "NFO regenerated. 2 tags updated.",
|
||||||
|
"nfo_path": "/anime/Attack on Titan (2013)/tvshow.nfo",
|
||||||
|
"repaired_tags": ["title", "tmdbid"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
- `400 Bad Request` — Series has no `tmdb_id`.
|
||||||
|
- `401 Unauthorized` — Not authenticated.
|
||||||
|
- `404 Not Found` — Series with the given key does not exist.
|
||||||
|
- `500 Internal Server Error` — TMDB or NFO regeneration failure.
|
||||||
@@ -80,8 +80,12 @@ src/server/
|
|||||||
| +-- progress_service.py # Progress tracking
|
| +-- progress_service.py # Progress tracking
|
||||||
| +-- websocket_service.py# WebSocket broadcasting
|
| +-- websocket_service.py# WebSocket broadcasting
|
||||||
| +-- queue_repository.py # Database persistence
|
| +-- queue_repository.py # Database persistence
|
||||||
| +-- nfo_service.py # NFO metadata management
|
| +-- setup_service.py # Series key resolution from folder names
|
||||||
| +-- folder_scan_service.py # Daily folder maintenance scan
|
| +-- nfo_scan_service.py # NFO creation, update, and regeneration from TMDB
|
||||||
|
| +-- scan_service.py # Library rescan (episodes, missing files)
|
||||||
|
| +-- folder_naming_service.py # Folder rename to Title (YYYY) convention
|
||||||
|
| +-- scheduler/ # Scheduled tasks
|
||||||
|
| | +-- scheduler_service.py # Cron-based library rescans
|
||||||
+-- models/ # Pydantic models
|
+-- models/ # Pydantic models
|
||||||
| +-- auth.py # Auth request/response models
|
| +-- auth.py # Auth request/response models
|
||||||
| +-- config.py # Configuration models
|
| +-- config.py # Configuration models
|
||||||
@@ -165,11 +169,42 @@ src/server/web/static/js/
|
|||||||
| +-- socket-handler.js # WebSocket event handlers
|
| +-- socket-handler.js # WebSocket event handlers
|
||||||
| +-- app-init.js # Application initialization
|
| +-- app-init.js # Application initialization
|
||||||
+-- queue/ # Queue page modules
|
+-- queue/ # Queue page modules
|
||||||
+-- queue-api.js # Queue API interactions
|
| +-- queue-api.js # Queue API wrapper (uses ApiClient internally)
|
||||||
+-- queue-renderer.js # Queue list rendering
|
| +-- queue-renderer.js # Queue DOM rendering
|
||||||
+-- progress-handler.js # Download progress updates
|
| +-- progress-handler.js # Download progress updates
|
||||||
+-- queue-socket-handler.js # WebSocket events for queue
|
| +-- queue-socket-handler.js # WebSocket events for queue
|
||||||
+-- queue-init.js # Queue page initialization
|
| +-- queue-init.js # Queue page initialisation and orchestration
|
||||||
|
```
|
||||||
|
|
||||||
|
**Queue Module Public APIs:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// queue-api.js — wraps /api/queue/* endpoints via AniWorld.ApiClient
|
||||||
|
AniWorld.QueueAPI.loadQueueData() // GET /api/queue/status → queue data
|
||||||
|
AniWorld.QueueAPI.startQueue() // POST /api/queue/start
|
||||||
|
AniWorld.QueueAPI.stopQueue() // POST /api/queue/stop
|
||||||
|
AniWorld.QueueAPI.removeFromQueue(itemId) // DELETE /api/queue/{item_id}
|
||||||
|
AniWorld.QueueAPI.retryDownloads(itemIds) // POST /api/queue/retry
|
||||||
|
AniWorld.QueueAPI.clearCompleted() // DELETE /api/queue/completed
|
||||||
|
AniWorld.QueueAPI.clearFailed() // DELETE /api/queue/failed
|
||||||
|
AniWorld.QueueAPI.clearPending() // DELETE /api/queue/pending
|
||||||
|
|
||||||
|
// queue-init.js — page orchestration (imports QueueAPI internally)
|
||||||
|
AniWorld.QueueApp.init() // Bootstrap; calls loadQueueData()
|
||||||
|
AniWorld.QueueApp.loadQueueData() // Fetch queue data and render
|
||||||
|
AniWorld.QueueApp.startDownload() // Start queue processing
|
||||||
|
AniWorld.QueueApp.stopDownloads() // Stop queue processing
|
||||||
|
AniWorld.QueueApp.removeFromQueue(id) // Remove single item
|
||||||
|
AniWorld.QueueApp.retryDownload(id) // Retry failed item
|
||||||
|
AniWorld.QueueApp.retryAllFailed() // Retry all failed items
|
||||||
|
AniWorld.QueueApp.clearQueue(type) // Clear completed|failed|pending
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Module dependency rule**: Queue page modules must access API endpoints via
|
||||||
|
> `AniWorld.QueueAPI.<method>()`. The `API` object (`AniWorld.Constants.API`) is
|
||||||
|
> private to each module's IIFE closure and is NOT a global. Do NOT use bare
|
||||||
|
> `fetch(API.QUEUE_STATUS, ...)` in `queue-init.js` — use
|
||||||
|
> `AniWorld.QueueAPI.loadQueueData()` instead.
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Module Pattern
|
#### Module Pattern
|
||||||
@@ -194,51 +229,48 @@ AniWorld.ModuleName = (function () {
|
|||||||
|
|
||||||
Source: [src/server/web/static/](../src/server/web/static/)
|
Source: [src/server/web/static/](../src/server/web/static/)
|
||||||
|
|
||||||
### 2.3 Core Layer (`src/core/`)
|
### 2.3 Core Layer (`src/server/`)
|
||||||
|
|
||||||
Domain logic for anime series management.
|
Domain logic for anime series management, NFO metadata, and episode downloads.
|
||||||
|
|
||||||
```
|
```
|
||||||
src/core/
|
src/server/
|
||||||
+-- SeriesApp.py # Main application facade
|
|
||||||
+-- SerieScanner.py # Directory scanning, targeted single-series scan
|
+-- SerieScanner.py # Directory scanning, targeted single-series scan
|
||||||
+-- entities/ # Domain entities
|
+-- SerieList.py # Series collection (stub; see src/server/database/SerieList.py)
|
||||||
| +-- series.py # Serie class with sanitized_folder property
|
+-- nfo/ # NFO metadata generation and mapping
|
||||||
| +-- SerieList.py # SerieList collection with sanitized folder support
|
| +-- nfo_generator.py # TVShowNFO → XML serialiser (generate_tvshow_nfo)
|
||||||
| +-- nfo_models.py # Pydantic models for tvshow.nfo (TVShowNFO, ActorInfo…)
|
|
||||||
+-- services/ # Domain services
|
|
||||||
| +-- nfo_service.py # NFO lifecycle: create / update tvshow.nfo
|
|
||||||
| +-- nfo_repair_service.py # Detect & repair incomplete tvshow.nfo files
|
|
||||||
| | # (parse_nfo_tags, find_missing_tags, NfoRepairService)
|
|
||||||
| +-- tmdb_client.py # Async TMDB API client
|
|
||||||
+-- utils/ # Utility helpers (no side-effects)
|
|
||||||
| +-- nfo_generator.py # TVShowNFO → XML serialiser
|
|
||||||
| +-- nfo_mapper.py # TMDB API dict → TVShowNFO (tmdb_to_nfo_model,
|
| +-- nfo_mapper.py # TMDB API dict → TVShowNFO (tmdb_to_nfo_model,
|
||||||
| | # _extract_rating_by_country, _extract_fsk_rating)
|
| | # _extract_rating_by_country, _extract_fsk_rating)
|
||||||
| +-- image_downloader.py # TMDB image downloader
|
| +-- nfo_models.py # Pydantic models for NFO XML (TVShowNFO, ActorInfo…)
|
||||||
|
| +-- tmdb_client.py # Async TMDB API client
|
||||||
+-- providers/ # External provider adapters
|
+-- providers/ # External provider adapters
|
||||||
| +-- base_provider.py # Loader interface
|
| +-- base_provider.py # Loader interface
|
||||||
| +-- provider_factory.py # Provider registry
|
| +-- provider_factory.py # Provider registry
|
||||||
+-- interfaces/ # Abstract interfaces
|
| +-- aniworld_provider.py # AniWorld scraper
|
||||||
|
| +-- enhanced_provider.py # Multi-provider with failover
|
||||||
|
| +-- provider_config.py # Provider preference configuration
|
||||||
|
| +-- streaming/ # Provider-specific extractors (VOE, Doodstream, etc.)
|
||||||
|
+-- entities/
|
||||||
|
| +-- nfo_models.py # Domain entities for NFO (aligns with nfo/nfo_models.py)
|
||||||
|
+-- interfaces/
|
||||||
| +-- callbacks.py # Progress callback system
|
| +-- callbacks.py # Progress callback system
|
||||||
+-- exceptions/ # Domain exceptions
|
| +-- providers.py # Provider interface definitions
|
||||||
+-- Exceptions.py # Custom exceptions
|
+-- exceptions/
|
||||||
|
+-- Exceptions.py # Custom exceptions
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key Components:**
|
**Key Components:**
|
||||||
|
|
||||||
| Component | Purpose |
|
|| Component | Purpose |
|
||||||
| -------------- | -------------------------------------------------------------------------- |
|
| --- | --- |
|
||||||
| `SeriesApp` | Main application facade for anime operations |
|
| `SerieScanner` | Scans directories for anime; `scan_single_series()` for targeted scans |
|
||||||
| `SerieScanner` | Scans directories for anime; `scan_single_series()` for targeted scans |
|
| `tmdb_client.py` | Async TMDB API client |
|
||||||
| `Serie` | Domain entity with `sanitized_folder` property for filesystem-safe names |
|
| `nfo_generator.py` | Serialises `TVShowNFO` to XML |
|
||||||
| `SerieList` | Collection management with automatic folder creation using sanitized names |
|
| `nfo_mapper.py` | Maps TMDB API response to `TVShowNFO` domain model |
|
||||||
|
| `enhanced_provider.py` | Multi-provider downloader with failover chain |
|
||||||
|
|
||||||
**Initialization:**
|
> **Note:** The `src/core/` directory was an earlier architectural proposal and is
|
||||||
|
> currently empty. All domain logic lives under `src/server/`.
|
||||||
`SeriesApp` is initialized with `skip_load=True` passed to `SerieList`, preventing automatic loading of series from data files on every instantiation. Series data is loaded once during application setup via `sync_series_from_data_files()` in the FastAPI lifespan, which reads data files and syncs them to the database. Subsequent operations load series from the database through the service layer.
|
|
||||||
|
|
||||||
Source: [src/core/](../src/core/)
|
|
||||||
|
|
||||||
### 2.4 Infrastructure Layer (`src/infrastructure/`)
|
### 2.4 Infrastructure Layer (`src/infrastructure/`)
|
||||||
|
|
||||||
@@ -427,8 +459,8 @@ Source: [src/server/middleware/auth.py](../src/server/middleware/auth.py#L1-L209
|
|||||||
| Exception / cancellation | Temp + `.part` fragments removed in `except` block |
|
| Exception / cancellation | Temp + `.part` fragments removed in `except` block |
|
||||||
|
|
||||||
Source: [src/server/services/download_service.py](../src/server/services/download_service.py#L1-L150),
|
Source: [src/server/services/download_service.py](../src/server/services/download_service.py#L1-L150),
|
||||||
[src/core/providers/aniworld_provider.py](../src/core/providers/aniworld_provider.py),
|
[src/server/providers/aniworld_provider.py](../src/server/providers/aniworld_provider.py),
|
||||||
[src/core/providers/enhanced_provider.py](../src/core/providers/enhanced_provider.py)
|
[src/server/providers/enhanced_provider.py](../src/server/providers/enhanced_provider.py)
|
||||||
|
|
||||||
### 3.3 WebSocket Event Flow
|
### 3.3 WebSocket Event Flow
|
||||||
|
|
||||||
@@ -730,7 +762,7 @@ class Loader(ABC):
|
|||||||
def get_episodes(self, serie: Serie) -> Dict[int, List[int]]: ...
|
def get_episodes(self, serie: Serie) -> Dict[int, List[int]]: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
Source: [src/core/providers/base_provider.py](../src/core/providers/base_provider.py)
|
Source: [src/server/providers/base_provider.py](../src/server/providers/base_provider.py)
|
||||||
|
|
||||||
### 8.2 Filesystem Integration
|
### 8.2 Filesystem Integration
|
||||||
|
|
||||||
@@ -744,7 +776,7 @@ SerieScanner(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Source: [src/core/SerieScanner.py](../src/core/SerieScanner.py#L59-L96)
|
Source: [src/server/SerieScanner.py](../src/server/SerieScanner.py#L59-L96)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -37,21 +37,105 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [Unreleased] - 2026-06-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Anime Settings page** — renamed from "NFO Diagnostics". Right-click
|
||||||
|
on any anime card → "Anime Settings" navigates to
|
||||||
|
`/anime/settings?key=<series>`. The new page lets the user view and
|
||||||
|
edit `name`, `folder`, `tmdb_id`, `tvdb_id`, and `site` directly in
|
||||||
|
the database, with options to rename the on-disk folder and
|
||||||
|
regenerate `tvshow.nfo` in one click.
|
||||||
|
- **New API endpoints** under `/api/anime/{key}/`:
|
||||||
|
- `GET /settings` — return full editable settings payload
|
||||||
|
- `PUT /settings` — update fields with validation
|
||||||
|
- `POST /regenerate-nfo` — regenerate `tvshow.nfo` from TMDB
|
||||||
|
- **Pydantic models**: `AnimeSettingsResponse`,
|
||||||
|
`AnimeSettingsUpdateRequest`, `AnimeSettingsRegenerateNfoResponse`
|
||||||
|
in [src/server/models/anime.py](../src/server/models/anime.py).
|
||||||
|
- **Frontend module**: `AniWorld.AnimeSettingsManager` IIFE in
|
||||||
|
[src/server/web/static/js/pages/anime-settings.js](../src/server/web/static/js/pages/anime-settings.js)
|
||||||
|
with public API: `init`, `loadSeries`, `saveSettings`,
|
||||||
|
`regenerateNfo`, `validateField`, `populateForm`, `showSaveSuccess`,
|
||||||
|
`showError`.
|
||||||
|
- **Vitest JS unit tests** covering every public function on
|
||||||
|
`AnimeSettingsManager` — 31 tests in
|
||||||
|
[tests/frontend/unit/anime_settings.test.js](../tests/frontend/unit/anime_settings.test.js).
|
||||||
|
- **Playwright E2E test** for the right-click → settings page flow in
|
||||||
|
[tests/frontend/e2e/anime_settings_page.spec.js](../tests/frontend/e2e/anime_settings_page.spec.js).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Right-click context menu** on the library page: "NFO Diagnostics"
|
||||||
|
→ "Anime Settings" (`data-action="nfo-diagnostics"` →
|
||||||
|
`data-action="anime-settings"`).
|
||||||
|
- **Configuration modal link**: "Open NFO Diagnostics" → "Open Anime
|
||||||
|
Settings", target URL `/settings/nfo` → `/anime/settings`.
|
||||||
|
- **Page route**: `/settings/nfo` returns a 301 redirect to
|
||||||
|
`/anime/settings` for backwards compatibility with bookmarks.
|
||||||
|
- **Pydantic model rename** in [src/server/models/nfo.py](../src/server/models/nfo.py):
|
||||||
|
- `NfoDiagnosticsResponse` → `NfoSettingsResponse`
|
||||||
|
- `NfoSeriesDiagnostics` → `NfoSeriesSettings`
|
||||||
|
- **Function rename** in [src/server/api/nfo.py](../src/server/api/nfo.py):
|
||||||
|
- `get_nfo_diagnostics` → `get_nfo_settings`
|
||||||
|
- `repair_nfo` → `repair_nfo_settings`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Queue view blank after adding items**: `queue-init.js`'s `loadQueueData()` called
|
||||||
|
`API.QUEUE_STATUS` directly, but `API` is a local variable inside
|
||||||
|
`AniWorld.QueueAPI`'s IIFE — not accessible globally. Items added to the
|
||||||
|
queue were persisted server-side but the queue page could not fetch them,
|
||||||
|
leaving the view empty with an `API is not defined` console error. Fixed by
|
||||||
|
replacing the inline `fetch` with `AniWorld.QueueAPI.loadQueueData()`, which
|
||||||
|
already exists and correctly accesses the endpoint through its own closure.
|
||||||
|
The same file already uses `AniWorld.QueueAPI.*` for all other queue
|
||||||
|
operations (`startQueue`, `stopQueue`, `removeFromQueue`, etc.).
|
||||||
|
|
||||||
|
- **Bug**: `src/server/api/nfo.py` called the non-existent
|
||||||
|
`anime_service.update_series_nfo_status(...)` method, which would
|
||||||
|
raise `AttributeError` after a successful NFO repair. Renamed the
|
||||||
|
call to the existing `update_nfo_status(...)` method (matching its
|
||||||
|
signature `(key, has_nfo, tmdb_id=None, tvdb_id=None, db=None)`)
|
||||||
|
and added an explicit `AnimeSeriesService.update(db, id, nfo_path=...)`
|
||||||
|
call to keep `nfo_path` in sync. Covered by regression tests in
|
||||||
|
`TestBugFixCreateOrUpdateNfo`.
|
||||||
|
|
||||||
|
- **Bug**: Right-clicking a series card and choosing "Anime Settings"
|
||||||
|
opened `/anime/settings?key=null` instead of carrying the series key.
|
||||||
|
Root cause: the click handler in
|
||||||
|
[src/server/web/static/js/index/context-menu.js](../src/server/web/static/js/index/context-menu.js)
|
||||||
|
called `hide()` BEFORE building the URL — and `hide()` cleared
|
||||||
|
`currentSeriesKey` to null. Fix captures the key into a local
|
||||||
|
`const` before calling `hide()`. Regression-locked by
|
||||||
|
`tests/frontend/unit/context_menu.test.js` (5 tests).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Unreleased] - 2026-06-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Folder scan series key resolution**: Fixed "Could not resolve series key for folder, skipping" warnings during library setup. `_resolve_key_via_search()` now uses fuzzy title matching instead of exact string comparison.
|
||||||
|
- Added `_normalize_title()` to strip anime suffixes: `(TV)`, `(Anime)`, `(OAD)`, `(OVA)`, `(Special)`, `(Movie)`, `(Spin-Off)`
|
||||||
|
- Added `_titles_match()` using `difflib.SequenceMatcher` with 0.85 similarity threshold for tolerance of minor title variations
|
||||||
|
- Added debug logging for title mismatches and multiple search results
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.3.1] - 2026-02-22
|
## [1.3.1] - 2026-02-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Encoding detection for HTML parsing** (`src/core/providers/aniworld_provider.py`):
|
- **Encoding detection for HTML parsing** (`src/server/providers/aniworld_provider.py`):
|
||||||
Added `_decode_html_content()` function that uses `chardet` to detect the actual
|
Added `_decode_html_content()` function that uses `chardet` to detect the actual
|
||||||
encoding of HTML content before parsing. Falls back to UTF-8 with `errors='replace'`
|
encoding of HTML content before parsing. Falls back to UTF-8 with `errors='replace'`
|
||||||
to handle pages with mismatched encoding declarations. Applied to all BeautifulSoup
|
to handle pages with mismatched encoding declarations. Applied to all BeautifulSoup
|
||||||
parsing calls to prevent "Some characters could not be decoded" warnings.
|
parsing calls to prevent "Some characters could not be decoded" warnings.
|
||||||
- **chardet dependency**: Added `chardet>=5.2.0` to `requirements.txt` for encoding detection.
|
- **chardet dependency**: Added `chardet>=5.2.0` to `requirements.txt` for encoding detection.
|
||||||
|
- **Temp file cleanup after every download** (`src/server/providers/aniworld_provider.py`,
|
||||||
### Added
|
`src/server/providers/enhanced_provider.py`): Module-level helper
|
||||||
|
|
||||||
- **Temp file cleanup after every download** (`src/core/providers/aniworld_provider.py`,
|
|
||||||
`src/core/providers/enhanced_provider.py`): Module-level helper
|
|
||||||
`_cleanup_temp_file()` removes the working temp file and any yt-dlp `.part`
|
`_cleanup_temp_file()` removes the working temp file and any yt-dlp `.part`
|
||||||
fragments after each download attempt — on success, on failure, and on
|
fragments after each download attempt — on success, on failure, and on
|
||||||
exceptions (including `BrokenPipeError` and cancellation). Ensures that no
|
exceptions (including `BrokenPipeError` and cancellation). Ensures that no
|
||||||
@@ -68,37 +152,34 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **NFO tag completeness (`nfo_mapper.py`)**: All 17 required NFO tags are now
|
- **NFO tag completeness (`src/server/nfo/nfo_mapper.py`)**: All 17 required NFO tags are now
|
||||||
explicitly populated during creation: `originaltitle`, `sorttitle`, `year`,
|
explicitly populated during creation: `originaltitle`, `sorttitle`, `year`,
|
||||||
`plot`, `outline`, `tagline`, `runtime`, `premiered`, `status`, `imdbid`,
|
`plot`, `outline`, `tagline`, `runtime`, `premiered`, `status`, `imdbid`,
|
||||||
`genre`, `studio`, `country`, `actor`, `watched`, `dateadded`, `mpaa`.
|
`genre`, `studio`, `country`, `actor`, `watched`, `dateadded`, `mpaa`.
|
||||||
- **`src/core/utils/nfo_mapper.py`**: New module containing
|
- **`src/server/nfo/nfo_mapper.py`**: New module containing
|
||||||
`tmdb_to_nfo_model()`, `_extract_rating_by_country()`, and
|
`tmdb_to_nfo_model()`, `_extract_rating_by_country()`, and
|
||||||
`_extract_fsk_rating()`. Extracted from `NFOService` to keep files under
|
`_extract_fsk_rating()`. Extracted to keep files under 500 lines and isolate
|
||||||
500 lines and isolate pure mapping logic.
|
pure mapping logic.
|
||||||
|
- **`src/server/nfo/nfo_generator.py`**: XML serialiser for NFO files
|
||||||
|
(`generate_tvshow_nfo`).
|
||||||
- **US MPAA rating**: `_extract_rating_by_country(ratings, "US")` now maps the
|
- **US MPAA rating**: `_extract_rating_by_country(ratings, "US")` now maps the
|
||||||
US TMDB content rating to the `<mpaa>` NFO tag.
|
US TMDB content rating to the `<mpaa>` NFO tag.
|
||||||
- **`NfoRepairService` (`src/core/services/nfo_repair_service.py`)**: New service
|
- **`NfoScanService` (`src/server/services/nfo_scan_service.py`)**: New service
|
||||||
that detects incomplete `tvshow.nfo` files and triggers TMDB re-fetch.
|
that detects incomplete `tvshow.nfo` files and regenerates them from TMDB.
|
||||||
Provides `parse_nfo_tags()`, `find_missing_tags()`, `nfo_needs_repair()`, and
|
Provides `scan_all()`, `_scan_series()`, `_create_nfo()`,
|
||||||
`NfoRepairService.repair_series()`. 13 required tags are checked.
|
`_update_nfo_if_needed()`, and `_regenerate_nfo()`. 17 NFO tags are written.
|
||||||
- **`perform_nfo_repair_scan()`
|
- **`ScanService` (`src/server/services/scan_service.py`)**: New service for
|
||||||
(`src/server/services/folder_scan_service.py`)**: New async function
|
library rescans — detects new and removed episode files and syncs the
|
||||||
that iterates every series directory, checks whether `tvshow.nfo` is missing
|
`episodes` table accordingly.
|
||||||
required tags using `nfo_needs_repair()`, and queues the series for background
|
- **`FolderNamingService` (`src/server/services/folder_naming_service.py`)**:
|
||||||
reload via `asyncio.create_task`. Skips gracefully when `tmdb_api_key` or
|
Renames series folders to the `Title (YYYY)` convention using the year from
|
||||||
`anime_directory` is not configured.
|
`tvshow.nfo`. Prevents double-year accumulation on repeated runs.
|
||||||
- **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
|
### Changed
|
||||||
|
|
||||||
- `NFOService._tmdb_to_nfo_model()` and `NFOService._extract_fsk_rating()` moved
|
- `src/server/nfo/nfo_mapper.py` and `src/server/nfo/nfo_generator.py`
|
||||||
to `src/core/utils/nfo_mapper.py` as module-level functions
|
replaced the monolithic NFO logic from the previous service.
|
||||||
`tmdb_to_nfo_model()` and `_extract_fsk_rating()`.
|
- NFO generation moved to `src/server/nfo/nfo_generator.py`.
|
||||||
- `src/core/services/nfo_service.py` reduced from 640 → 471 lines.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ Location: `data/config.json`
|
|||||||
},
|
},
|
||||||
"backup": {
|
"backup": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"path": "data/backups",
|
"path": "data/config_backups",
|
||||||
"keep_days": 30
|
"keep_days": 30
|
||||||
},
|
},
|
||||||
"nfo": {
|
"nfo": {
|
||||||
234
Docs/NAVIGATION.md
Normal file
234
Docs/NAVIGATION.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
# Navigation & Redirect Logic
|
||||||
|
|
||||||
|
This document describes the setup flow navigation, covering how users progress from initial setup through to the main application.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The application uses a middleware-based redirect system to enforce a strict state machine. Users must complete each phase before accessing the next. Attempting to bypass the current phase redirects to the appropriate page.
|
||||||
|
|
||||||
|
## State Machine
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ NAVIGATION STATES │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ NO_SETUP ──────────► SETUP_COMPLETE ──────────► UNRESOLVED_PENDING │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ /setup /loading /setup/unresolved │
|
||||||
|
│ (series scan) (resolve folders) │
|
||||||
|
│ │
|
||||||
|
│ UNRESOLVED_DONE ───────┐
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ NFO_SCAN_PENDING │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ /loading │
|
||||||
|
│ (NFO scan) │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ COMPLETE │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ /login │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Definitions
|
||||||
|
|
||||||
|
| State | Condition | Target Page |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `NO_SETUP` | No master password configured | `/setup` |
|
||||||
|
| `SETUP_COMPLETE` | Initial config passed, loading not started | `/loading` |
|
||||||
|
| `UNRESOLVED_PENDING` | Setup done, unresolved exist, not marked done | `/setup/unresolved` |
|
||||||
|
| `UNRESOLVED_DONE` | Unresolved phase marked complete, NFO scan pending | `/loading` |
|
||||||
|
| `NFO_SCAN_PENDING` | Unresolved done, NFO scan incomplete | `/loading` |
|
||||||
|
| `COMPLETE` | All phases finished | `/login` |
|
||||||
|
|
||||||
|
## Middleware: SetupRedirectMiddleware
|
||||||
|
|
||||||
|
**File:** `src/server/middleware/setup_redirect.py`
|
||||||
|
|
||||||
|
The middleware intercepts all requests and enforces the state machine.
|
||||||
|
|
||||||
|
### Exempt Paths (always accessible)
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `/setup` | Initial setup page |
|
||||||
|
| `/setup/unresolved` | Unresolved folder resolution |
|
||||||
|
| `/loading` | Initialization progress page |
|
||||||
|
| `/login` | Authentication |
|
||||||
|
| `/api/auth/*` | Auth endpoints |
|
||||||
|
| `/api/config/*` | Config API |
|
||||||
|
| `/api/health` | Health check |
|
||||||
|
| `/static/*` | Static assets |
|
||||||
|
|
||||||
|
### Middleware Logic
|
||||||
|
|
||||||
|
The middleware checks the current state and redirects accordingly:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. NO_SETUP state:
|
||||||
|
→ Redirect ALL requests to /setup
|
||||||
|
→ Exception: /setup itself is accessible
|
||||||
|
|
||||||
|
2. SETUP_COMPLETE state:
|
||||||
|
→ Redirect /setup to /loading
|
||||||
|
→ Redirect any other page to /loading
|
||||||
|
|
||||||
|
3. UNRESOLVED_PENDING state (unresolved folders exist, not marked done):
|
||||||
|
→ Redirect /setup to /setup/unresolved
|
||||||
|
→ Redirect /loading to /setup/unresolved
|
||||||
|
→ Allow access to /setup/unresolved
|
||||||
|
→ Redirect any other page to /setup/unresolved
|
||||||
|
|
||||||
|
4. UNRESOLVED_DONE state (unresolved marked done, NFO scan pending):
|
||||||
|
→ Redirect /setup to /loading
|
||||||
|
→ Redirect /setup/unresolved to /loading
|
||||||
|
→ Redirect any other page to /loading
|
||||||
|
|
||||||
|
5. NFO_SCAN_PENDING state:
|
||||||
|
→ Redirect /setup to /loading
|
||||||
|
→ Redirect /setup/unresolved to /loading
|
||||||
|
→ Allow access to /loading (NFO phase runs)
|
||||||
|
→ Redirect any other page to /loading
|
||||||
|
|
||||||
|
6. COMPLETE state (loading finished):
|
||||||
|
→ Redirect /setup, /loading, /setup/unresolved to /login
|
||||||
|
→ Allow access to /login and main app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase Tracking Flags
|
||||||
|
|
||||||
|
| Flag | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `setup_complete` | Initial configuration was saved |
|
||||||
|
| `loading_started` | Loading phase has been initiated (redirected to /loading) |
|
||||||
|
| `unresolved_completed` | User clicked "Done" on unresolved page |
|
||||||
|
| `loading_complete` | Series scan + initial loading finished |
|
||||||
|
| `nfo_scan_complete` | Final NFO scan finished |
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
### 1. Setup Page (`/setup`)
|
||||||
|
|
||||||
|
**File:** `src/server/web/templates/setup.html`
|
||||||
|
|
||||||
|
Handles initial configuration:
|
||||||
|
- Master password creation
|
||||||
|
- Anime directory selection
|
||||||
|
- Database initialization
|
||||||
|
|
||||||
|
**Allowed in states:** `NO_SETUP`
|
||||||
|
|
||||||
|
**Post-completion:**
|
||||||
|
- Sets `setup_complete` flag
|
||||||
|
- Redirects to `/loading`
|
||||||
|
|
||||||
|
### 2. Loading Page (`/loading`)
|
||||||
|
|
||||||
|
**File:** `src/server/web/templates/loading.html`
|
||||||
|
|
||||||
|
Shows initialization progress via WebSocket:
|
||||||
|
- Series scanning
|
||||||
|
- Database population
|
||||||
|
- Logo/image loading
|
||||||
|
|
||||||
|
**Allowed in states:** `SETUP_COMPLETE`, `UNRESOLVED_DONE`, `NFO_SCAN_PENDING`
|
||||||
|
|
||||||
|
**Post-initialization (series scan complete):**
|
||||||
|
```javascript
|
||||||
|
async function checkUnresolvedAndProceed() {
|
||||||
|
const res = await fetch('/api/setup/unresolved', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const folders = await res.json();
|
||||||
|
|
||||||
|
if (folders.length > 0) {
|
||||||
|
window.location.href = '/setup/unresolved';
|
||||||
|
} else {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Post-NFO scan:**
|
||||||
|
- Sets `nfo_scan_complete` flag
|
||||||
|
- Redirects to `/login`
|
||||||
|
|
||||||
|
### 3. Unresolved Folders Page (`/setup/unresolved`)
|
||||||
|
|
||||||
|
**File:** `src/server/web/templates/unresolved.html`
|
||||||
|
|
||||||
|
Allows manual resolution of folders that couldn't be auto-matched:
|
||||||
|
- Shows list of unresolved folders
|
||||||
|
- Provides search suggestions
|
||||||
|
- Input field for entering provider key
|
||||||
|
- Resolve/delete actions
|
||||||
|
- **Done button** to complete the phase without resolving all folders
|
||||||
|
|
||||||
|
**Allowed in states:** `UNRESOLVED_PENDING`
|
||||||
|
|
||||||
|
**Done button behavior:**
|
||||||
|
- Sets `unresolved_completed` flag
|
||||||
|
- Redirects to `/loading` for final NFO scan
|
||||||
|
|
||||||
|
**After completion:**
|
||||||
|
- Any access redirects to `/loading`
|
||||||
|
|
||||||
|
### 4. Login Page (`/login`)
|
||||||
|
|
||||||
|
**File:** `src/server/web/templates/login.html`
|
||||||
|
|
||||||
|
Authentication page. After successful login → redirect to `/` (main app).
|
||||||
|
|
||||||
|
**Allowed in states:** `COMPLETE`
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Unresolved Folders API
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/api/setup/unresolved` | List all unresolved folders |
|
||||||
|
| `GET` | `/api/setup/unresolved/{folder_name}` | Get specific folder details |
|
||||||
|
| `POST` | `/api/setup/unresolved/{folder_name}/resolve` | Resolve with provider key |
|
||||||
|
| `POST` | `/api/setup/unresolved/{folder_name}/search` | Re-search for matches |
|
||||||
|
| `DELETE` | `/api/setup/unresolved/{folder_name}` | Remove folder from tracking |
|
||||||
|
| `POST` | `/api/setup/unresolved/done` | Mark unresolved phase as complete |
|
||||||
|
|
||||||
|
### Auth API
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `POST` | `/api/auth/setup` | Create master password |
|
||||||
|
| `POST` | `/api/auth/login` | Authenticate |
|
||||||
|
| `POST` | `/api/auth/logout` | End session |
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `src/server/middleware/setup_redirect.py` | Redirect middleware (state machine) |
|
||||||
|
| `src/server/controllers/page_controller.py` | Page route handlers |
|
||||||
|
| `src/server/web/templates/setup.html` | Setup template |
|
||||||
|
| `src/server/web/templates/loading.html` | Loading template |
|
||||||
|
| `src/server/web/templates/unresolved.html` | Unresolved folders template |
|
||||||
|
| `src/server/api/setup_endpoints.py` | Unresolved folders API |
|
||||||
|
| `src/server/database/service.py` | UnresolvedFolderService |
|
||||||
|
|
||||||
|
## Navigation Summary
|
||||||
|
|
||||||
|
| Current State | Access `/setup` | Access `/loading` | Access `/setup/unresolved` |
|
||||||
|
|--------------|-----------------|-------------------|---------------------------|
|
||||||
|
| NO_SETUP | ✅ Allowed | ❌ → `/setup` | ❌ → `/setup` |
|
||||||
|
| SETUP_COMPLETE | ❌ → `/loading` | ✅ Allowed | ❌ → `/loading` |
|
||||||
|
| UNRESOLVED_PENDING | ❌ → `/setup/unresolved` | ❌ → `/setup/unresolved` | ✅ Allowed |
|
||||||
|
| UNRESOLVED_DONE | ❌ → `/loading` | ✅ Allowed (NFO phase) | ❌ → `/loading` |
|
||||||
|
| NFO_SCAN_PENDING | ❌ → `/loading` | ✅ Allowed (NFO phase) | ❌ → `/loading` |
|
||||||
|
| COMPLETE | ❌ → `/login` | ❌ → `/login` | ❌ → `/login` |
|
||||||
@@ -728,11 +728,11 @@ Every poster check action is logged:
|
|||||||
|
|
||||||
### 8.1 Custom NFO Templates
|
### 8.1 Custom NFO Templates
|
||||||
|
|
||||||
You can customize NFO generation by modifying the NFO service:
|
You can customise NFO generation by modifying `src/server/nfo/nfo_generator.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# src/core/services/nfo_creator.py
|
# src/server/nfo/nfo_generator.py
|
||||||
def generate_tvshow_nfo(self, metadata: dict) -> str:
|
def generate_tvshow_nfo(metadata: TVShowNFO) -> str:
|
||||||
# Add custom fields or modify structure
|
# Add custom fields or modify structure
|
||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
@@ -811,78 +811,64 @@ updated via `create_tvshow_nfo()` / `update_tvshow_nfo()`.
|
|||||||
| `watched` | Always `false` on creation | ✅ |
|
| `watched` | Always `false` on creation | ✅ |
|
||||||
| `dateadded` | System clock at creation time (`YYYY-MM-DD HH:MM:SS`) | ✅ |
|
| `dateadded` | System clock at creation time (`YYYY-MM-DD HH:MM:SS`) | ✅ |
|
||||||
|
|
||||||
The mapping logic lives in `src/core/utils/nfo_mapper.py` (`tmdb_to_nfo_model`).
|
The mapping logic lives in `src/server/nfo/nfo_mapper.py` (`tmdb_to_nfo_model`).
|
||||||
The XML serialisation lives in `src/core/utils/nfo_generator.py`
|
The XML serialisation lives in `src/server/nfo/nfo_generator.py`
|
||||||
(`generate_tvshow_nfo`).
|
(`generate_tvshow_nfo`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Automatic NFO Repair
|
## 11. Automatic NFO Repair
|
||||||
|
|
||||||
NFO repair now runs as part of the scheduled daily folder scan rather than on every
|
NFO repair runs as part of the scheduled daily scan via ``SchedulerService``.
|
||||||
startup. When the scheduler triggers `FolderScanService.run_folder_scan()`, the first
|
When the scheduler fires, it calls ``_run_nfo_scan()`` which delegates to
|
||||||
step is `perform_nfo_repair_scan(background_loader=None)`. Each incomplete NFO is
|
``NfoScanService.scan_all()``. This detects series whose ``tvshow.nfo`` is
|
||||||
queued as a background `asyncio` task, so the scan returns quickly while repairs
|
missing required tags and regenerates them from TMDB.
|
||||||
continue asynchronously.
|
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
1. **Scan** — `perform_nfo_repair_scan()` in
|
1. **Scheduler** fires the daily job (``SchedulerService._run_nfo_scan()``)
|
||||||
`src/server/services/initialization_service.py` is called from
|
2. **Detect** — ``NfoScanService._scan_series()`` parses each ``tvshow.nfo``
|
||||||
`FolderScanService.run_folder_scan()` (`src/server/services/folder_scan_service.py`).
|
and calls ``_create_nfo()`` / ``_update_nfo_if_needed()`` /
|
||||||
2. **Detect** — `nfo_needs_repair(nfo_path)` from
|
``_regenerate_nfo()`` to fill missing tags from TMDB
|
||||||
`src/core/services/nfo_repair_service.py` parses each `tvshow.nfo` with
|
3. **Repair** — If TMDB lookup succeeds, the NFO is overwritten with complete
|
||||||
`lxml` and checks for the 13 required tags listed below.
|
data; if it fails, the original is kept and the failure is logged
|
||||||
3. **Repair** — Series whose NFO is incomplete are queued for background reload
|
|
||||||
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)
|
### Tags Written / Updated
|
||||||
|
|
||||||
| XPath | Tag name |
|
The NFO scan writes all 17 tags listed in the
|
||||||
| ----------------- | --------------- |
|
[Tag Reference](#10-tag-reference) above. Missing or empty tags trigger a
|
||||||
| `./title` | `title` |
|
regeneration from TMDB.
|
||||||
| `./originaltitle` | `originaltitle` |
|
|
||||||
| `./year` | `year` |
|
|
||||||
| `./plot` | `plot` |
|
|
||||||
| `./runtime` | `runtime` |
|
|
||||||
| `./premiered` | `premiered` |
|
|
||||||
| `./status` | `status` |
|
|
||||||
| `./imdbid` | `imdbid` |
|
|
||||||
| `./genre` | `genre` |
|
|
||||||
| `./studio` | `studio` |
|
|
||||||
| `./country` | `country` |
|
|
||||||
| `./actor/name` | `actor/name` |
|
|
||||||
| `./watched` | `watched` |
|
|
||||||
|
|
||||||
### Log Messages
|
### Log Messages
|
||||||
|
|
||||||
| Message | Meaning |
|
|| Message | Meaning |
|
||||||
| ----------------------------------------------------------- | ------------------------------------------------- |
|
| --- | --- |
|
||||||
| `NFO repair scan complete: 0 of N series queued for repair` | All NFOs are complete — no action needed |
|
| `NFO scan complete: N series processed` | Scan finished normally |
|
||||||
| `NFO repair scan complete: X of N series queued for repair` | X series had incomplete NFOs and have been queued |
|
| `NFO scan skipped: TMDB API key not configured` | ``tmdb_api_key`` is empty — set it in ``data/config.json`` |
|
||||||
| `NFO repair scan skipped: TMDB API key not configured` | Set `tmdb_api_key` in `data/config.json` |
|
| `NFO scan skipped: anime directory not configured` | ``anime_directory`` is not set |
|
||||||
| `NFO repair scan skipped: anime directory not configured` | Set `anime_directory` in `data/config.json` |
|
|
||||||
|
|
||||||
### Triggering a Manual Repair
|
### Manual Repair
|
||||||
|
|
||||||
You can also repair a single series on demand via the API:
|
You can repair a single series on demand via the API:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST /api/nfo/update/{series_key}
|
POST /api/nfo/{series_key}/create
|
||||||
```
|
```
|
||||||
|
|
||||||
This calls `NFOService.update_tvshow_nfo()` directly and overwrites the existing
|
or update with fresh TMDB data:
|
||||||
`tvshow.nfo` with fresh data from TMDB.
|
|
||||||
|
```http
|
||||||
|
POST /api/nfo/{series_key}/update
|
||||||
|
```
|
||||||
|
|
||||||
### Source Files
|
### Source Files
|
||||||
|
|
||||||
| File | Purpose |
|
|| File | Purpose |
|
||||||
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
| --- | --- |
|
||||||
| `src/core/services/nfo_repair_service.py` | `REQUIRED_TAGS`, `parse_nfo_tags`, `find_missing_tags`, `nfo_needs_repair`, `NfoRepairService` |
|
| ``src/server/services/scheduler/scheduler_service.py`` | ``SchedulerService._run_nfo_scan()`` — entry point called by the scheduler |
|
||||||
| `src/server/services/folder_scan_service.py` | `perform_nfo_repair_scan` — invoked during the scheduled daily folder scan |
|
| ``src/server/services/nfo_scan_service.py`` | ``NfoScanService.scan_all()`` — detects incomplete NFOs and regenerates them |
|
||||||
|
| ``src/server/services/scan_service.py`` | ``ScanService`` — library rescan (episodes, missing files) |
|
||||||
|
| ``src/server/services/folder_naming_service.py`` | ``FolderNamingService`` — renames folders to ``Title (YYYY)`` format |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
75
Docs/TESTING.md
Normal file
75
Docs/TESTING.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
|
||||||
|
### Testing FolderNamingService
|
||||||
|
|
||||||
|
FolderNamingService fixes missing years in anime folder names after NFO refresh. Test file: `tests/unit/test_folder_naming_service.py`.
|
||||||
|
|
||||||
|
Key methods tested:
|
||||||
|
- `_build_target_folder()` — constructs safe "Title (YYYY)" names, strips existing year suffix first (prevents double-year like "Naruto (1999) (1999)")
|
||||||
|
- `_folder_has_year()` — detects existing `(YYYY)` pattern
|
||||||
|
- `_extract_year_from_folder_name()` — parses `(YYYY)` suffix
|
||||||
|
- `_extract_title_from_folder_name()` — strips year suffix
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Safe: repeated calls never accumulate years
|
||||||
|
r1 = FolderNamingService._build_target_folder("Naruto", 1999)
|
||||||
|
r2 = FolderNamingService._build_target_folder(r1, 1999)
|
||||||
|
assert r1 == r2 == "Naruto (1999)"
|
||||||
|
|
||||||
|
# Safe: existing year is replaced, not appended
|
||||||
|
result = FolderNamingService._build_target_folder("Naruto (2020)", 1999)
|
||||||
|
assert result == "Naruto (1999)"
|
||||||
|
assert "2020" not in result
|
||||||
|
```
|
||||||
|
|
||||||
|
The service is also tested end-to-end with mocked filesystem and database:
|
||||||
|
- Renames folder and updates DB when year is missing from folder name
|
||||||
|
- Skips rename when folder already has a year
|
||||||
|
- Skips rename when DB has no year
|
||||||
|
- Skips when target folder already exists on disk
|
||||||
|
- Safety guard detects and skips if target folder year mismatches DB year
|
||||||
|
|
||||||
|
### Mocking aiohttp Sessions
|
||||||
|
|
||||||
|
When testing code that uses `aiohttp.ClientSession`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
|
||||||
|
# Mock aiohttp session for testing
|
||||||
|
class MockAiohttpSession:
|
||||||
|
def __init__(self):
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
def get(self, url, **kwargs):
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.json = AsyncMock(return_value={"data": "test"})
|
||||||
|
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
||||||
|
mock_response.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
return mock_response
|
||||||
|
|
||||||
|
# Use in fixture
|
||||||
|
@pytest.fixture
|
||||||
|
async def mock_tmdb_session():
|
||||||
|
session = MockAiohttpSession()
|
||||||
|
yield session
|
||||||
|
# Cleanup verification
|
||||||
|
assert session.closed, "Session was not closed"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key points:**
|
||||||
|
- Always verify `session.closed` is `True` after context manager exits
|
||||||
|
- Mock `__aenter__` and `__aexit__` for response context managers
|
||||||
|
- Set `closed = False` on mock session for unclosed warning tests
|
||||||
|
|
||||||
|
7. Coverage Requirements
|
||||||
|
8. CI/CD Integration
|
||||||
|
9. Writing Good Tests
|
||||||
|
- Arrange-Act-Assert pattern
|
||||||
|
- Test isolation
|
||||||
|
- Edge cases
|
||||||
|
10. Common Pitfalls to Avoid
|
||||||
@@ -90,7 +90,7 @@ The application now features a comprehensive configuration system that allows us
|
|||||||
- **Queue Organization**: Displays downloads organized by status (pending, active, completed, failed)
|
- **Queue Organization**: Displays downloads organized by status (pending, active, completed, failed)
|
||||||
- **NFO Integration**: Automatic NFO and media file creation before episode downloads
|
- **NFO Integration**: Automatic NFO and media file creation before episode downloads
|
||||||
- **Manual Start/Stop Control**: User manually starts downloads one at a time with Start/Stop buttons
|
- **Manual Start/Stop Control**: User manually starts downloads one at a time with Start/Stop buttons
|
||||||
- **FIFO Queue Processing**: First-in, first-out queue order (no priority or reordering)
|
- **Queue Processing Order**: Items processed in user-defined order via drag-and-drop reordering (`POST /api/queue/reorder`)
|
||||||
- **Single Download Mode**: Only one download active at a time, new downloads must be manually started
|
- **Single Download Mode**: Only one download active at a time, new downloads must be manually started
|
||||||
- **Download Status Display**: Real-time status updates and progress of current download
|
- **Download Status Display**: Real-time status updates and progress of current download
|
||||||
- **Queue Operations**: Add and remove items from the pending queue
|
- **Queue Operations**: Add and remove items from the pending queue
|
||||||
@@ -107,6 +107,10 @@ The application now features a comprehensive configuration system that allows us
|
|||||||
- **Progress Tracking**: Live progress updates for downloads and scans
|
- **Progress Tracking**: Live progress updates for downloads and scans
|
||||||
- **System Notifications**: Real-time system messages and alerts
|
- **System Notifications**: Real-time system messages and alerts
|
||||||
|
|
||||||
|
## Folder Management
|
||||||
|
|
||||||
|
- **Fuzzy Series Key Resolution**: Automatic series key resolution from folder names using fuzzy title matching — tolerates title variations like `(TV)`, `(OVA)`, `(Movie)` suffixes and uses similarity matching to resolve provider keys during library setup
|
||||||
|
|
||||||
## Core Functionality Overview
|
## Core Functionality Overview
|
||||||
|
|
||||||
The web application provides a complete interface for managing anime downloads with user-friendly pages for configuration, library management, search capabilities, and download monitoring. All operations are tracked in real-time with comprehensive progress reporting and error handling.
|
The web application provides a complete interface for managing anime downloads with user-friendly pages for configuration, library management, search capabilities, and download monitoring. All operations are tracked in real-time with comprehensive progress reporting and error handling.
|
||||||
8
Docs/key
Normal file
8
Docs/key
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
API key : 299ae8f630a31bda814263c551361448
|
||||||
|
9bc3e547caff878615cbdba2cc421d37
|
||||||
|
|
||||||
|
/setup
|
||||||
|
|
||||||
|
|
||||||
|
SeriesApp initialized for directory:
|
||||||
|
to remove:
|
||||||
@@ -24,7 +24,7 @@ Console.CancelKeyPress += (_, e) =>
|
|||||||
|
|
||||||
// ── Paths ─────────────────────────────────────────────────────────────────────
|
// ── Paths ─────────────────────────────────────────────────────────────────────
|
||||||
var repoRoot = Directory.GetCurrentDirectory();
|
var repoRoot = Directory.GetCurrentDirectory();
|
||||||
var tasksFile = Path.Combine(repoRoot, "Docs", "Tasks.md");
|
var tasksFile = Path.Combine(repoRoot, "Docs", "tasks.md");
|
||||||
|
|
||||||
if (!File.Exists(tasksFile))
|
if (!File.Exists(tasksFile))
|
||||||
{
|
{
|
||||||
@@ -102,7 +102,7 @@ for (int i = 0; i < items.Count; i++)
|
|||||||
|
|
||||||
// Step 1 — run the task prompt
|
// Step 1 — run the task prompt
|
||||||
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
|
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
|
||||||
await RunCopilot(new[] { "--continue" }, $"read ./Docs/instructions.md. {item}");
|
await RunCopilot(new[] { "--continue" }, $"{item}");
|
||||||
if (cts.IsCancellationRequested) break;
|
if (cts.IsCancellationRequested) break;
|
||||||
|
|
||||||
// Step 2 — confirm completion in the same chat session
|
// Step 2 — confirm completion in the same chat session
|
||||||
24
Makefile
Normal file
24
Makefile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
.PHONY: up down clean browser-clean setup test-robot
|
||||||
|
|
||||||
|
up:
|
||||||
|
python run_server.py
|
||||||
|
|
||||||
|
down:
|
||||||
|
pkill -f "uvicorn src.server.fastapi_app:app" || pkill -f "python.*run_server.py" || true
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf data/*.db data/*.db-shm data/*.db-wal data/config.json
|
||||||
|
|
||||||
|
browser-clean:
|
||||||
|
rm -rf "$$HOME/.cache/microsoft-edge"/* || true
|
||||||
|
rm -rf "$$HOME/.cache/mozilla/firefox"/* || true
|
||||||
|
find "$$HOME/.mozilla/firefox" -name "cache2" -type d -exec rm -rf {} \; 2>/dev/null || true
|
||||||
|
|
||||||
|
setup:
|
||||||
|
curl -X POST http://127.0.0.1:8000/setup \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-API-Key: 299ae8f630a31bda814263c551361448" \
|
||||||
|
-d '{"path": "/home/lukas/Volume/serien/", "password": "Hallo123!"}'
|
||||||
|
|
||||||
|
test-robot:
|
||||||
|
bash tests/robot/run.sh
|
||||||
BIN
browser/screenshot/fail-screenshot-1.png
Normal file
BIN
browser/screenshot/fail-screenshot-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -1,111 +0,0 @@
|
|||||||
# Migration Guide: File-Based to Database Storage
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This guide covers the transition from file-based series metadata storage to the new database-backed system introduced in v2.0.
|
|
||||||
|
|
||||||
## What Changed
|
|
||||||
|
|
||||||
**Before v2.0**: Series metadata stored in `key` and `data` files alongside anime folders.
|
|
||||||
|
|
||||||
**After v2.0**: All metadata stored in SQLite database (`aniworld.db`). Files are deprecated but still supported for backward compatibility during migration.
|
|
||||||
|
|
||||||
## Automated Migration
|
|
||||||
|
|
||||||
The application automatically migrates on first startup:
|
|
||||||
|
|
||||||
1. Scans anime directory for `key` and `data` files
|
|
||||||
2. Parses legacy files into `AnimeSeries` and `Episode` records
|
|
||||||
3. Loads series into in-memory cache
|
|
||||||
4. Logs migration results
|
|
||||||
|
|
||||||
**No manual action required.**
|
|
||||||
|
|
||||||
## Manual Verification
|
|
||||||
|
|
||||||
After first startup with the new version:
|
|
||||||
|
|
||||||
1. **Check logs** for: `"Migrated X series from files to DB"`
|
|
||||||
2. **Verify series count**: UI shows same number of series as before
|
|
||||||
3. **Confirm episodes**: Episode counts match expected totals
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check migration log
|
|
||||||
grep "Migrated" logs/app.log
|
|
||||||
|
|
||||||
# Verify series via API
|
|
||||||
curl http://localhost:8000/api/anime | jq '.total'
|
|
||||||
```
|
|
||||||
|
|
||||||
## After Migration
|
|
||||||
|
|
||||||
### Safe to Delete
|
|
||||||
|
|
||||||
Once verified, these files can be removed:
|
|
||||||
|
|
||||||
```
|
|
||||||
<anime_folder>/
|
|
||||||
├── Attack on Titan (2013)/
|
|
||||||
│ ├── key # ❌ Can delete
|
|
||||||
│ ├── data # ❌ Can delete
|
|
||||||
│ └── Season 1/
|
|
||||||
│ └── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Deleting these files does not affect the database.** The metadata now lives in `aniworld.db`.
|
|
||||||
|
|
||||||
### Backup (Recommended)
|
|
||||||
|
|
||||||
Before deleting, backup the files:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create backup directory
|
|
||||||
mkdir -p backup/legacy_series_files
|
|
||||||
|
|
||||||
# Copy all key and data files
|
|
||||||
find /path/to/anime -name "key" -o -name "data" | while read f; do
|
|
||||||
cp "$f" "backup/legacy_series_files/"
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
## Reverting (Not Recommended)
|
|
||||||
|
|
||||||
If you must revert to file-based storage:
|
|
||||||
|
|
||||||
1. **Restore from database backup** (if available)
|
|
||||||
2. **Export manually** (no export script exists)
|
|
||||||
|
|
||||||
**Warning**: File-based storage is deprecated and will be removed in v3.0.0.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Series Not Appearing After Migration
|
|
||||||
|
|
||||||
1. Check logs for migration errors: `grep -i error logs/app.log`
|
|
||||||
2. Verify `key` and `data` files exist and are readable
|
|
||||||
3. Manually trigger rescan: `POST /api/scheduler/trigger-rescan`
|
|
||||||
|
|
||||||
### Duplicate Series
|
|
||||||
|
|
||||||
1. Check for duplicate `key` files (same series in multiple folders)
|
|
||||||
2. Verify series key uniqueness in database:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sqlite3 aniworld.db "SELECT key, COUNT(*) FROM anime_series GROUP BY key HAVING COUNT(*) > 1;"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Missing Episodes
|
|
||||||
|
|
||||||
1. Trigger targeted scan for affected series
|
|
||||||
2. Check episode sync logs
|
|
||||||
3. Verify file permissions on anime directory
|
|
||||||
|
|
||||||
## Deprecation Timeline
|
|
||||||
|
|
||||||
| Version | Status |
|
|
||||||
|---------|--------|
|
|
||||||
| v2.0.x | Legacy files supported, migration automated |
|
|
||||||
| v2.1.x | Legacy files still supported, warnings in logs |
|
|
||||||
| v3.0.0 | **Legacy files removed** - database only |
|
|
||||||
|
|
||||||
Upgrade to v3.0.0 before legacy file support ends.
|
|
||||||
155
docs/TESTING.md
155
docs/TESTING.md
@@ -1,155 +0,0 @@
|
|||||||
# Testing Documentation
|
|
||||||
|
|
||||||
## Document Purpose
|
|
||||||
|
|
||||||
This document describes the testing strategy, guidelines, and practices for the Aniworld project.
|
|
||||||
|
|
||||||
### What This Document Contains
|
|
||||||
|
|
||||||
- **Testing Strategy**: Overall approach to quality assurance
|
|
||||||
- **Test Categories**: Unit, integration, API, performance, security tests
|
|
||||||
- **Test Structure**: Organization of test files and directories
|
|
||||||
- **Writing Tests**: Guidelines for writing effective tests
|
|
||||||
- **Fixtures and Mocking**: Shared test utilities and mock patterns
|
|
||||||
- **Running Tests**: Commands and configurations
|
|
||||||
- **Coverage Requirements**: Minimum coverage thresholds
|
|
||||||
- **CI/CD Integration**: How tests run in automation
|
|
||||||
- **Test Data Management**: Managing test fixtures and data
|
|
||||||
- **Best Practices**: Do's and don'ts for testing
|
|
||||||
|
|
||||||
### What This Document Does NOT Contain
|
|
||||||
|
|
||||||
- Production deployment (see [DEPLOYMENT.md](DEPLOYMENT.md))
|
|
||||||
- Security audit procedures (see [SECURITY.md](SECURITY.md))
|
|
||||||
- Bug tracking and issue management
|
|
||||||
- Performance benchmarking results
|
|
||||||
|
|
||||||
### Target Audience
|
|
||||||
|
|
||||||
- Developers writing tests
|
|
||||||
- QA Engineers
|
|
||||||
- CI/CD Engineers
|
|
||||||
- Code reviewers
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sections to Document
|
|
||||||
|
|
||||||
1. Testing Philosophy
|
|
||||||
- Test pyramid approach
|
|
||||||
- Quality gates
|
|
||||||
2. Test Categories
|
|
||||||
- Unit Tests (`tests/unit/`)
|
|
||||||
- Integration Tests (`tests/integration/`)
|
|
||||||
- API Tests (`tests/api/`)
|
|
||||||
- Frontend Tests (`tests/frontend/`)
|
|
||||||
- Performance Tests (`tests/performance/`)
|
|
||||||
- Security Tests (`tests/security/`)
|
|
||||||
3. Test Structure and Naming
|
|
||||||
- File naming conventions
|
|
||||||
- Test function naming
|
|
||||||
- Test class organization
|
|
||||||
4. Running Tests
|
|
||||||
- pytest commands
|
|
||||||
- Running specific tests
|
|
||||||
- Verbose output
|
|
||||||
- Coverage reports
|
|
||||||
5. Fixtures and Conftest
|
|
||||||
- Shared fixtures
|
|
||||||
- Database fixtures
|
|
||||||
- Mock services
|
|
||||||
6. Mocking Guidelines
|
|
||||||
- What to mock
|
|
||||||
- Mock patterns
|
|
||||||
- External service mocks
|
|
||||||
|
|
||||||
### Mocking the Download Queue
|
|
||||||
|
|
||||||
Use `MockQueueRepository` for testing download queue functionality:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from src.server.models.download import DownloadItem, EpisodeIdentifier
|
|
||||||
|
|
||||||
class MockQueueRepository:
|
|
||||||
def __init__(self):
|
|
||||||
self._items: Dict[str, DownloadItem] = {}
|
|
||||||
|
|
||||||
async def save_item(self, item: DownloadItem) -> DownloadItem:
|
|
||||||
self._items[item.id] = item
|
|
||||||
return item
|
|
||||||
|
|
||||||
async def get_item(self, item_id: str) -> Optional[DownloadItem]:
|
|
||||||
return self._items.get(item_id)
|
|
||||||
|
|
||||||
async def get_all_items(self) -> List[DownloadItem]:
|
|
||||||
return list(self._items.values())
|
|
||||||
|
|
||||||
async def set_error(self, item_id: str, error: str) -> bool:
|
|
||||||
if item_id in self._items:
|
|
||||||
self._items[item_id].error = error
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def delete_item(self, item_id: str) -> bool:
|
|
||||||
if item_id in self._items:
|
|
||||||
del self._items[item_id]
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def clear_all(self) -> int:
|
|
||||||
count = len(self._items)
|
|
||||||
self._items.clear()
|
|
||||||
return count
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key points:**
|
|
||||||
- The mock uses in-memory storage, no database required
|
|
||||||
- All async methods are implemented (even if just pass-through)
|
|
||||||
- `save_item` uses `item.id` as key (must be set before calling)
|
|
||||||
- Suitable for unit tests only (no persistence)
|
|
||||||
|
|
||||||
### Mocking aiohttp Sessions
|
|
||||||
|
|
||||||
When testing code that uses `aiohttp.ClientSession`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
from aiohttp import ClientSession
|
|
||||||
|
|
||||||
# Mock aiohttp session for testing
|
|
||||||
class MockAiohttpSession:
|
|
||||||
def __init__(self):
|
|
||||||
self.closed = False
|
|
||||||
|
|
||||||
async def close(self):
|
|
||||||
self.closed = True
|
|
||||||
|
|
||||||
def get(self, url, **kwargs):
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status = 200
|
|
||||||
mock_response.json = AsyncMock(return_value={"data": "test"})
|
|
||||||
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
|
||||||
mock_response.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
return mock_response
|
|
||||||
|
|
||||||
# Use in fixture
|
|
||||||
@pytest.fixture
|
|
||||||
async def mock_tmdb_session():
|
|
||||||
session = MockAiohttpSession()
|
|
||||||
yield session
|
|
||||||
# Cleanup verification
|
|
||||||
assert session.closed, "Session was not closed"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key points:**
|
|
||||||
- Always verify `session.closed` is `True` after context manager exits
|
|
||||||
- Mock `__aenter__` and `__aexit__` for response context managers
|
|
||||||
- Set `closed = False` on mock session for unclosed warning tests
|
|
||||||
|
|
||||||
7. Coverage Requirements
|
|
||||||
8. CI/CD Integration
|
|
||||||
9. Writing Good Tests
|
|
||||||
- Arrange-Act-Assert pattern
|
|
||||||
- Test isolation
|
|
||||||
- Edge cases
|
|
||||||
10. Common Pitfalls to Avoid
|
|
||||||
51
docs/key
51
docs/key
@@ -1,51 +0,0 @@
|
|||||||
API key : 299ae8f630a31bda814263c551361448
|
|
||||||
|
|
||||||
/mnt/server/serien/Serien/
|
|
||||||
|
|
||||||
{
|
|
||||||
"name": "Aniworld",
|
|
||||||
"data_dir": "data",
|
|
||||||
"scheduler": {
|
|
||||||
"enabled": true,
|
|
||||||
"interval_minutes": 60,
|
|
||||||
"schedule_time": "03:00",
|
|
||||||
"schedule_days": [
|
|
||||||
"mon",
|
|
||||||
"tue",
|
|
||||||
"wed",
|
|
||||||
"thu",
|
|
||||||
"fri",
|
|
||||||
"sat",
|
|
||||||
"sun"
|
|
||||||
],
|
|
||||||
"auto_download_after_rescan": true,
|
|
||||||
"folder_scan_enabled": true
|
|
||||||
},
|
|
||||||
"logging": {
|
|
||||||
"level": "INFO",
|
|
||||||
"file": null,
|
|
||||||
"max_bytes": null,
|
|
||||||
"backup_count": 3
|
|
||||||
},
|
|
||||||
"backup": {
|
|
||||||
"enabled": false,
|
|
||||||
"path": "data/backups",
|
|
||||||
"keep_days": 30
|
|
||||||
},
|
|
||||||
"nfo": {
|
|
||||||
"tmdb_api_key": "9bc3e547caff878615cbdba2cc421d37",
|
|
||||||
"auto_create": true,
|
|
||||||
"update_on_scan": true,
|
|
||||||
"download_poster": true,
|
|
||||||
"download_logo": true,
|
|
||||||
"download_fanart": true,
|
|
||||||
"image_size": "original"
|
|
||||||
},
|
|
||||||
"other": {
|
|
||||||
"master_password_hash": "$pbkdf2-sha256$29000$HQNASKk1xpgTAgAgJGRMaQ$73TOCCM0UEZONyNXQEPa3SmIoXeG6C1l5mMFDNgYfMQ",
|
|
||||||
"anime_directory": "/data"
|
|
||||||
},
|
|
||||||
"version": "1.0.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
178
docs/tasks.md
178
docs/tasks.md
@@ -1,178 +0,0 @@
|
|||||||
# Tasks
|
|
||||||
|
|
||||||
## 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.2: Create FolderScanService skeleton
|
|
||||||
|
|
||||||
**Where is that found**
|
|
||||||
- New file: `src/server/services/folder_scan_service.py`
|
|
||||||
- `src/server/services/scheduler_service.py` (to call it)
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
Keep the implementation empty for the sub-tasks (1.3–1.5) to fill in. Just add the skeleton and the semaphore.
|
|
||||||
|
|
||||||
**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 changes needed**
|
|
||||||
- `docs/ARCHITECTURE.md`: Add `folder_scan_service.py` to the services list.
|
|
||||||
|
|
||||||
**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 1.3: Integrate NFO repair into folder scan
|
|
||||||
|
|
||||||
**Where is that found**
|
|
||||||
- `src/server/services/folder_scan_service.py`
|
|
||||||
- `src/server/services/initialization_service.py` (`perform_nfo_repair_scan`)
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
**Why this is needed**
|
|
||||||
Reuses the existing, tested NFO repair logic. Moves NFO repair from startup blocking to scheduled background maintenance.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1.4: Validate and rename series folders
|
|
||||||
|
|
||||||
**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)
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
**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 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.
|
|
||||||
|
|
||||||
**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 1.5: Check and download missing poster.jpg
|
|
||||||
|
|
||||||
**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)
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
**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 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.
|
|
||||||
|
|
||||||
**Why this is needed**
|
|
||||||
Ensures every series has artwork, which is required by most media center front-ends for a polished library view.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
2644
package-lock.json
generated
Normal file
2644
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aniworld-web",
|
"name": "aniworld-web",
|
||||||
"version": "1.3.4",
|
"version": "1.5.2",
|
||||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"@playwright/test": "^1.41.0",
|
"@playwright/test": "^1.41.0",
|
||||||
"@vitest/coverage-v8": "^1.2.0",
|
"@vitest/coverage-v8": "^1.2.0",
|
||||||
"@vitest/ui": "^1.2.0",
|
"@vitest/ui": "^1.2.0",
|
||||||
"happy-dom": "^13.3.5",
|
"happy-dom": "^13.10.1",
|
||||||
"vitest": "^1.2.0"
|
"vitest": "^1.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -25,4 +25,10 @@ beautifulsoup4>=4.12.0
|
|||||||
chardet>=5.2.0
|
chardet>=5.2.0
|
||||||
fake-useragent>=1.4.0
|
fake-useragent>=1.4.0
|
||||||
yt-dlp>=2024.1.0
|
yt-dlp>=2024.1.0
|
||||||
urllib3>=2.0.0
|
urllib3>=2.0.0
|
||||||
|
|
||||||
|
# Robot Framework testing dependencies
|
||||||
|
robotframework>=7.0
|
||||||
|
robotframework-browser>=18.0
|
||||||
|
robotframework-requests>=0.9
|
||||||
|
robotframework-jsonlibrary>=0.5
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
"""CLI command for NFO management.
|
"""CLI command for NFO management.
|
||||||
|
|
||||||
This script provides command-line interface for creating, updating,
|
Note: NFO service has been removed. This CLI is no longer functional.
|
||||||
and checking NFO metadata files.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -12,9 +10,6 @@ from pathlib import Path
|
|||||||
# Add src to path
|
# Add src to path
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
from src.config.settings import settings
|
|
||||||
from src.core.services.series_manager_service import SeriesManagerService
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -125,7 +120,7 @@ async def check_nfo_status():
|
|||||||
logger.info("Anime Directory: %s", settings.anime_directory)
|
logger.info("Anime Directory: %s", settings.anime_directory)
|
||||||
|
|
||||||
# Create series list (no NFO service needed for status check)
|
# Create series list (no NFO service needed for status check)
|
||||||
from src.core.entities.SerieList import SerieList
|
from src.server.database.SerieList import SerieList
|
||||||
serie_list = SerieList(settings.anime_directory)
|
serie_list = SerieList(settings.anime_directory)
|
||||||
all_series = serie_list.get_all()
|
all_series = serie_list.get_all()
|
||||||
|
|
||||||
@@ -179,91 +174,6 @@ async def check_nfo_status():
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
async def update_nfo_files():
|
|
||||||
"""Update existing NFO files with fresh data from TMDB."""
|
|
||||||
logger.info("%s", "=" * 70)
|
|
||||||
logger.info("NFO Update Tool")
|
|
||||||
logger.info("%s", "=" * 70)
|
|
||||||
|
|
||||||
if not settings.tmdb_api_key:
|
|
||||||
logger.error("TMDB_API_KEY not configured")
|
|
||||||
logger.error("Set TMDB_API_KEY in .env file or environment")
|
|
||||||
logger.error("Get API key from: https://www.themoviedb.org/settings/api")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if not settings.anime_directory:
|
|
||||||
logger.error("ANIME_DIRECTORY not configured")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
logger.info("Anime Directory: %s", settings.anime_directory)
|
|
||||||
logger.info(
|
|
||||||
"Download media: %s",
|
|
||||||
settings.nfo_download_poster or settings.nfo_download_logo or settings.nfo_download_fanart,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get series with NFO
|
|
||||||
from src.core.entities.SerieList import SerieList
|
|
||||||
serie_list = SerieList(settings.anime_directory)
|
|
||||||
all_series = serie_list.get_all()
|
|
||||||
series_with_nfo = [s for s in all_series if s.has_nfo()]
|
|
||||||
|
|
||||||
if not series_with_nfo:
|
|
||||||
logger.warning("No series with NFO files found")
|
|
||||||
logger.info("Run 'scan' command first to create NFO files")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
logger.info("Found %d series with NFO files", len(series_with_nfo))
|
|
||||||
logger.info("Updating NFO files with fresh data from TMDB...")
|
|
||||||
logger.info("This may take a while")
|
|
||||||
|
|
||||||
# Initialize NFO service using factory
|
|
||||||
from src.core.services.nfo_factory import create_nfo_service
|
|
||||||
try:
|
|
||||||
nfo_service = create_nfo_service()
|
|
||||||
except ValueError as e:
|
|
||||||
logger.error("Error creating NFO service: %s", e)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
success_count = 0
|
|
||||||
error_count = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
for i, serie in enumerate(series_with_nfo, 1):
|
|
||||||
logger.info("[%d/%d] Updating: %s", i, len(series_with_nfo), serie.name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await nfo_service.update_tvshow_nfo(
|
|
||||||
serie_folder=serie.folder,
|
|
||||||
download_media=(
|
|
||||||
settings.nfo_download_poster or
|
|
||||||
settings.nfo_download_logo or
|
|
||||||
settings.nfo_download_fanart
|
|
||||||
),
|
|
||||||
)
|
|
||||||
logger.info("Updated successfully: %s", serie.name)
|
|
||||||
success_count += 1
|
|
||||||
|
|
||||||
# Small delay to respect API rate limits
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Failed to update NFO for %s", serie.name)
|
|
||||||
error_count += 1
|
|
||||||
|
|
||||||
logger.info("%s", "=" * 70)
|
|
||||||
logger.info("Update complete")
|
|
||||||
logger.info("Success: %d", success_count)
|
|
||||||
logger.info("Errors: %d", error_count)
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Fatal error during NFO update")
|
|
||||||
return 1
|
|
||||||
finally:
|
|
||||||
await nfo_service.close()
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Main CLI entry point."""
|
"""Main CLI entry point."""
|
||||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
@@ -273,7 +183,6 @@ def main():
|
|||||||
logger.info("\nUsage:")
|
logger.info("\nUsage:")
|
||||||
logger.info(" python -m src.cli.nfo_cli scan # Scan and create missing NFO files")
|
logger.info(" python -m src.cli.nfo_cli scan # Scan and create missing NFO files")
|
||||||
logger.info(" python -m src.cli.nfo_cli status # Check NFO status for all series")
|
logger.info(" python -m src.cli.nfo_cli status # Check NFO status for all series")
|
||||||
logger.info(" python -m src.cli.nfo_cli update # Update existing NFO files with fresh data")
|
|
||||||
logger.info("\nConfiguration:")
|
logger.info("\nConfiguration:")
|
||||||
logger.info(" Set TMDB_API_KEY in .env file")
|
logger.info(" Set TMDB_API_KEY in .env file")
|
||||||
logger.info(" Set NFO_AUTO_CREATE=true to enable auto-creation")
|
logger.info(" Set NFO_AUTO_CREATE=true to enable auto-creation")
|
||||||
@@ -286,11 +195,9 @@ def main():
|
|||||||
return asyncio.run(scan_and_create_nfo())
|
return asyncio.run(scan_and_create_nfo())
|
||||||
elif command == "status":
|
elif command == "status":
|
||||||
return asyncio.run(check_nfo_status())
|
return asyncio.run(check_nfo_status())
|
||||||
elif command == "update":
|
|
||||||
return asyncio.run(update_nfo_files())
|
|
||||||
else:
|
else:
|
||||||
logger.error("Unknown command: %s", command)
|
logger.error("Unknown command: %s", command)
|
||||||
logger.info("Use 'scan', 'status', or 'update'")
|
logger.info("Use 'scan' or 'status'")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,531 +0,0 @@
|
|||||||
"""Utilities for loading and managing stored anime series metadata.
|
|
||||||
|
|
||||||
This module provides the SerieList class for managing collections of anime
|
|
||||||
series metadata. It supports loading from both filesystem (legacy) and
|
|
||||||
database (primary).
|
|
||||||
|
|
||||||
Note:
|
|
||||||
This module is part of the core domain layer. Database operations
|
|
||||||
are handled by the service layer via add_to_db().
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import warnings
|
|
||||||
from json import JSONDecodeError
|
|
||||||
from typing import Dict, Iterable, List, Optional
|
|
||||||
|
|
||||||
from src.config.settings import settings
|
|
||||||
from src.core.entities.series import Serie
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SerieList:
|
|
||||||
"""
|
|
||||||
Represents the collection of cached series stored on disk.
|
|
||||||
|
|
||||||
Series are identified by their unique 'key' (provider identifier).
|
|
||||||
The 'folder' is metadata only and not used for lookups.
|
|
||||||
|
|
||||||
This class manages in-memory series data loaded from filesystem.
|
|
||||||
It has no database dependencies - all persistence is handled by
|
|
||||||
the service layer.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
# File-based mode
|
|
||||||
serie_list = SerieList("/path/to/anime")
|
|
||||||
series = serie_list.get_all()
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
directory: Path to the anime directory
|
|
||||||
keyDict: Internal dictionary mapping serie.key to Serie objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
base_path: str,
|
|
||||||
skip_load: bool = False
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the SerieList.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_path: Path to the anime directory
|
|
||||||
skip_load: If True, skip automatic loading of series from files.
|
|
||||||
Useful when planning to load from database instead.
|
|
||||||
"""
|
|
||||||
self.directory: str = base_path
|
|
||||||
# Internal storage using serie.key as the dictionary key
|
|
||||||
self.keyDict: Dict[str, Serie] = {}
|
|
||||||
|
|
||||||
# Only auto-load from files if not skipping
|
|
||||||
if not skip_load:
|
|
||||||
self.load_series()
|
|
||||||
|
|
||||||
def add(self, serie: Serie, use_sanitized_folder: bool = True) -> str:
|
|
||||||
"""
|
|
||||||
Persist a new series if it is not already present (file-based mode).
|
|
||||||
|
|
||||||
Uses serie.key for identification. Creates the filesystem folder
|
|
||||||
using either the sanitized display name (default) or the existing
|
|
||||||
folder property.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
serie: The Serie instance to add
|
|
||||||
use_sanitized_folder: If True (default), use serie.sanitized_folder
|
|
||||||
for the filesystem folder name based on display name.
|
|
||||||
If False, use serie.folder as-is for backward compatibility.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The folder path that was created/used
|
|
||||||
|
|
||||||
Note:
|
|
||||||
This method creates data files on disk. For database storage,
|
|
||||||
use add_to_db() instead.
|
|
||||||
"""
|
|
||||||
if self.contains(serie.key):
|
|
||||||
# Return existing folder path
|
|
||||||
existing = self.keyDict[serie.key]
|
|
||||||
return os.path.join(self.directory, existing.folder)
|
|
||||||
|
|
||||||
# Determine folder name to use
|
|
||||||
if use_sanitized_folder:
|
|
||||||
folder_name = serie.sanitized_folder
|
|
||||||
# Update the serie's folder property to match what we create
|
|
||||||
serie.folder = folder_name
|
|
||||||
else:
|
|
||||||
folder_name = serie.folder
|
|
||||||
|
|
||||||
data_path = os.path.join(self.directory, folder_name, "data")
|
|
||||||
anime_path = os.path.join(self.directory, folder_name)
|
|
||||||
os.makedirs(anime_path, exist_ok=True)
|
|
||||||
if not os.path.isfile(data_path):
|
|
||||||
serie.save_to_file(data_path)
|
|
||||||
# Store by key, not folder
|
|
||||||
self.keyDict[serie.key] = serie
|
|
||||||
|
|
||||||
return anime_path
|
|
||||||
|
|
||||||
async def add_to_db(self, serie: Serie) -> bool:
|
|
||||||
"""Persist a new series to the database.
|
|
||||||
|
|
||||||
Creates the filesystem folder using serie.folder, then persists
|
|
||||||
the series metadata to the database.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
serie: The Serie instance to add
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from src.server.database.connection import get_async_session_factory
|
|
||||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
|
||||||
|
|
||||||
folder_name = serie.folder
|
|
||||||
anime_path = os.path.join(self.directory, folder_name)
|
|
||||||
os.makedirs(anime_path, exist_ok=True)
|
|
||||||
|
|
||||||
session_factory = get_async_session_factory()
|
|
||||||
db = session_factory()
|
|
||||||
try:
|
|
||||||
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
|
||||||
if existing:
|
|
||||||
logger.debug(
|
|
||||||
"Series '%s' (key=%s) already exists in DB, skipping",
|
|
||||||
serie.name, serie.key
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
anime_series = await AnimeSeriesService.create(
|
|
||||||
db=db,
|
|
||||||
key=serie.key,
|
|
||||||
name=serie.name,
|
|
||||||
site=serie.site,
|
|
||||||
folder=folder_name,
|
|
||||||
year=serie.year
|
|
||||||
)
|
|
||||||
for season, eps in serie.episodeDict.items():
|
|
||||||
for ep in eps:
|
|
||||||
await EpisodeService.create(
|
|
||||||
db=db,
|
|
||||||
series_id=anime_series.id,
|
|
||||||
season=season,
|
|
||||||
episode_number=ep
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
self.keyDict[serie.key] = serie
|
|
||||||
logger.info(
|
|
||||||
"Persisted series '%s' to database",
|
|
||||||
serie.name
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
await db.rollback()
|
|
||||||
logger.error(
|
|
||||||
"Failed to persist series '%s' to DB: %s",
|
|
||||||
serie.key, e, exc_info=True
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
await db.close()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Could not add series '%s' to DB (DB unavailable?): %s",
|
|
||||||
serie.key, e
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def contains(self, key: str) -> bool:
|
|
||||||
"""
|
|
||||||
Return True when a series identified by ``key`` already exists.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: The unique provider identifier for the series
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if the series exists in the collection
|
|
||||||
"""
|
|
||||||
return key in self.keyDict
|
|
||||||
|
|
||||||
def load_series(self) -> None:
|
|
||||||
"""Populate the in-memory map with metadata discovered on disk."""
|
|
||||||
|
|
||||||
logger.info("Scanning anime folders in %s", self.directory)
|
|
||||||
try:
|
|
||||||
entries: Iterable[str] = os.listdir(self.directory)
|
|
||||||
except OSError as error:
|
|
||||||
logger.error(
|
|
||||||
"Unable to scan directory %s: %s",
|
|
||||||
self.directory,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
nfo_stats = {"total": 0, "with_nfo": 0, "without_nfo": 0}
|
|
||||||
media_stats = {
|
|
||||||
"with_poster": 0,
|
|
||||||
"without_poster": 0,
|
|
||||||
"with_logo": 0,
|
|
||||||
"without_logo": 0,
|
|
||||||
"with_fanart": 0,
|
|
||||||
"without_fanart": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
for anime_folder in entries:
|
|
||||||
if settings.should_ignore_folder(anime_folder):
|
|
||||||
logger.debug("Skipping ignored folder: %s", anime_folder)
|
|
||||||
continue
|
|
||||||
anime_path = os.path.join(self.directory, anime_folder, "data")
|
|
||||||
if os.path.isfile(anime_path):
|
|
||||||
logger.debug("Found data file for folder %s", anime_folder)
|
|
||||||
serie = self._load_data(anime_folder, anime_path)
|
|
||||||
|
|
||||||
if serie:
|
|
||||||
nfo_stats["total"] += 1
|
|
||||||
# Check for NFO file
|
|
||||||
nfo_file_path = os.path.join(
|
|
||||||
self.directory, anime_folder, "tvshow.nfo"
|
|
||||||
)
|
|
||||||
if os.path.isfile(nfo_file_path):
|
|
||||||
serie.nfo_path = nfo_file_path
|
|
||||||
nfo_stats["with_nfo"] += 1
|
|
||||||
else:
|
|
||||||
nfo_stats["without_nfo"] += 1
|
|
||||||
logger.debug(
|
|
||||||
"Series '%s' (key: %s) is missing tvshow.nfo",
|
|
||||||
serie.name,
|
|
||||||
serie.key
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for media files
|
|
||||||
folder_path = os.path.join(self.directory, anime_folder)
|
|
||||||
|
|
||||||
poster_path = os.path.join(folder_path, "poster.jpg")
|
|
||||||
if os.path.isfile(poster_path):
|
|
||||||
media_stats["with_poster"] += 1
|
|
||||||
else:
|
|
||||||
media_stats["without_poster"] += 1
|
|
||||||
logger.debug(
|
|
||||||
"Series '%s' (key: %s) is missing poster.jpg",
|
|
||||||
serie.name,
|
|
||||||
serie.key
|
|
||||||
)
|
|
||||||
|
|
||||||
logo_path = os.path.join(folder_path, "logo.png")
|
|
||||||
if os.path.isfile(logo_path):
|
|
||||||
media_stats["with_logo"] += 1
|
|
||||||
else:
|
|
||||||
media_stats["without_logo"] += 1
|
|
||||||
logger.debug(
|
|
||||||
"Series '%s' (key: %s) is missing logo.png",
|
|
||||||
serie.name,
|
|
||||||
serie.key
|
|
||||||
)
|
|
||||||
|
|
||||||
fanart_path = os.path.join(folder_path, "fanart.jpg")
|
|
||||||
if os.path.isfile(fanart_path):
|
|
||||||
media_stats["with_fanart"] += 1
|
|
||||||
else:
|
|
||||||
media_stats["without_fanart"] += 1
|
|
||||||
logger.debug(
|
|
||||||
"Series '%s' (key: %s) is missing fanart.jpg",
|
|
||||||
serie.name,
|
|
||||||
serie.key
|
|
||||||
)
|
|
||||||
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.warning(
|
|
||||||
"Skipping folder %s because no metadata file was found",
|
|
||||||
anime_folder,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log summary statistics
|
|
||||||
if nfo_stats["total"] > 0:
|
|
||||||
logger.info(
|
|
||||||
"NFO scan complete: %d series total, %d with NFO, %d without NFO",
|
|
||||||
nfo_stats["total"],
|
|
||||||
nfo_stats["with_nfo"],
|
|
||||||
nfo_stats["without_nfo"]
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Media scan complete: Poster (%d/%d), Logo (%d/%d), Fanart (%d/%d)",
|
|
||||||
media_stats["with_poster"],
|
|
||||||
nfo_stats["total"],
|
|
||||||
media_stats["with_logo"],
|
|
||||||
nfo_stats["total"],
|
|
||||||
media_stats["with_fanart"],
|
|
||||||
nfo_stats["total"]
|
|
||||||
)
|
|
||||||
|
|
||||||
def _load_data(self, anime_folder: str, data_path: str) -> Optional[Serie]:
|
|
||||||
"""
|
|
||||||
Load a single series metadata file into the in-memory collection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
anime_folder: The folder name (for logging only)
|
|
||||||
data_path: Path to the metadata file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Serie: The loaded Serie object, or None if loading failed
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
serie = Serie.load_from_file(data_path)
|
|
||||||
# Store by key, not folder
|
|
||||||
self.keyDict[serie.key] = serie
|
|
||||||
logger.debug(
|
|
||||||
"Successfully loaded metadata for %s (key: %s)",
|
|
||||||
anime_folder,
|
|
||||||
serie.key
|
|
||||||
)
|
|
||||||
return serie
|
|
||||||
except (OSError, JSONDecodeError, KeyError, ValueError) as error:
|
|
||||||
logger.error(
|
|
||||||
"Failed to load metadata for folder %s from %s: %s",
|
|
||||||
anime_folder,
|
|
||||||
data_path,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def GetMissingEpisode(self) -> List[Serie]:
|
|
||||||
"""Return all series that still contain missing episodes."""
|
|
||||||
return [
|
|
||||||
serie
|
|
||||||
for serie in self.keyDict.values()
|
|
||||||
if serie.episodeDict
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_missing_episodes(self) -> List[Serie]:
|
|
||||||
"""PEP8-friendly alias for :meth:`GetMissingEpisode`."""
|
|
||||||
return self.GetMissingEpisode()
|
|
||||||
|
|
||||||
def GetList(self) -> List[Serie]:
|
|
||||||
"""Return all series instances stored in the list."""
|
|
||||||
return list(self.keyDict.values())
|
|
||||||
|
|
||||||
def get_all(self) -> List[Serie]:
|
|
||||||
"""PEP8-friendly alias for :meth:`GetList`."""
|
|
||||||
return self.GetList()
|
|
||||||
|
|
||||||
def get_by_key(self, key: str) -> Optional[Serie]:
|
|
||||||
"""
|
|
||||||
Get a series by its unique provider key.
|
|
||||||
|
|
||||||
This is the primary method for series lookup.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: The unique provider identifier (e.g., "attack-on-titan")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The Serie instance if found, None otherwise
|
|
||||||
"""
|
|
||||||
return self.keyDict.get(key)
|
|
||||||
|
|
||||||
def get_by_folder(self, folder: str) -> Optional[Serie]:
|
|
||||||
"""
|
|
||||||
Get a series by its folder name.
|
|
||||||
|
|
||||||
.. deprecated:: 2.0.0
|
|
||||||
Use :meth:`get_by_key` instead. Folder-based lookups will be
|
|
||||||
removed in version 3.0.0. The `folder` field is metadata only
|
|
||||||
and should not be used for identification.
|
|
||||||
|
|
||||||
This method is provided for backward compatibility only.
|
|
||||||
Prefer using get_by_key() for new code.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
folder: The filesystem folder name (e.g., "Attack on Titan (2013)")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The Serie instance if found, None otherwise
|
|
||||||
"""
|
|
||||||
warnings.warn(
|
|
||||||
"get_by_folder() is deprecated and will be removed in v3.0.0. "
|
|
||||||
"Use get_by_key() instead. The 'folder' field is metadata only.",
|
|
||||||
DeprecationWarning,
|
|
||||||
stacklevel=2
|
|
||||||
)
|
|
||||||
for serie in self.keyDict.values():
|
|
||||||
if serie.folder == folder:
|
|
||||||
return serie
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def load_all_from_db(self) -> int:
|
|
||||||
"""Load all series from database into in-memory cache.
|
|
||||||
|
|
||||||
Retrieves all anime series from the database with their episodes
|
|
||||||
and populates the in-memory keyDict for fast access.
|
|
||||||
|
|
||||||
This method replaces file-based loading. Use after initialization
|
|
||||||
when database is ready.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: Number of series loaded into cache
|
|
||||||
"""
|
|
||||||
from src.server.database.connection import get_async_session_factory
|
|
||||||
from src.server.database.service import AnimeSeriesService
|
|
||||||
|
|
||||||
try:
|
|
||||||
session_factory = get_async_session_factory()
|
|
||||||
db = session_factory()
|
|
||||||
try:
|
|
||||||
anime_series_list = await AnimeSeriesService.get_all(
|
|
||||||
db, with_episodes=True
|
|
||||||
)
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
for anime_series in anime_series_list:
|
|
||||||
episode_dict: Dict[int, List[int]] = {}
|
|
||||||
if anime_series.episodes:
|
|
||||||
for ep in anime_series.episodes:
|
|
||||||
if ep.season not in episode_dict:
|
|
||||||
episode_dict[ep.season] = []
|
|
||||||
episode_dict[ep.season].append(ep.episode_number)
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key=anime_series.key,
|
|
||||||
name=anime_series.name,
|
|
||||||
site=anime_series.site,
|
|
||||||
folder=anime_series.folder,
|
|
||||||
episodeDict=episode_dict,
|
|
||||||
year=anime_series.year
|
|
||||||
)
|
|
||||||
self.keyDict[serie.key] = serie
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Loaded %d series from database into in-memory cache",
|
|
||||||
count
|
|
||||||
)
|
|
||||||
return count
|
|
||||||
finally:
|
|
||||||
await db.close()
|
|
||||||
except RuntimeError:
|
|
||||||
logger.warning(
|
|
||||||
"Database not available, skipping DB load"
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
async def _load_single_series_from_db(
|
|
||||||
self,
|
|
||||||
anime_folder: str
|
|
||||||
) -> Optional[Serie]:
|
|
||||||
"""Load a single series from database by folder name.
|
|
||||||
|
|
||||||
Looks up a series in the database by its folder name and adds
|
|
||||||
it to the in-memory cache.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
anime_folder: The filesystem folder name to look up
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Serie if found and loaded, None otherwise
|
|
||||||
"""
|
|
||||||
from src.server.database.connection import get_async_session_factory
|
|
||||||
from src.server.database.service import AnimeSeriesService
|
|
||||||
|
|
||||||
try:
|
|
||||||
session_factory = get_async_session_factory()
|
|
||||||
db = session_factory()
|
|
||||||
try:
|
|
||||||
anime_series = await AnimeSeriesService.get_by_folder(
|
|
||||||
db, anime_folder
|
|
||||||
)
|
|
||||||
if not anime_series:
|
|
||||||
logger.debug(
|
|
||||||
"Series with folder '%s' not found in DB",
|
|
||||||
anime_folder
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
episode_dict: Dict[int, List[int]] = {}
|
|
||||||
if anime_series.episodes:
|
|
||||||
for ep in anime_series.episodes:
|
|
||||||
if ep.season not in episode_dict:
|
|
||||||
episode_dict[ep.season] = []
|
|
||||||
episode_dict[ep.season].append(ep.episode_number)
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key=anime_series.key,
|
|
||||||
name=anime_series.name,
|
|
||||||
site=anime_series.site,
|
|
||||||
folder=anime_series.folder,
|
|
||||||
episodeDict=episode_dict,
|
|
||||||
year=anime_series.year
|
|
||||||
)
|
|
||||||
self.keyDict[serie.key] = serie
|
|
||||||
logger.debug(
|
|
||||||
"Loaded series '%s' (key=%s) from DB",
|
|
||||||
serie.name, serie.key
|
|
||||||
)
|
|
||||||
return serie
|
|
||||||
finally:
|
|
||||||
await db.close()
|
|
||||||
except RuntimeError:
|
|
||||||
logger.warning(
|
|
||||||
"Database not available, cannot load series '%s'",
|
|
||||||
anime_folder
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def invalidate_cache(self) -> None:
|
|
||||||
"""Clear the in-memory cache.
|
|
||||||
|
|
||||||
Use after database modifications to force reload from DB
|
|
||||||
on next access.
|
|
||||||
"""
|
|
||||||
self.keyDict.clear()
|
|
||||||
logger.debug("SerieList in-memory cache invalidated")
|
|
||||||
|
|
||||||
def reload(self) -> None:
|
|
||||||
"""Reload series from filesystem (legacy mode).
|
|
||||||
|
|
||||||
Warning:
|
|
||||||
This method uses file-based loading and should only be
|
|
||||||
used as fallback when database is not available.
|
|
||||||
"""
|
|
||||||
self.load_series()
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import warnings
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from src.server.utils.filesystem import sanitize_folder_name
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Serie:
|
|
||||||
"""
|
|
||||||
Represents an anime series with metadata and episode information.
|
|
||||||
|
|
||||||
The `key` property is the unique identifier for the series
|
|
||||||
(provider-assigned, URL-safe).
|
|
||||||
The `folder` property is the filesystem folder name
|
|
||||||
(metadata only, not used for lookups).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: Unique series identifier from provider
|
|
||||||
(e.g., "attack-on-titan"). Cannot be empty.
|
|
||||||
name: Display name of the series
|
|
||||||
site: Provider site URL
|
|
||||||
folder: Filesystem folder name (metadata only,
|
|
||||||
e.g., "Attack on Titan (2013)")
|
|
||||||
episodeDict: Dictionary mapping season numbers to
|
|
||||||
lists of episode numbers
|
|
||||||
year: Release year of the series (optional)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If key is None or empty string
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
key: str,
|
|
||||||
name: str,
|
|
||||||
site: str,
|
|
||||||
folder: str,
|
|
||||||
episodeDict: dict[int, list[int]],
|
|
||||||
year: int | None = None,
|
|
||||||
nfo_path: Optional[str] = None
|
|
||||||
):
|
|
||||||
if not key or not key.strip():
|
|
||||||
raise ValueError("Serie key cannot be None or empty")
|
|
||||||
|
|
||||||
self._key = key.strip()
|
|
||||||
self._name = name
|
|
||||||
self._site = site
|
|
||||||
self._folder = folder
|
|
||||||
self._episodeDict = episodeDict
|
|
||||||
self._year = year
|
|
||||||
self._nfo_path = nfo_path
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
"""String representation of Serie object"""
|
|
||||||
year_str = f", year={self.year}" if self.year else ""
|
|
||||||
return (
|
|
||||||
f"Serie(key='{self.key}', name='{self.name}', "
|
|
||||||
f"site='{self.site}', folder='{self.folder}', "
|
|
||||||
f"episodeDict={self.episodeDict}{year_str})"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
"""Concise developer representation of Serie object."""
|
|
||||||
season_count = len(self.episodeDict)
|
|
||||||
episode_count = sum(len(eps) for eps in self.episodeDict.values())
|
|
||||||
year_str = f", year={self.year}" if self.year else ""
|
|
||||||
return (
|
|
||||||
f"Serie(key={self.key!r}, name={self.name!r}"
|
|
||||||
f"{year_str}, seasons={season_count}, episodes={episode_count})"
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key(self) -> str:
|
|
||||||
"""
|
|
||||||
Unique series identifier (primary identifier for all lookups).
|
|
||||||
|
|
||||||
This is the provider-assigned, URL-safe identifier used
|
|
||||||
throughout the application for series identification,
|
|
||||||
lookups, and operations.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The unique series key
|
|
||||||
"""
|
|
||||||
return self._key
|
|
||||||
|
|
||||||
@key.setter
|
|
||||||
def key(self, value: str):
|
|
||||||
"""
|
|
||||||
Set the unique series identifier.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value: New key value
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If value is None or empty string
|
|
||||||
"""
|
|
||||||
if not value or not value.strip():
|
|
||||||
raise ValueError("Serie key cannot be None or empty")
|
|
||||||
self._key = value.strip()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return self._name
|
|
||||||
|
|
||||||
@name.setter
|
|
||||||
def name(self, value: str):
|
|
||||||
self._name = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def site(self) -> str:
|
|
||||||
return self._site
|
|
||||||
|
|
||||||
@site.setter
|
|
||||||
def site(self, value: str):
|
|
||||||
self._site = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def folder(self) -> str:
|
|
||||||
"""
|
|
||||||
Filesystem folder name (metadata only, not used for lookups).
|
|
||||||
|
|
||||||
This property contains the local directory name where the series
|
|
||||||
files are stored. It should NOT be used as an identifier for
|
|
||||||
series lookups - use `key` instead.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The filesystem folder name
|
|
||||||
"""
|
|
||||||
return self._folder
|
|
||||||
|
|
||||||
@folder.setter
|
|
||||||
def folder(self, value: str):
|
|
||||||
"""
|
|
||||||
Set the filesystem folder name.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value: Folder name for the series
|
|
||||||
"""
|
|
||||||
self._folder = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def episodeDict(self) -> dict[int, list[int]]:
|
|
||||||
return self._episodeDict
|
|
||||||
|
|
||||||
@episodeDict.setter
|
|
||||||
def episodeDict(self, value: dict[int, list[int]]):
|
|
||||||
self._episodeDict = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def year(self) -> int | None:
|
|
||||||
"""
|
|
||||||
Release year of the series.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int or None: The year the series was released, or None if unknown
|
|
||||||
"""
|
|
||||||
return self._year
|
|
||||||
|
|
||||||
@year.setter
|
|
||||||
def year(self, value: int | None):
|
|
||||||
"""Set the release year of the series."""
|
|
||||||
self._year = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def nfo_path(self) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Path to the tvshow.nfo metadata file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str or None: Path to the NFO file, or None if not set
|
|
||||||
"""
|
|
||||||
return self._nfo_path
|
|
||||||
|
|
||||||
@nfo_path.setter
|
|
||||||
def nfo_path(self, value: Optional[str]):
|
|
||||||
"""Set the path to the NFO file."""
|
|
||||||
self._nfo_path = value
|
|
||||||
|
|
||||||
def has_nfo(self, base_directory: Optional[str] = None) -> bool:
|
|
||||||
"""
|
|
||||||
Check if tvshow.nfo file exists for this series.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_directory: Base anime directory path. If provided, checks
|
|
||||||
relative to base_directory/folder/tvshow.nfo. If not provided,
|
|
||||||
uses nfo_path directly.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if tvshow.nfo exists, False otherwise
|
|
||||||
"""
|
|
||||||
if base_directory:
|
|
||||||
nfo_file = Path(base_directory) / self.folder / "tvshow.nfo"
|
|
||||||
elif self._nfo_path:
|
|
||||||
nfo_file = Path(self._nfo_path)
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return nfo_file.exists() and nfo_file.is_file()
|
|
||||||
|
|
||||||
def has_poster(self, base_directory: Optional[str] = None) -> bool:
|
|
||||||
"""
|
|
||||||
Check if poster.jpg file exists for this series.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_directory: Base anime directory path. If provided, checks
|
|
||||||
relative to base_directory/folder/poster.jpg.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if poster.jpg exists, False otherwise
|
|
||||||
"""
|
|
||||||
if not base_directory:
|
|
||||||
return False
|
|
||||||
|
|
||||||
poster_file = Path(base_directory) / self.folder / "poster.jpg"
|
|
||||||
return poster_file.exists() and poster_file.is_file()
|
|
||||||
|
|
||||||
def has_logo(self, base_directory: Optional[str] = None) -> bool:
|
|
||||||
"""
|
|
||||||
Check if logo.png file exists for this series.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_directory: Base anime directory path. If provided, checks
|
|
||||||
relative to base_directory/folder/logo.png.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if logo.png exists, False otherwise
|
|
||||||
"""
|
|
||||||
if not base_directory:
|
|
||||||
return False
|
|
||||||
|
|
||||||
logo_file = Path(base_directory) / self.folder / "logo.png"
|
|
||||||
return logo_file.exists() and logo_file.is_file()
|
|
||||||
|
|
||||||
def has_fanart(self, base_directory: Optional[str] = None) -> bool:
|
|
||||||
"""
|
|
||||||
Check if fanart.jpg file exists for this series.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_directory: Base anime directory path. If provided, checks
|
|
||||||
relative to base_directory/folder/fanart.jpg.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if fanart.jpg exists, False otherwise
|
|
||||||
"""
|
|
||||||
if not base_directory:
|
|
||||||
return False
|
|
||||||
|
|
||||||
fanart_file = Path(base_directory) / self.folder / "fanart.jpg"
|
|
||||||
return fanart_file.exists() and fanart_file.is_file()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name_with_year(self) -> str:
|
|
||||||
"""
|
|
||||||
Get the series name with year appended if available.
|
|
||||||
|
|
||||||
Returns a name in the format "Name (Year)" if year is available,
|
|
||||||
otherwise returns just the name. This should be used for creating
|
|
||||||
filesystem folders to distinguish series with the same name.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Name with year in format "Name (Year)", or just name if no year
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> serie = Serie("dororo", "Dororo", ..., year=2025)
|
|
||||||
>>> serie.name_with_year
|
|
||||||
'Dororo (2025)'
|
|
||||||
"""
|
|
||||||
if self._year:
|
|
||||||
import re
|
|
||||||
year_suffix = f" ({self._year})"
|
|
||||||
# Strip ALL trailing year suffixes before appending to prevent duplication
|
|
||||||
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', self._name).strip()
|
|
||||||
return f"{clean_name}{year_suffix}"
|
|
||||||
return self._name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sanitized_folder(self) -> str:
|
|
||||||
"""
|
|
||||||
Get a filesystem-safe folder name derived from the display name with year.
|
|
||||||
|
|
||||||
This property returns a sanitized version of the series name with year
|
|
||||||
(if available) suitable for use as a filesystem folder name. It removes/
|
|
||||||
replaces characters that are invalid for filesystems while preserving
|
|
||||||
Unicode characters.
|
|
||||||
|
|
||||||
Use this property when creating folders for the series on disk.
|
|
||||||
The `folder` property stores the actual folder name used.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Filesystem-safe folder name based on display name with year
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> serie = Serie("attack-on-titan", "Attack on Titan: Final", ..., year=2025)
|
|
||||||
>>> serie.sanitized_folder
|
|
||||||
'Attack on Titan Final (2025)'
|
|
||||||
"""
|
|
||||||
# Use name_with_year if available, fall back to folder, then key
|
|
||||||
name_to_sanitize = self.name_with_year or self._folder or self._key
|
|
||||||
try:
|
|
||||||
return sanitize_folder_name(name_to_sanitize)
|
|
||||||
except ValueError:
|
|
||||||
# Fallback to key if name cannot be sanitized
|
|
||||||
return sanitize_folder_name(self._key)
|
|
||||||
|
|
||||||
def ensure_folder_with_year(self) -> str:
|
|
||||||
"""Ensure folder name includes year if available.
|
|
||||||
|
|
||||||
If the serie has a year and the current folder name doesn't include it,
|
|
||||||
updates the folder name to include the year in format "Name (Year)".
|
|
||||||
|
|
||||||
This method should be called before creating folders or NFO files to
|
|
||||||
ensure consistent naming across the application.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The folder name (updated if needed)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> serie = Serie("perfect-blue", "Perfect Blue", ..., folder="Perfect Blue", year=1997)
|
|
||||||
>>> serie.ensure_folder_with_year()
|
|
||||||
'Perfect Blue (1997)'
|
|
||||||
>>> serie.folder # folder property is updated
|
|
||||||
'Perfect Blue (1997)'
|
|
||||||
"""
|
|
||||||
if self._year:
|
|
||||||
# Check if folder already has year format
|
|
||||||
year_pattern = f"({self._year})"
|
|
||||||
if year_pattern not in self._folder:
|
|
||||||
# Update folder to include year
|
|
||||||
self._folder = self.sanitized_folder
|
|
||||||
logger.info(
|
|
||||||
f"Updated folder name for '{self._key}' to include year: {self._folder}"
|
|
||||||
)
|
|
||||||
return self._folder
|
|
||||||
|
|
||||||
def to_dict(self):
|
|
||||||
"""Convert Serie object to dictionary for JSON serialization."""
|
|
||||||
return {
|
|
||||||
"key": self.key,
|
|
||||||
"name": self.name,
|
|
||||||
"site": self.site,
|
|
||||||
"folder": self.folder,
|
|
||||||
"episodeDict": {
|
|
||||||
str(k): list(v) for k, v in self.episodeDict.items()
|
|
||||||
},
|
|
||||||
"year": self.year,
|
|
||||||
"nfo_path": self.nfo_path
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_dict(data: dict):
|
|
||||||
"""Create a Serie object from dictionary."""
|
|
||||||
# Convert keys to int
|
|
||||||
episode_dict = {
|
|
||||||
int(k): v for k, v in data["episodeDict"].items()
|
|
||||||
}
|
|
||||||
return Serie(
|
|
||||||
data["key"],
|
|
||||||
data["name"],
|
|
||||||
data["site"],
|
|
||||||
data["folder"],
|
|
||||||
episode_dict,
|
|
||||||
data.get("year"), # Optional year field for backward compatibility
|
|
||||||
data.get("nfo_path") # Optional nfo_path field
|
|
||||||
)
|
|
||||||
|
|
||||||
def save_to_file(self, filename: str):
|
|
||||||
"""Save Serie object to JSON file.
|
|
||||||
|
|
||||||
.. deprecated::
|
|
||||||
File-based storage is deprecated. Use database storage via
|
|
||||||
`AnimeSeriesService.create()` instead. This method will be
|
|
||||||
removed in v3.0.0.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Path to save the JSON file
|
|
||||||
"""
|
|
||||||
warnings.warn(
|
|
||||||
"save_to_file() is deprecated and will be removed in v3.0.0. "
|
|
||||||
"Use database storage via AnimeSeriesService.create() instead.",
|
|
||||||
DeprecationWarning,
|
|
||||||
stacklevel=2
|
|
||||||
)
|
|
||||||
with open(filename, "w", encoding="utf-8") as file:
|
|
||||||
json.dump(self.to_dict(), file, indent=4)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def load_from_file(cls, filename: str) -> "Serie":
|
|
||||||
"""Load Serie object from JSON file.
|
|
||||||
|
|
||||||
.. deprecated::
|
|
||||||
File-based storage is deprecated. Use database storage via
|
|
||||||
`AnimeSeriesService.get_by_key()` instead. This method will be
|
|
||||||
removed in v3.0.0.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Path to load the JSON file from
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Serie: The loaded Serie object
|
|
||||||
"""
|
|
||||||
warnings.warn(
|
|
||||||
"load_from_file() is deprecated and will be removed in v3.0.0. "
|
|
||||||
"Use database storage via AnimeSeriesService instead.",
|
|
||||||
DeprecationWarning,
|
|
||||||
stacklevel=2
|
|
||||||
)
|
|
||||||
with open(filename, "r", encoding="utf-8") as file:
|
|
||||||
data = json.load(file)
|
|
||||||
return cls.from_dict(data)
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
"""NFO Service Factory Module.
|
|
||||||
|
|
||||||
This module provides a centralized factory for creating NFOService instances
|
|
||||||
with consistent configuration and initialization logic.
|
|
||||||
|
|
||||||
The factory supports both direct instantiation and FastAPI dependency injection,
|
|
||||||
while remaining testable through optional dependency overrides.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from src.config.settings import settings
|
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class NFOServiceFactory:
|
|
||||||
"""Factory for creating NFOService instances with consistent configuration.
|
|
||||||
|
|
||||||
This factory centralizes NFO service initialization logic that was previously
|
|
||||||
duplicated across multiple modules (SeriesApp, SeriesManagerService, API endpoints).
|
|
||||||
|
|
||||||
The factory follows these precedence rules for configuration:
|
|
||||||
1. Explicit parameters (highest priority)
|
|
||||||
2. Environment variables via settings
|
|
||||||
3. config.json via ConfigService (fallback)
|
|
||||||
4. Raise error if TMDB API key unavailable
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> factory = NFOServiceFactory()
|
|
||||||
>>> nfo_service = factory.create()
|
|
||||||
>>> # Or with custom settings:
|
|
||||||
>>> nfo_service = factory.create(tmdb_api_key="custom_key")
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize the NFO service factory."""
|
|
||||||
self._config_service = None
|
|
||||||
|
|
||||||
def create(
|
|
||||||
self,
|
|
||||||
tmdb_api_key: Optional[str] = None,
|
|
||||||
anime_directory: Optional[str] = None,
|
|
||||||
image_size: Optional[str] = None,
|
|
||||||
auto_create: Optional[bool] = None
|
|
||||||
) -> NFOService:
|
|
||||||
"""Create an NFOService instance with proper configuration.
|
|
||||||
|
|
||||||
This method implements the configuration precedence:
|
|
||||||
1. Use explicit parameters if provided
|
|
||||||
2. Fall back to settings (from ENV vars)
|
|
||||||
3. Fall back to config.json (only if ENV not set)
|
|
||||||
4. Raise ValueError if TMDB API key still unavailable
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tmdb_api_key: TMDB API key (optional, falls back to settings/config)
|
|
||||||
anime_directory: Anime directory path (optional, defaults to settings)
|
|
||||||
image_size: Image size for downloads (optional, defaults to settings)
|
|
||||||
auto_create: Whether to auto-create NFO files (optional, defaults to settings)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
NFOService: Configured NFO service instance
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If TMDB API key cannot be determined from any source
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> factory = NFOServiceFactory()
|
|
||||||
>>> # Use all defaults from settings
|
|
||||||
>>> service = factory.create()
|
|
||||||
>>> # Override specific settings
|
|
||||||
>>> service = factory.create(auto_create=False)
|
|
||||||
"""
|
|
||||||
# Step 1: Determine TMDB API key with fallback logic
|
|
||||||
api_key = tmdb_api_key or settings.tmdb_api_key
|
|
||||||
|
|
||||||
# Step 2: If no API key in settings, try config.json as fallback
|
|
||||||
if not api_key:
|
|
||||||
api_key = self._get_api_key_from_config()
|
|
||||||
|
|
||||||
# Step 3: Validate API key is available
|
|
||||||
if not api_key:
|
|
||||||
raise ValueError(
|
|
||||||
"TMDB API key not configured. Set TMDB_API_KEY environment "
|
|
||||||
"variable or configure in config.json (nfo.tmdb_api_key)."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 4: Use provided values or fall back to settings
|
|
||||||
directory = anime_directory or settings.anime_directory
|
|
||||||
size = image_size or settings.nfo_image_size
|
|
||||||
auto = auto_create if auto_create is not None else settings.nfo_auto_create
|
|
||||||
|
|
||||||
# Step 5: Create and return the service
|
|
||||||
logger.debug(
|
|
||||||
"Creating NFOService: directory=%s, size=%s, auto_create=%s",
|
|
||||||
directory, size, auto
|
|
||||||
)
|
|
||||||
|
|
||||||
return NFOService(
|
|
||||||
tmdb_api_key=api_key,
|
|
||||||
anime_directory=directory,
|
|
||||||
image_size=size,
|
|
||||||
auto_create=auto
|
|
||||||
)
|
|
||||||
|
|
||||||
def create_optional(
|
|
||||||
self,
|
|
||||||
tmdb_api_key: Optional[str] = None,
|
|
||||||
anime_directory: Optional[str] = None,
|
|
||||||
image_size: Optional[str] = None,
|
|
||||||
auto_create: Optional[bool] = None
|
|
||||||
) -> Optional[NFOService]:
|
|
||||||
"""Create an NFOService instance, returning None if configuration unavailable.
|
|
||||||
|
|
||||||
This is a convenience method for cases where NFO service is optional.
|
|
||||||
Unlike create(), this returns None instead of raising ValueError when
|
|
||||||
the TMDB API key is not configured.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tmdb_api_key: TMDB API key (optional)
|
|
||||||
anime_directory: Anime directory path (optional)
|
|
||||||
image_size: Image size for downloads (optional)
|
|
||||||
auto_create: Whether to auto-create NFO files (optional)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Optional[NFOService]: Configured service or None if key unavailable
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> factory = NFOServiceFactory()
|
|
||||||
>>> service = factory.create_optional()
|
|
||||||
>>> if service:
|
|
||||||
... service.create_tvshow_nfo(...)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return self.create(
|
|
||||||
tmdb_api_key=tmdb_api_key,
|
|
||||||
anime_directory=anime_directory,
|
|
||||||
image_size=image_size,
|
|
||||||
auto_create=auto_create
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
logger.debug("NFO service not available: %s", e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_api_key_from_config(self) -> Optional[str]:
|
|
||||||
"""Get TMDB API key from config.json as fallback.
|
|
||||||
|
|
||||||
This method is only called when the API key is not in settings
|
|
||||||
(i.e., not set via environment variable). It provides backward
|
|
||||||
compatibility with config.json configuration.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Optional[str]: API key from config.json, or None if unavailable
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Lazy import to avoid circular dependencies
|
|
||||||
from src.server.services.config_service import get_config_service
|
|
||||||
|
|
||||||
if self._config_service is None:
|
|
||||||
self._config_service = get_config_service()
|
|
||||||
|
|
||||||
config = self._config_service.load_config()
|
|
||||||
|
|
||||||
if config.nfo and config.nfo.tmdb_api_key:
|
|
||||||
logger.debug("Using TMDB API key from config.json")
|
|
||||||
return config.nfo.tmdb_api_key
|
|
||||||
|
|
||||||
except Exception as e: # pylint: disable=broad-except
|
|
||||||
logger.debug("Could not load API key from config.json: %s", e)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Global factory instance for convenience
|
|
||||||
_factory_instance: Optional[NFOServiceFactory] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_nfo_factory() -> NFOServiceFactory:
|
|
||||||
"""Get the global NFO service factory instance.
|
|
||||||
|
|
||||||
This function provides a singleton factory instance for the application.
|
|
||||||
The singleton pattern here is for the factory itself (which is stateless),
|
|
||||||
not for the NFO service instances it creates.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
NFOServiceFactory: The global factory instance
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> factory = get_nfo_factory()
|
|
||||||
>>> service = factory.create()
|
|
||||||
"""
|
|
||||||
global _factory_instance
|
|
||||||
|
|
||||||
if _factory_instance is None:
|
|
||||||
_factory_instance = NFOServiceFactory()
|
|
||||||
|
|
||||||
return _factory_instance
|
|
||||||
|
|
||||||
|
|
||||||
def create_nfo_service(
|
|
||||||
tmdb_api_key: Optional[str] = None,
|
|
||||||
anime_directory: Optional[str] = None,
|
|
||||||
image_size: Optional[str] = None,
|
|
||||||
auto_create: Optional[bool] = None
|
|
||||||
) -> NFOService:
|
|
||||||
"""Convenience function to create an NFOService instance.
|
|
||||||
|
|
||||||
This is a shorthand for get_nfo_factory().create() that can be used
|
|
||||||
when you need a quick NFO service instance without interacting with
|
|
||||||
the factory directly.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tmdb_api_key: TMDB API key (optional)
|
|
||||||
anime_directory: Anime directory path (optional)
|
|
||||||
image_size: Image size for downloads (optional)
|
|
||||||
auto_create: Whether to auto-create NFO files (optional)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
NFOService: Configured NFO service instance
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If TMDB API key cannot be determined
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> service = create_nfo_service()
|
|
||||||
>>> # Or with custom settings:
|
|
||||||
>>> service = create_nfo_service(auto_create=False)
|
|
||||||
"""
|
|
||||||
factory = get_nfo_factory()
|
|
||||||
return factory.create(
|
|
||||||
tmdb_api_key=tmdb_api_key,
|
|
||||||
anime_directory=anime_directory,
|
|
||||||
image_size=image_size,
|
|
||||||
auto_create=auto_create
|
|
||||||
)
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
"""NFO repair service for detecting and fixing incomplete tvshow.nfo files.
|
|
||||||
|
|
||||||
This module provides utilities to check whether an existing ``tvshow.nfo``
|
|
||||||
contains all required tags and to trigger a repair (re-fetch from TMDB) when
|
|
||||||
needed.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> service = NfoRepairService(nfo_service)
|
|
||||||
>>> repaired = await service.repair_series(Path("/anime/Attack on Titan"), "Attack on Titan")
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List
|
|
||||||
|
|
||||||
from lxml import etree
|
|
||||||
|
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
from src.core.services.tmdb_client import TMDBAPIError
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# XPath relative to <tvshow> root → human-readable label
|
|
||||||
REQUIRED_TAGS: Dict[str, str] = {
|
|
||||||
"./title": "title",
|
|
||||||
"./originaltitle": "originaltitle",
|
|
||||||
"./year": "year",
|
|
||||||
"./plot": "plot",
|
|
||||||
"./runtime": "runtime",
|
|
||||||
"./premiered": "premiered",
|
|
||||||
"./status": "status",
|
|
||||||
"./imdbid": "imdbid",
|
|
||||||
"./genre": "genre",
|
|
||||||
"./studio": "studio",
|
|
||||||
"./country": "country",
|
|
||||||
"./actor/name": "actor/name",
|
|
||||||
"./watched": "watched",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def parse_nfo_tags(nfo_path: Path) -> Dict[str, List[str]]:
|
|
||||||
"""Parse an existing tvshow.nfo and return present tag values.
|
|
||||||
|
|
||||||
Evaluates every XPath in :data:`REQUIRED_TAGS` against the document root
|
|
||||||
and collects all non-empty text values.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
nfo_path: Absolute path to the ``tvshow.nfo`` file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Mapping of XPath expression → list of non-empty text strings found in
|
|
||||||
the document. Returns an empty dict on any error (missing file,
|
|
||||||
invalid XML, permission error).
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> tags = parse_nfo_tags(Path("/anime/Attack on Titan/tvshow.nfo"))
|
|
||||||
>>> tags.get("./title")
|
|
||||||
['Attack on Titan']
|
|
||||||
"""
|
|
||||||
if not nfo_path.exists():
|
|
||||||
logger.debug("NFO file not found: %s", nfo_path)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
tree = etree.parse(str(nfo_path))
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
result: Dict[str, List[str]] = {}
|
|
||||||
for xpath in REQUIRED_TAGS:
|
|
||||||
elements = root.findall(xpath)
|
|
||||||
result[xpath] = [e.text for e in elements if e.text]
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
except etree.XMLSyntaxError as exc:
|
|
||||||
logger.warning("Malformed XML in %s: %s", nfo_path, exc)
|
|
||||||
return {}
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
|
||||||
logger.warning("Unexpected error parsing %s: %s", nfo_path, exc)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def find_missing_tags(nfo_path: Path) -> List[str]:
|
|
||||||
"""Return tags that are absent or empty in the NFO.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
nfo_path: Absolute path to the ``tvshow.nfo`` file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of human-readable tag labels (values from :data:`REQUIRED_TAGS`)
|
|
||||||
whose XPath matched no elements or only elements with empty text.
|
|
||||||
An empty list means the NFO is complete.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> missing = find_missing_tags(Path("/anime/series/tvshow.nfo"))
|
|
||||||
>>> if missing:
|
|
||||||
... print("Missing:", missing)
|
|
||||||
"""
|
|
||||||
parsed = parse_nfo_tags(nfo_path)
|
|
||||||
missing: List[str] = []
|
|
||||||
for xpath, label in REQUIRED_TAGS.items():
|
|
||||||
if not parsed.get(xpath):
|
|
||||||
missing.append(label)
|
|
||||||
return missing
|
|
||||||
|
|
||||||
|
|
||||||
def nfo_needs_repair(nfo_path: Path) -> bool:
|
|
||||||
"""Return ``True`` if the NFO is missing any required tag.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
nfo_path: Absolute path to the ``tvshow.nfo`` file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if :func:`find_missing_tags` returns a non-empty list.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> if nfo_needs_repair(Path("/anime/series/tvshow.nfo")):
|
|
||||||
... await service.repair_series(series_path, series_name)
|
|
||||||
"""
|
|
||||||
return bool(find_missing_tags(nfo_path))
|
|
||||||
|
|
||||||
|
|
||||||
def _read_tmdb_id(nfo_path: Path) -> int | None:
|
|
||||||
"""Return the TMDB ID stored in an existing NFO, or ``None``.
|
|
||||||
|
|
||||||
Checks both ``<tmdbid>`` and ``<uniqueid type="tmdb">`` elements.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
nfo_path: Absolute path to the ``tvshow.nfo`` file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Integer TMDB ID, or ``None`` if not found or not parseable.
|
|
||||||
"""
|
|
||||||
if not nfo_path.exists():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
root = etree.parse(str(nfo_path)).getroot()
|
|
||||||
|
|
||||||
for uniqueid in root.findall(".//uniqueid"):
|
|
||||||
if uniqueid.get("type") == "tmdb" and uniqueid.text:
|
|
||||||
return int(uniqueid.text)
|
|
||||||
|
|
||||||
tmdbid_elem = root.find(".//tmdbid")
|
|
||||||
if tmdbid_elem is not None and tmdbid_elem.text:
|
|
||||||
return int(tmdbid_elem.text)
|
|
||||||
|
|
||||||
except (etree.XMLSyntaxError, ValueError):
|
|
||||||
pass
|
|
||||||
except Exception: # pylint: disable=broad-except
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class NfoRepairService:
|
|
||||||
"""Service that detects and repairs incomplete tvshow.nfo files.
|
|
||||||
|
|
||||||
Wraps the module-level helpers with structured logging and delegates
|
|
||||||
the actual TMDB re-fetch to an injected :class:`NFOService` instance.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
_nfo_service: The underlying NFOService used to update NFOs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, nfo_service: NFOService) -> None:
|
|
||||||
"""Initialise the repair service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
nfo_service: Configured :class:`NFOService` instance.
|
|
||||||
"""
|
|
||||||
self._nfo_service = nfo_service
|
|
||||||
|
|
||||||
async def repair_series(self, series_path: Path, series_name: str) -> bool:
|
|
||||||
"""Repair an NFO file if required tags are missing.
|
|
||||||
|
|
||||||
Checks ``{series_path}/tvshow.nfo`` for completeness. If tags are
|
|
||||||
missing, logs them and calls
|
|
||||||
``NFOService.update_tvshow_nfo(series_name)`` to re-fetch metadata
|
|
||||||
from TMDB.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
series_path: Absolute path to the series folder.
|
|
||||||
series_name: Series folder name used as the identifier for
|
|
||||||
:meth:`NFOService.update_tvshow_nfo`.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``True`` if a repair was triggered, ``False`` if the NFO was
|
|
||||||
already complete (or did not exist).
|
|
||||||
"""
|
|
||||||
nfo_path = series_path / "tvshow.nfo"
|
|
||||||
missing = find_missing_tags(nfo_path)
|
|
||||||
|
|
||||||
if not missing:
|
|
||||||
logger.info(
|
|
||||||
"NFO repair skipped — complete: %s",
|
|
||||||
series_name,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"NFO repair triggered for %s — missing tags: %s",
|
|
||||||
series_name,
|
|
||||||
", ".join(missing),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self._nfo_service.update_tvshow_nfo(
|
|
||||||
series_name,
|
|
||||||
download_media=False,
|
|
||||||
)
|
|
||||||
except TMDBAPIError as e:
|
|
||||||
if "No TMDB ID found" in str(e):
|
|
||||||
# No TMDB ID in existing NFO — create new one via search
|
|
||||||
logger.info(
|
|
||||||
"NFO has no TMDB ID, creating new NFO via TMDB search"
|
|
||||||
)
|
|
||||||
await self._nfo_service.create_tvshow_nfo(
|
|
||||||
serie_name=series_name,
|
|
||||||
serie_folder=series_name,
|
|
||||||
download_poster=False,
|
|
||||||
download_logo=False,
|
|
||||||
download_fanart=False,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
logger.info("NFO repair completed: %s", series_name)
|
|
||||||
return True
|
|
||||||
@@ -1,891 +0,0 @@
|
|||||||
"""NFO service for creating and managing tvshow.nfo files.
|
|
||||||
|
|
||||||
This service orchestrates TMDB API calls, XML generation, and media downloads
|
|
||||||
to create complete NFO metadata for TV series.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> nfo_service = NFOService(tmdb_api_key="key", anime_directory="/anime")
|
|
||||||
>>> await nfo_service.create_tvshow_nfo("Attack on Titan", "/anime/aot", 2013)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import unicodedata
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
from lxml import etree
|
|
||||||
|
|
||||||
from src.core.services.tmdb_client import TMDBAPIError, TMDBClient
|
|
||||||
from src.core.utils.image_downloader import ImageDownloader
|
|
||||||
from src.core.utils.nfo_generator import generate_tvshow_nfo
|
|
||||||
from src.core.utils.nfo_mapper import tmdb_to_nfo_model
|
|
||||||
from src.core.entities.nfo_models import TVShowNFO
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class NFOService:
|
|
||||||
"""Service for creating and managing tvshow.nfo files.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
tmdb_client: TMDB API client
|
|
||||||
image_downloader: Image downloader utility
|
|
||||||
anime_directory: Base directory for anime series
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
tmdb_api_key: str,
|
|
||||||
anime_directory: str,
|
|
||||||
image_size: str = "original",
|
|
||||||
auto_create: bool = True
|
|
||||||
):
|
|
||||||
"""Initialize NFO service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tmdb_api_key: TMDB API key
|
|
||||||
anime_directory: Base anime directory path
|
|
||||||
image_size: Image size to download (original, w500, etc.)
|
|
||||||
auto_create: Whether to auto-create NFOs
|
|
||||||
"""
|
|
||||||
self.tmdb_client = TMDBClient(api_key=tmdb_api_key)
|
|
||||||
self.image_downloader = ImageDownloader()
|
|
||||||
self.anime_directory = Path(anime_directory)
|
|
||||||
self.image_size = image_size
|
|
||||||
self.auto_create = auto_create
|
|
||||||
|
|
||||||
async def __aenter__(self) -> "NFOService":
|
|
||||||
"""Enter async context manager."""
|
|
||||||
await self.tmdb_client.__aenter__()
|
|
||||||
await self.image_downloader.__aenter__()
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
||||||
"""Exit async context manager and cleanup resources."""
|
|
||||||
await self.tmdb_client.close()
|
|
||||||
await self.image_downloader.close()
|
|
||||||
return False
|
|
||||||
|
|
||||||
def has_nfo(self, serie_folder: str) -> bool:
|
|
||||||
"""Check if tvshow.nfo exists for a series.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
serie_folder: Series folder name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if NFO file exists
|
|
||||||
"""
|
|
||||||
nfo_path = self.anime_directory / serie_folder / "tvshow.nfo"
|
|
||||||
return nfo_path.exists()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_year_from_name(serie_name: str) -> Tuple[str, Optional[int]]:
|
|
||||||
"""Extract year from series name if present in format 'Name (YYYY)'.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
serie_name: Series name, possibly with year in parentheses
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (clean_name, year)
|
|
||||||
- clean_name: Series name without year
|
|
||||||
- year: Extracted year or None
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> _extract_year_from_name("Attack on Titan (2013)")
|
|
||||||
("Attack on Titan", 2013)
|
|
||||||
>>> _extract_year_from_name("Attack on Titan")
|
|
||||||
("Attack on Titan", None)
|
|
||||||
"""
|
|
||||||
# Match the last year in parentheses at the end: (YYYY)
|
|
||||||
match = re.search(r'\((\d{4})\)\s*$', serie_name)
|
|
||||||
if match:
|
|
||||||
year = int(match.group(1))
|
|
||||||
# Strip ALL trailing year suffixes to get a fully clean name
|
|
||||||
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', serie_name).strip()
|
|
||||||
return clean_name, year
|
|
||||||
return serie_name, None
|
|
||||||
|
|
||||||
async def check_nfo_exists(self, serie_folder: str) -> bool:
|
|
||||||
"""Check if tvshow.nfo exists for a series.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
serie_folder: Series folder name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if tvshow.nfo exists
|
|
||||||
"""
|
|
||||||
nfo_path = self.anime_directory / serie_folder / "tvshow.nfo"
|
|
||||||
return nfo_path.exists()
|
|
||||||
|
|
||||||
async def create_tvshow_nfo(
|
|
||||||
self,
|
|
||||||
serie_name: str,
|
|
||||||
serie_folder: str,
|
|
||||||
year: Optional[int] = None,
|
|
||||||
download_poster: bool = True,
|
|
||||||
download_logo: bool = True,
|
|
||||||
download_fanart: bool = True,
|
|
||||||
alt_titles: Optional[List[str]] = None
|
|
||||||
) -> Path:
|
|
||||||
"""Create tvshow.nfo by scraping TMDB.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
serie_name: Name of the series to search (may include year in parentheses)
|
|
||||||
serie_folder: Series folder name
|
|
||||||
year: Release year (helps narrow search). If None and name contains year,
|
|
||||||
year will be auto-extracted
|
|
||||||
download_poster: Whether to download poster.jpg
|
|
||||||
download_logo: Whether to download logo.png
|
|
||||||
download_fanart: Whether to download fanart.jpg
|
|
||||||
alt_titles: Alternative titles (e.g., Japanese title) for fallback search
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to created NFO file
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TMDBAPIError: If TMDB API fails
|
|
||||||
FileNotFoundError: If series folder doesn't exist
|
|
||||||
"""
|
|
||||||
# Extract year from name if not provided
|
|
||||||
clean_name, extracted_year = self._extract_year_from_name(serie_name)
|
|
||||||
if year is None and extracted_year is not None:
|
|
||||||
year = extracted_year
|
|
||||||
logger.info("Extracted year %s from series name", year)
|
|
||||||
|
|
||||||
# Use clean name for search
|
|
||||||
search_name = clean_name
|
|
||||||
|
|
||||||
logger.info("Creating NFO for %s (year: %s)", search_name, year)
|
|
||||||
|
|
||||||
folder_path = self.anime_directory / serie_folder
|
|
||||||
if not folder_path.exists():
|
|
||||||
logger.info("Creating series folder: %s", folder_path)
|
|
||||||
folder_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Check for existing NFO with TMDB ID to skip search
|
|
||||||
nfo_path = folder_path / "tvshow.nfo"
|
|
||||||
existing_ids = None
|
|
||||||
if nfo_path.exists():
|
|
||||||
try:
|
|
||||||
existing_ids = self.parse_nfo_ids(nfo_path)
|
|
||||||
if existing_ids.get("tmdb_id"):
|
|
||||||
logger.info(
|
|
||||||
"Found existing TMDB ID %s in NFO, using directly",
|
|
||||||
existing_ids["tmdb_id"]
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Could not parse existing NFO IDs: %s", e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.tmdb_client._ensure_session()
|
|
||||||
|
|
||||||
# Use existing TMDB ID if found, otherwise search
|
|
||||||
if existing_ids and existing_ids.get("tmdb_id"):
|
|
||||||
tv_id = existing_ids["tmdb_id"]
|
|
||||||
logger.info("Fetching details directly for TMDB ID: %s", tv_id)
|
|
||||||
details = await self.tmdb_client.get_tv_show_details(
|
|
||||||
tv_id,
|
|
||||||
append_to_response="credits,external_ids,images"
|
|
||||||
)
|
|
||||||
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id)
|
|
||||||
tv_show = {"id": tv_id, "name": details.get("name", serie_name)}
|
|
||||||
search_source = "nfo_override"
|
|
||||||
else:
|
|
||||||
# Search for TV show - try multiple strategies
|
|
||||||
tv_show, search_source = await self._search_with_fallback(
|
|
||||||
search_name, year, alt_titles
|
|
||||||
)
|
|
||||||
tv_id = tv_show["id"]
|
|
||||||
|
|
||||||
logger.info("Found match: %s (ID: %s)", tv_show['name'], tv_id)
|
|
||||||
|
|
||||||
# Get detailed information with multi-language image support
|
|
||||||
# Skip if we already fetched details via nfo_override
|
|
||||||
if search_source != "nfo_override":
|
|
||||||
details = await self.tmdb_client.get_tv_show_details(
|
|
||||||
tv_id,
|
|
||||||
append_to_response="credits,external_ids,images"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get content ratings for FSK
|
|
||||||
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id)
|
|
||||||
|
|
||||||
# Enrich with fallback languages for empty overview/tagline
|
|
||||||
# Pass search result overview as last resort fallback
|
|
||||||
search_overview = tv_show.get("overview") or None
|
|
||||||
if not search_overview:
|
|
||||||
try:
|
|
||||||
logger.debug(
|
|
||||||
"No overview in German search result, trying en-US search fallback for: %s",
|
|
||||||
search_name,
|
|
||||||
)
|
|
||||||
en_search_results = await self.tmdb_client.search_tv_show(
|
|
||||||
search_name,
|
|
||||||
language="en-US",
|
|
||||||
)
|
|
||||||
if en_search_results.get("results"):
|
|
||||||
en_match = self._find_best_match(
|
|
||||||
en_search_results["results"], search_name, year
|
|
||||||
)
|
|
||||||
search_overview = en_match.get("overview") or None
|
|
||||||
if search_overview:
|
|
||||||
logger.info(
|
|
||||||
"Using en-US search overview fallback for %s",
|
|
||||||
search_name,
|
|
||||||
)
|
|
||||||
except (TMDBAPIError, Exception) as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Failed en-US search fallback for overview: %s",
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
details = await self._enrich_details_with_fallback(
|
|
||||||
details, search_overview=search_overview
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# When using nfo_override, content_ratings already fetched
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Convert TMDB data to TVShowNFO model
|
|
||||||
nfo_model = tmdb_to_nfo_model(
|
|
||||||
details,
|
|
||||||
content_ratings,
|
|
||||||
self.tmdb_client.get_image_url,
|
|
||||||
self.image_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate XML
|
|
||||||
nfo_xml = generate_tvshow_nfo(nfo_model)
|
|
||||||
|
|
||||||
# Save NFO file
|
|
||||||
nfo_path = folder_path / "tvshow.nfo"
|
|
||||||
nfo_path.write_text(nfo_xml, encoding="utf-8")
|
|
||||||
logger.info("Created NFO: %s", nfo_path)
|
|
||||||
|
|
||||||
# Download media files
|
|
||||||
await self._download_media_files(
|
|
||||||
details,
|
|
||||||
folder_path,
|
|
||||||
download_poster=download_poster,
|
|
||||||
download_logo=download_logo,
|
|
||||||
download_fanart=download_fanart
|
|
||||||
)
|
|
||||||
|
|
||||||
return nfo_path
|
|
||||||
finally:
|
|
||||||
await self.tmdb_client.close()
|
|
||||||
|
|
||||||
async def update_tvshow_nfo(
|
|
||||||
self,
|
|
||||||
serie_folder: str,
|
|
||||||
download_media: bool = True
|
|
||||||
) -> Path:
|
|
||||||
"""Update existing tvshow.nfo with fresh data from TMDB.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
serie_folder: Series folder name
|
|
||||||
download_media: Whether to re-download media files
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to updated NFO file
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
FileNotFoundError: If NFO file doesn't exist
|
|
||||||
TMDBAPIError: If TMDB API fails or no TMDB ID found in NFO
|
|
||||||
"""
|
|
||||||
folder_path = self.anime_directory / serie_folder
|
|
||||||
nfo_path = folder_path / "tvshow.nfo"
|
|
||||||
|
|
||||||
if not nfo_path.exists():
|
|
||||||
raise FileNotFoundError(f"NFO file not found: {nfo_path}")
|
|
||||||
|
|
||||||
logger.info("Updating NFO for %s", serie_folder)
|
|
||||||
|
|
||||||
# Parse existing NFO to extract TMDB ID
|
|
||||||
try:
|
|
||||||
tree = etree.parse(str(nfo_path))
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
# Try to find TMDB ID from uniqueid elements
|
|
||||||
tmdb_id = None
|
|
||||||
for uniqueid in root.findall(".//uniqueid"):
|
|
||||||
if uniqueid.get("type") == "tmdb":
|
|
||||||
tmdb_id = int(uniqueid.text)
|
|
||||||
break
|
|
||||||
|
|
||||||
# Fallback: check for tmdbid element
|
|
||||||
if tmdb_id is None:
|
|
||||||
tmdbid_elem = root.find(".//tmdbid")
|
|
||||||
if tmdbid_elem is not None and tmdbid_elem.text:
|
|
||||||
tmdb_id = int(tmdbid_elem.text)
|
|
||||||
|
|
||||||
if tmdb_id is None:
|
|
||||||
raise TMDBAPIError(
|
|
||||||
f"No TMDB ID found in existing NFO. "
|
|
||||||
f"Delete the NFO and create a new one instead."
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug("Found TMDB ID: %s", tmdb_id)
|
|
||||||
|
|
||||||
except etree.XMLSyntaxError as e:
|
|
||||||
raise TMDBAPIError(f"Invalid XML in NFO file: {e}")
|
|
||||||
except ValueError as e:
|
|
||||||
raise TMDBAPIError(f"Invalid TMDB ID format in NFO: {e}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.tmdb_client._ensure_session()
|
|
||||||
logger.debug("Fetching fresh data for TMDB ID: %s", tmdb_id)
|
|
||||||
details = await self.tmdb_client.get_tv_show_details(
|
|
||||||
tmdb_id,
|
|
||||||
append_to_response="credits,external_ids,images"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get content ratings for FSK
|
|
||||||
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tmdb_id)
|
|
||||||
|
|
||||||
# Enrich with fallback languages for empty overview/tagline
|
|
||||||
details = await self._enrich_details_with_fallback(details)
|
|
||||||
# Convert TMDB data to TVShowNFO model
|
|
||||||
nfo_model = tmdb_to_nfo_model(
|
|
||||||
details,
|
|
||||||
content_ratings,
|
|
||||||
self.tmdb_client.get_image_url,
|
|
||||||
self.image_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate XML
|
|
||||||
nfo_xml = generate_tvshow_nfo(nfo_model)
|
|
||||||
|
|
||||||
# Save updated NFO file
|
|
||||||
nfo_path.write_text(nfo_xml, encoding="utf-8")
|
|
||||||
logger.info("Updated NFO: %s", nfo_path)
|
|
||||||
|
|
||||||
# Re-download media files if requested
|
|
||||||
if download_media:
|
|
||||||
await self._download_media_files(
|
|
||||||
details,
|
|
||||||
folder_path,
|
|
||||||
download_poster=True,
|
|
||||||
download_logo=True,
|
|
||||||
download_fanart=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return nfo_path
|
|
||||||
finally:
|
|
||||||
await self.tmdb_client.close()
|
|
||||||
|
|
||||||
def parse_nfo_ids(self, nfo_path: Path) -> Dict[str, Optional[int]]:
|
|
||||||
"""Parse TMDB ID and TVDB ID from an existing NFO file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
nfo_path: Path to tvshow.nfo file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with 'tmdb_id' and 'tvdb_id' keys.
|
|
||||||
Values are integers if found, None otherwise.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> ids = nfo_service.parse_nfo_ids(Path("/anime/series/tvshow.nfo"))
|
|
||||||
>>> print(ids)
|
|
||||||
{'tmdb_id': 1429, 'tvdb_id': 79168}
|
|
||||||
"""
|
|
||||||
result = {"tmdb_id": None, "tvdb_id": None}
|
|
||||||
|
|
||||||
if not nfo_path.exists():
|
|
||||||
logger.debug("NFO file not found: %s", nfo_path)
|
|
||||||
return result
|
|
||||||
|
|
||||||
try:
|
|
||||||
tree = etree.parse(str(nfo_path))
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
# Try to find TMDB ID from uniqueid elements first
|
|
||||||
for uniqueid in root.findall(".//uniqueid"):
|
|
||||||
uid_type = uniqueid.get("type")
|
|
||||||
uid_text = uniqueid.text
|
|
||||||
|
|
||||||
if uid_type == "tmdb" and uid_text:
|
|
||||||
try:
|
|
||||||
result["tmdb_id"] = int(uid_text)
|
|
||||||
except ValueError:
|
|
||||||
logger.warning(
|
|
||||||
f"Invalid TMDB ID format in NFO: {uid_text}"
|
|
||||||
)
|
|
||||||
|
|
||||||
elif uid_type == "tvdb" and uid_text:
|
|
||||||
try:
|
|
||||||
result["tvdb_id"] = int(uid_text)
|
|
||||||
except ValueError:
|
|
||||||
logger.warning(
|
|
||||||
f"Invalid TVDB ID format in NFO: {uid_text}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fallback: check for dedicated tmdbid/tvdbid elements
|
|
||||||
if result["tmdb_id"] is None:
|
|
||||||
tmdbid_elem = root.find(".//tmdbid")
|
|
||||||
if tmdbid_elem is not None and tmdbid_elem.text:
|
|
||||||
try:
|
|
||||||
result["tmdb_id"] = int(tmdbid_elem.text)
|
|
||||||
except ValueError:
|
|
||||||
logger.warning(
|
|
||||||
f"Invalid TMDB ID format in tmdbid element: "
|
|
||||||
f"{tmdbid_elem.text}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if result["tvdb_id"] is None:
|
|
||||||
tvdbid_elem = root.find(".//tvdbid")
|
|
||||||
if tvdbid_elem is not None and tvdbid_elem.text:
|
|
||||||
try:
|
|
||||||
result["tvdb_id"] = int(tvdbid_elem.text)
|
|
||||||
except ValueError:
|
|
||||||
logger.warning(
|
|
||||||
f"Invalid TVDB ID format in tvdbid element: "
|
|
||||||
f"{tvdbid_elem.text}"
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"Parsed IDs from NFO: {nfo_path.name} - "
|
|
||||||
f"TMDB: {result['tmdb_id']}, TVDB: {result['tvdb_id']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
except etree.XMLSyntaxError as e:
|
|
||||||
logger.error("Invalid XML in NFO file %s: %s", nfo_path, e)
|
|
||||||
except Exception as e: # pylint: disable=broad-except
|
|
||||||
logger.error("Error parsing NFO file %s: %s", nfo_path, e)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def parse_nfo_year(self, nfo_path: Path) -> Optional[int]:
|
|
||||||
"""Parse year from an existing NFO file.
|
|
||||||
|
|
||||||
Extracts year from <year> or <premiered> elements.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
nfo_path: Path to tvshow.nfo file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Year as integer if found, None otherwise.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> year = nfo_service.parse_nfo_year(Path("/anime/series/tvshow.nfo"))
|
|
||||||
>>> print(year)
|
|
||||||
2013
|
|
||||||
"""
|
|
||||||
if not nfo_path.exists():
|
|
||||||
logger.debug("NFO file not found: %s", nfo_path)
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
tree = etree.parse(str(nfo_path))
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
# Try <year> element first
|
|
||||||
year_elem = root.find(".//year")
|
|
||||||
if year_elem is not None and year_elem.text:
|
|
||||||
try:
|
|
||||||
year = int(year_elem.text)
|
|
||||||
if 1900 <= year <= 2100:
|
|
||||||
logger.debug("Found year in NFO: %d", year)
|
|
||||||
return year
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fallback: try <premiered> element (format: YYYY-MM-DD)
|
|
||||||
premiered_elem = root.find(".//premiered")
|
|
||||||
if premiered_elem is not None and premiered_elem.text:
|
|
||||||
if premiered_elem.text and len(premiered_elem.text) >= 4:
|
|
||||||
try:
|
|
||||||
year = int(premiered_elem.text[:4])
|
|
||||||
if 1900 <= year <= 2100:
|
|
||||||
logger.debug("Found year from premiered in NFO: %d", year)
|
|
||||||
return year
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger.debug("No year found in NFO: %s", nfo_path)
|
|
||||||
|
|
||||||
except etree.XMLSyntaxError as e:
|
|
||||||
logger.error("Invalid XML in NFO file %s: %s", nfo_path, e)
|
|
||||||
except Exception as e: # pylint: disable=broad-except
|
|
||||||
logger.error("Error parsing year from NFO file %s: %s", nfo_path, e)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _enrich_details_with_fallback(
|
|
||||||
self,
|
|
||||||
details: Dict[str, Any],
|
|
||||||
search_overview: Optional[str] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Enrich TMDB details with fallback languages for empty fields.
|
|
||||||
|
|
||||||
When requesting details in ``de-DE``, some anime have an empty
|
|
||||||
``overview`` (and potentially other translatable fields). This
|
|
||||||
method detects empty values and fills them from alternative
|
|
||||||
languages (``en-US``, then ``ja-JP``) so that NFO files always
|
|
||||||
contain a ``plot`` regardless of whether the German translation
|
|
||||||
exists. As a last resort, the overview from the search result
|
|
||||||
is used.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
details: TMDB TV show details (language ``de-DE``).
|
|
||||||
search_overview: Overview text from the TMDB search result,
|
|
||||||
used as a final fallback if all language-specific
|
|
||||||
requests fail or return empty overviews.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The *same* dict, mutated in-place with fallback values
|
|
||||||
where needed.
|
|
||||||
"""
|
|
||||||
overview = details.get("overview") or ""
|
|
||||||
|
|
||||||
if overview:
|
|
||||||
# Overview already populated – nothing to do.
|
|
||||||
return details
|
|
||||||
|
|
||||||
tmdb_id = details.get("id")
|
|
||||||
fallback_languages = ["en-US", "ja-JP"]
|
|
||||||
|
|
||||||
for lang in fallback_languages:
|
|
||||||
if details.get("overview"):
|
|
||||||
break
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"Trying %s fallback for TMDB ID %s",
|
|
||||||
lang, tmdb_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
lang_details = await self.tmdb_client.get_tv_show_details(
|
|
||||||
tmdb_id,
|
|
||||||
language=lang,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not details.get("overview") and lang_details.get("overview"):
|
|
||||||
details["overview"] = lang_details["overview"]
|
|
||||||
logger.info(
|
|
||||||
"Used %s overview fallback for TMDB ID %s",
|
|
||||||
lang, tmdb_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Also fill tagline if missing
|
|
||||||
if not details.get("tagline") and lang_details.get("tagline"):
|
|
||||||
details["tagline"] = lang_details["tagline"]
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
|
||||||
logger.warning(
|
|
||||||
"Failed to fetch %s fallback for TMDB ID %s: %s",
|
|
||||||
lang, tmdb_id, exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Last resort: use search result overview
|
|
||||||
if not details.get("overview") and search_overview:
|
|
||||||
details["overview"] = search_overview
|
|
||||||
logger.info(
|
|
||||||
"Used search result overview fallback for TMDB ID %s",
|
|
||||||
tmdb_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return details
|
|
||||||
|
|
||||||
def _find_best_match(
|
|
||||||
self,
|
|
||||||
results: List[Dict[str, Any]],
|
|
||||||
query: str,
|
|
||||||
year: Optional[int] = None
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Find best matching TV show from search results.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
results: TMDB search results
|
|
||||||
query: Original search query
|
|
||||||
year: Expected release year
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Best matching TV show data
|
|
||||||
"""
|
|
||||||
if not results:
|
|
||||||
raise TMDBAPIError("No search results to match")
|
|
||||||
|
|
||||||
# If year is provided, try to find exact match
|
|
||||||
if year:
|
|
||||||
for result in results:
|
|
||||||
first_air_date = result.get("first_air_date", "")
|
|
||||||
if first_air_date.startswith(str(year)):
|
|
||||||
logger.debug("Found year match: %s (%s)", result['name'], first_air_date)
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Return first result (usually best match)
|
|
||||||
return results[0]
|
|
||||||
|
|
||||||
async def _search_with_fallback(
|
|
||||||
self,
|
|
||||||
primary_query: str,
|
|
||||||
year: Optional[int],
|
|
||||||
alt_titles: Optional[List[str]] = None
|
|
||||||
) -> Tuple[Dict[str, Any], str]:
|
|
||||||
"""Search TMDB with fallback strategies.
|
|
||||||
|
|
||||||
Tries multiple search strategies in order:
|
|
||||||
1. Primary query with year filter
|
|
||||||
2. Alternative titles (e.g., Japanese name)
|
|
||||||
3. Multi-language search (en-US)
|
|
||||||
4. Search without year constraint
|
|
||||||
5. Punctuation-normalized search
|
|
||||||
|
|
||||||
Args:
|
|
||||||
primary_query: Primary search term
|
|
||||||
year: Release year for filtering
|
|
||||||
alt_titles: Alternative titles to try if primary fails
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (matched TV show dict, source description string)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TMDBAPIError: If all search strategies fail
|
|
||||||
"""
|
|
||||||
search_strategies = [
|
|
||||||
# Strategy 1: Primary query as-is
|
|
||||||
{"query": primary_query, "year": year, "lang": "de-DE", "desc": "primary"},
|
|
||||||
]
|
|
||||||
|
|
||||||
# Strategy 2: Try alt titles (typically Japanese)
|
|
||||||
if alt_titles:
|
|
||||||
for alt in alt_titles:
|
|
||||||
if alt != primary_query:
|
|
||||||
search_strategies.append(
|
|
||||||
{"query": alt, "year": year, "lang": "ja-JP", "desc": f"alt_title:{alt}"}
|
|
||||||
)
|
|
||||||
search_strategies.append(
|
|
||||||
{"query": alt, "year": year, "lang": "en-US", "desc": f"alt_title:{alt}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Strategy 3: Try English search
|
|
||||||
search_strategies.append(
|
|
||||||
{"query": primary_query, "year": year, "lang": "en-US", "desc": "english"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Strategy 4: Try without year constraint
|
|
||||||
if year:
|
|
||||||
search_strategies.append(
|
|
||||||
{"query": primary_query, "year": None, "lang": "de-DE", "desc": "no_year"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Strategy 5: Normalize punctuation
|
|
||||||
normalized = self._normalize_query_for_search(primary_query)
|
|
||||||
if normalized != primary_query:
|
|
||||||
search_strategies.append(
|
|
||||||
{"query": normalized, "year": year, "lang": "de-DE", "desc": f"normalized:{normalized}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Strategy 6: Try search/multi for series indexed as movies
|
|
||||||
search_strategies.append(
|
|
||||||
{"query": primary_query, "year": year, "lang": "en-US", "desc": "multi_search", "use_multi": True}
|
|
||||||
)
|
|
||||||
|
|
||||||
last_error = None
|
|
||||||
for strategy in search_strategies:
|
|
||||||
query = strategy["query"]
|
|
||||||
lang = strategy["lang"]
|
|
||||||
desc = strategy["desc"]
|
|
||||||
use_multi = strategy.get("use_multi", False)
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.debug(
|
|
||||||
"TMDB search attempt: query='%s', lang=%s, year=%s, strategy=%s",
|
|
||||||
query, lang, strategy["year"], desc
|
|
||||||
)
|
|
||||||
|
|
||||||
# Use search/multi for multi_search strategy
|
|
||||||
if use_multi:
|
|
||||||
search_results = await self.tmdb_client.search_multi(
|
|
||||||
query,
|
|
||||||
language=lang
|
|
||||||
)
|
|
||||||
# Filter for TV shows only
|
|
||||||
if search_results.get("results"):
|
|
||||||
tv_results = [
|
|
||||||
r for r in search_results["results"]
|
|
||||||
if r.get("media_type") == "tv"
|
|
||||||
]
|
|
||||||
if tv_results:
|
|
||||||
search_results["results"] = tv_results
|
|
||||||
else:
|
|
||||||
search_results["results"] = []
|
|
||||||
else:
|
|
||||||
search_results = await self.tmdb_client.search_tv_show(
|
|
||||||
query,
|
|
||||||
language=lang
|
|
||||||
)
|
|
||||||
|
|
||||||
if search_results.get("results"):
|
|
||||||
# Apply year filter if we have one
|
|
||||||
results = search_results["results"]
|
|
||||||
if strategy["year"]:
|
|
||||||
year_filtered = [
|
|
||||||
r for r in results
|
|
||||||
if r.get("first_air_date", "").startswith(str(strategy["year"]))
|
|
||||||
]
|
|
||||||
if year_filtered:
|
|
||||||
match = year_filtered[0]
|
|
||||||
else:
|
|
||||||
# Year didn't match, still use first result but log it
|
|
||||||
match = results[0]
|
|
||||||
logger.debug(
|
|
||||||
"Year %s not found in results for '%s', using: %s",
|
|
||||||
strategy["year"], query, match["name"]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
match = results[0]
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"TMDB search succeeded: '%s' found via strategy '%s' (ID: %s)",
|
|
||||||
match["name"], desc, match["id"]
|
|
||||||
)
|
|
||||||
return match, desc
|
|
||||||
else:
|
|
||||||
logger.debug("No results for '%s' via %s", query, desc)
|
|
||||||
|
|
||||||
except TMDBAPIError as e:
|
|
||||||
last_error = e
|
|
||||||
logger.debug("Search strategy '%s' failed: %s", desc, e)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# All strategies exhausted
|
|
||||||
raise TMDBAPIError(
|
|
||||||
f"No results found for: {primary_query} (tried {len(search_strategies)} strategies)"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _normalize_query_for_search(self, query: str) -> str:
|
|
||||||
"""Normalize query by removing punctuation and special chars.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Original search query
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Query with punctuation removed
|
|
||||||
"""
|
|
||||||
# Remove common punctuation but keep CJK characters
|
|
||||||
normalized = unicodedata.normalize('NFKC', query)
|
|
||||||
# Remove punctuation but not CJK
|
|
||||||
normalized = re.sub(r'[^\w\s\u3000-\u9fff\u4e00-\u9faf]', '', normalized)
|
|
||||||
# Collapse multiple spaces
|
|
||||||
normalized = re.sub(r'\s+', ' ', normalized).strip()
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def _download_media_files(
|
|
||||||
self,
|
|
||||||
tmdb_data: Dict[str, Any],
|
|
||||||
folder_path: Path,
|
|
||||||
download_poster: bool = True,
|
|
||||||
download_logo: bool = True,
|
|
||||||
download_fanart: bool = True
|
|
||||||
) -> Dict[str, bool]:
|
|
||||||
"""Download media files (poster, logo, fanart).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tmdb_data: TMDB TV show details
|
|
||||||
folder_path: Series folder path
|
|
||||||
download_poster: Download poster.jpg
|
|
||||||
download_logo: Download logo.png
|
|
||||||
download_fanart: Download fanart.jpg
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with download status for each file
|
|
||||||
"""
|
|
||||||
poster_url = None
|
|
||||||
logo_url = None
|
|
||||||
fanart_url = None
|
|
||||||
|
|
||||||
# Get poster URL
|
|
||||||
if download_poster and tmdb_data.get("poster_path"):
|
|
||||||
poster_url = self.tmdb_client.get_image_url(
|
|
||||||
tmdb_data["poster_path"],
|
|
||||||
self.image_size
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get fanart URL
|
|
||||||
if download_fanart and tmdb_data.get("backdrop_path"):
|
|
||||||
fanart_url = self.tmdb_client.get_image_url(
|
|
||||||
tmdb_data["backdrop_path"],
|
|
||||||
"original" # Always use original for fanart
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get logo URL
|
|
||||||
if download_logo:
|
|
||||||
images_data = tmdb_data.get("images", {})
|
|
||||||
logos = images_data.get("logos", [])
|
|
||||||
if logos:
|
|
||||||
logo_url = self.tmdb_client.get_image_url(
|
|
||||||
logos[0]["file_path"],
|
|
||||||
"original" # Logos should be original size
|
|
||||||
)
|
|
||||||
|
|
||||||
# Download all media concurrently
|
|
||||||
results = await self.image_downloader.download_all_media(
|
|
||||||
folder_path,
|
|
||||||
poster_url=poster_url,
|
|
||||||
logo_url=logo_url,
|
|
||||||
fanart_url=fanart_url,
|
|
||||||
skip_existing=True
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Media download results: %s", results)
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def close(self):
|
|
||||||
"""Clean up resources."""
|
|
||||||
await self.tmdb_client.close()
|
|
||||||
await self.image_downloader.close()
|
|
||||||
|
|
||||||
async def create_minimal_nfo(
|
|
||||||
self,
|
|
||||||
serie_name: str,
|
|
||||||
serie_folder: str,
|
|
||||||
year: Optional[int] = None
|
|
||||||
) -> Path:
|
|
||||||
"""Create minimal tvshow.nfo when TMDB lookup fails.
|
|
||||||
|
|
||||||
Creates a basic NFO with just the title (and year if available)
|
|
||||||
so the series is tracked even without TMDB metadata.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
serie_name: Name of the series (may include year in parentheses)
|
|
||||||
serie_folder: Series folder name
|
|
||||||
year: Optional release year
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to created NFO file
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
FileNotFoundError: If series folder doesn't exist
|
|
||||||
"""
|
|
||||||
# Extract year from name if not provided
|
|
||||||
clean_name, extracted_year = self._extract_year_from_name(serie_name)
|
|
||||||
if year is None and extracted_year is not None:
|
|
||||||
year = extracted_year
|
|
||||||
|
|
||||||
folder_path = self.anime_directory / serie_folder
|
|
||||||
if not folder_path.exists():
|
|
||||||
logger.info("Creating series folder: %s", folder_path)
|
|
||||||
folder_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Create minimal NFO model with just title and year
|
|
||||||
nfo_model = TVShowNFO(
|
|
||||||
title=clean_name,
|
|
||||||
year=year,
|
|
||||||
plot=f"No metadata available for {clean_name}. TMDB lookup failed."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate XML
|
|
||||||
nfo_xml = generate_tvshow_nfo(nfo_model)
|
|
||||||
|
|
||||||
# Save NFO file
|
|
||||||
nfo_path = folder_path / "tvshow.nfo"
|
|
||||||
nfo_path.write_text(nfo_xml, encoding="utf-8")
|
|
||||||
logger.info("Created minimal NFO (no TMDB): %s", nfo_path)
|
|
||||||
|
|
||||||
return nfo_path
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
"""Service for managing series with NFO metadata support.
|
|
||||||
|
|
||||||
This service layer component orchestrates SerieList (core entity) with
|
|
||||||
NFOService to provide automatic NFO creation and updates during series scans.
|
|
||||||
|
|
||||||
This follows clean architecture principles by keeping the core entities
|
|
||||||
independent of external services like TMDB API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from src.config.settings import settings
|
|
||||||
from src.core.entities.SerieList import SerieList
|
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
from src.core.services.tmdb_client import TMDBAPIError
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SeriesManagerService:
|
|
||||||
"""Service for managing series with optional NFO metadata support.
|
|
||||||
|
|
||||||
This service wraps SerieList and adds NFO creation/update capabilities
|
|
||||||
based on configuration settings. It maintains clean separation between
|
|
||||||
core entities and external services.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
serie_list: SerieList instance for series management
|
|
||||||
nfo_service: Optional NFOService for metadata management
|
|
||||||
auto_create_nfo: Whether to auto-create NFO files
|
|
||||||
update_on_scan: Whether to update existing NFO files
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
anime_directory: str,
|
|
||||||
tmdb_api_key: Optional[str] = None,
|
|
||||||
auto_create_nfo: bool = False,
|
|
||||||
update_on_scan: bool = False,
|
|
||||||
download_poster: bool = True,
|
|
||||||
download_logo: bool = True,
|
|
||||||
download_fanart: bool = True,
|
|
||||||
image_size: str = "original"
|
|
||||||
):
|
|
||||||
"""Initialize series manager service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
anime_directory: Base directory for anime series
|
|
||||||
tmdb_api_key: TMDB API key (optional, required for NFO features)
|
|
||||||
auto_create_nfo: Automatically create NFO files when scanning
|
|
||||||
update_on_scan: Update existing NFO files when scanning
|
|
||||||
download_poster: Download poster.jpg
|
|
||||||
download_logo: Download logo.png
|
|
||||||
download_fanart: Download fanart.jpg
|
|
||||||
image_size: Image size to download
|
|
||||||
"""
|
|
||||||
self.anime_directory = anime_directory
|
|
||||||
# Skip automatic folder scanning - we load from database instead
|
|
||||||
self.serie_list = SerieList(anime_directory, skip_load=True)
|
|
||||||
|
|
||||||
# NFO configuration
|
|
||||||
self.auto_create_nfo = auto_create_nfo
|
|
||||||
self.update_on_scan = update_on_scan
|
|
||||||
self.download_poster = download_poster
|
|
||||||
self.download_logo = download_logo
|
|
||||||
self.download_fanart = download_fanart
|
|
||||||
|
|
||||||
# Initialize NFO service if API key provided and NFO features enabled
|
|
||||||
self.nfo_service: Optional[NFOService] = None
|
|
||||||
if tmdb_api_key and (auto_create_nfo or update_on_scan):
|
|
||||||
try:
|
|
||||||
from src.core.services.nfo_factory import get_nfo_factory
|
|
||||||
factory = get_nfo_factory()
|
|
||||||
self.nfo_service = factory.create(
|
|
||||||
tmdb_api_key=tmdb_api_key,
|
|
||||||
anime_directory=anime_directory,
|
|
||||||
image_size=image_size,
|
|
||||||
auto_create=auto_create_nfo
|
|
||||||
)
|
|
||||||
logger.info("NFO service initialized (auto_create=%s, update=%s)",
|
|
||||||
auto_create_nfo, update_on_scan)
|
|
||||||
except (ValueError, Exception) as e: # pylint: disable=broad-except
|
|
||||||
logger.warning(
|
|
||||||
"Failed to initialize NFO service: %s", str(e)
|
|
||||||
)
|
|
||||||
self.nfo_service = None
|
|
||||||
elif auto_create_nfo or update_on_scan:
|
|
||||||
logger.warning(
|
|
||||||
"NFO features requested but TMDB_API_KEY not provided. "
|
|
||||||
"NFO creation/updates will be skipped."
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_settings(cls) -> "SeriesManagerService":
|
|
||||||
"""Create SeriesManagerService from application settings.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Configured SeriesManagerService instance
|
|
||||||
"""
|
|
||||||
return cls(
|
|
||||||
anime_directory=settings.anime_directory,
|
|
||||||
tmdb_api_key=settings.tmdb_api_key,
|
|
||||||
auto_create_nfo=settings.nfo_auto_create,
|
|
||||||
update_on_scan=settings.nfo_update_on_scan,
|
|
||||||
download_poster=settings.nfo_download_poster,
|
|
||||||
download_logo=settings.nfo_download_logo,
|
|
||||||
download_fanart=settings.nfo_download_fanart,
|
|
||||||
image_size=settings.nfo_image_size
|
|
||||||
)
|
|
||||||
|
|
||||||
async def process_nfo_for_series(
|
|
||||||
self,
|
|
||||||
serie_folder: str,
|
|
||||||
serie_name: str,
|
|
||||||
serie_key: str,
|
|
||||||
year: Optional[int] = None
|
|
||||||
):
|
|
||||||
"""Process NFO file for a series (create or update).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
serie_folder: Series folder name
|
|
||||||
serie_name: Series display name
|
|
||||||
serie_key: Series unique identifier for database updates
|
|
||||||
year: Release year (helps with TMDB matching)
|
|
||||||
"""
|
|
||||||
if not self.nfo_service:
|
|
||||||
return
|
|
||||||
|
|
||||||
nfo_exists = False
|
|
||||||
ids = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
folder_path = Path(self.anime_directory) / serie_folder
|
|
||||||
nfo_path = folder_path / "tvshow.nfo"
|
|
||||||
nfo_exists = await self.nfo_service.check_nfo_exists(serie_folder)
|
|
||||||
|
|
||||||
# If NFO exists, parse IDs and update database
|
|
||||||
if nfo_exists:
|
|
||||||
logger.debug("Parsing IDs from existing NFO for '%s'", serie_name)
|
|
||||||
ids = self.nfo_service.parse_nfo_ids(nfo_path)
|
|
||||||
|
|
||||||
if ids["tmdb_id"] or ids["tvdb_id"]:
|
|
||||||
# Update database using service layer
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from src.server.database.connection import get_db_session
|
|
||||||
from src.server.database.service import AnimeSeriesService
|
|
||||||
|
|
||||||
async with get_db_session() as db:
|
|
||||||
series = await AnimeSeriesService.get_by_key(db, serie_key)
|
|
||||||
|
|
||||||
if series:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
# Prepare update fields
|
|
||||||
update_fields = {
|
|
||||||
"has_nfo": True,
|
|
||||||
"nfo_updated_at": now,
|
|
||||||
}
|
|
||||||
|
|
||||||
if series.nfo_created_at is None:
|
|
||||||
update_fields["nfo_created_at"] = now
|
|
||||||
|
|
||||||
if ids["tmdb_id"] is not None:
|
|
||||||
update_fields["tmdb_id"] = ids["tmdb_id"]
|
|
||||||
logger.debug(
|
|
||||||
f"Updated TMDB ID for '{serie_name}': "
|
|
||||||
f"{ids['tmdb_id']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if ids["tvdb_id"] is not None:
|
|
||||||
update_fields["tvdb_id"] = ids["tvdb_id"]
|
|
||||||
logger.debug(
|
|
||||||
f"Updated TVDB ID for '{serie_name}': "
|
|
||||||
f"{ids['tvdb_id']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Use service layer for update
|
|
||||||
await AnimeSeriesService.update(db, series.id, **update_fields)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Updated database with IDs from NFO for "
|
|
||||||
f"'{serie_name}' - TMDB: {ids['tmdb_id']}, "
|
|
||||||
f"TVDB: {ids['tvdb_id']}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"Series not found in database for NFO ID "
|
|
||||||
f"update: {serie_key}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create NFO file only if it doesn't exist and auto_create enabled
|
|
||||||
if not nfo_exists and self.auto_create_nfo:
|
|
||||||
logger.info(
|
|
||||||
f"Creating NFO for '{serie_name}' ({serie_folder})"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await self.nfo_service.create_tvshow_nfo(
|
|
||||||
serie_name=serie_name,
|
|
||||||
serie_folder=serie_folder,
|
|
||||||
year=year,
|
|
||||||
download_poster=self.download_poster,
|
|
||||||
download_logo=self.download_logo,
|
|
||||||
download_fanart=self.download_fanart
|
|
||||||
)
|
|
||||||
logger.info("Successfully created NFO for '%s'", serie_name)
|
|
||||||
except TMDBAPIError as create_error:
|
|
||||||
# TMDB lookup failed, create minimal NFO to track the series
|
|
||||||
logger.warning(
|
|
||||||
"TMDB lookup failed for '%s', creating minimal NFO: %s",
|
|
||||||
serie_name, create_error
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await self.nfo_service.create_minimal_nfo(
|
|
||||||
serie_name=serie_name,
|
|
||||||
serie_folder=serie_folder,
|
|
||||||
year=year
|
|
||||||
)
|
|
||||||
logger.info("Created minimal NFO for '%s'", serie_name)
|
|
||||||
except Exception as minimal_error:
|
|
||||||
logger.error(
|
|
||||||
"Failed to create minimal NFO for '%s': %s",
|
|
||||||
serie_name, minimal_error
|
|
||||||
)
|
|
||||||
elif nfo_exists:
|
|
||||||
logger.debug(
|
|
||||||
f"NFO exists for '{serie_name}', skipping download"
|
|
||||||
)
|
|
||||||
|
|
||||||
except TMDBAPIError as e:
|
|
||||||
# Only log at ERROR if no NFO exists and we have no IDs
|
|
||||||
# If NFO exists with IDs, this is just a lookup failure, log at DEBUG
|
|
||||||
if nfo_exists and (ids.get("tmdb_id") or ids.get("tvdb_id")):
|
|
||||||
logger.debug(
|
|
||||||
"TMDB API lookup failed for '%s' (has NFO with IDs): %s",
|
|
||||||
serie_name, e
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.error("TMDB API error processing '%s': %s", serie_name, e)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Unexpected error processing NFO for '{serie_name}': {e}",
|
|
||||||
exc_info=True
|
|
||||||
)
|
|
||||||
|
|
||||||
async def scan_and_process_nfo(self):
|
|
||||||
"""Scan all series and process NFO files based on configuration.
|
|
||||||
|
|
||||||
This method:
|
|
||||||
1. Loads series from database (avoiding filesystem scan)
|
|
||||||
2. For each series with existing NFO, reads TMDB/TVDB IDs
|
|
||||||
and updates database
|
|
||||||
3. For each series without NFO (if auto_create=True), creates one
|
|
||||||
4. For each series with NFO (if update_on_scan=True), updates it
|
|
||||||
5. Runs operations concurrently for better performance
|
|
||||||
"""
|
|
||||||
if not self.nfo_service:
|
|
||||||
logger.info("NFO service not enabled, skipping NFO processing")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Import database dependencies
|
|
||||||
from src.server.database.connection import get_db_session
|
|
||||||
from src.server.database.service import AnimeSeriesService
|
|
||||||
|
|
||||||
# Load series from database (not from filesystem)
|
|
||||||
async with get_db_session() as db:
|
|
||||||
anime_series_list = await AnimeSeriesService.get_all(
|
|
||||||
db, with_episodes=False
|
|
||||||
)
|
|
||||||
|
|
||||||
if not anime_series_list:
|
|
||||||
logger.info("No series found in database to process")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Processing NFO for %s series...", len(anime_series_list))
|
|
||||||
|
|
||||||
# Create tasks for concurrent processing
|
|
||||||
# Each task creates its own database session
|
|
||||||
tasks = []
|
|
||||||
for anime_series in anime_series_list:
|
|
||||||
# Extract year if available
|
|
||||||
year = getattr(anime_series, 'year', None)
|
|
||||||
|
|
||||||
task = self.process_nfo_for_series(
|
|
||||||
serie_folder=anime_series.folder,
|
|
||||||
serie_name=anime_series.name,
|
|
||||||
serie_key=anime_series.key,
|
|
||||||
year=year
|
|
||||||
)
|
|
||||||
tasks.append(task)
|
|
||||||
|
|
||||||
# Process in batches to avoid overwhelming TMDB API
|
|
||||||
batch_size = 5
|
|
||||||
for i in range(0, len(tasks), batch_size):
|
|
||||||
batch = tasks[i:i + batch_size]
|
|
||||||
await asyncio.gather(*batch, return_exceptions=True)
|
|
||||||
|
|
||||||
# Small delay between batches to respect rate limits
|
|
||||||
if i + batch_size < len(tasks):
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
|
|
||||||
async def close(self):
|
|
||||||
"""Clean up resources."""
|
|
||||||
if self.nfo_service:
|
|
||||||
await self.nfo_service.close()
|
|
||||||
@@ -21,10 +21,9 @@ from typing import Callable, Iterable, Iterator, Optional
|
|||||||
from events import Events
|
from events import Events
|
||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
|
from src.server.exceptions.exceptions.Exceptions import MatchNotFoundError
|
||||||
from src.core.providers.base_provider import Loader
|
from src.server.providers.base_provider import Loader
|
||||||
from src.core.utils.key_utils import generate_key_from_folder
|
|
||||||
from src.server.database.connection import get_sync_session
|
from src.server.database.connection import get_sync_session
|
||||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||||
|
|
||||||
@@ -53,23 +52,12 @@ class SerieScanner:
|
|||||||
# scan() detects running event loop and uses create_task()
|
# scan() detects running event loop and uses create_task()
|
||||||
# internally, so no special handling needed by caller.
|
# internally, so no special handling needed by caller.
|
||||||
# Results are in scanner.keyDict
|
# Results are in scanner.keyDict
|
||||||
|
|
||||||
# With DB lookup fallback:
|
|
||||||
scanner = SerieScanner("/path/to/anime", loader,
|
|
||||||
db_lookup=lambda folder: my_db.get_by_folder(folder))
|
|
||||||
|
|
||||||
# With scan key overrides:
|
|
||||||
overrides = {"Folder Name": "correct-provider-key"}
|
|
||||||
scanner = SerieScanner("/path/to/anime", loader,
|
|
||||||
scan_key_overrides=overrides)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
basePath: str,
|
basePath: str,
|
||||||
loader: Loader,
|
loader: Loader,
|
||||||
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
|
|
||||||
scan_key_overrides: Optional[dict[str, str]] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize the SerieScanner.
|
Initialize the SerieScanner.
|
||||||
@@ -77,15 +65,6 @@ class SerieScanner:
|
|||||||
Args:
|
Args:
|
||||||
basePath: Base directory containing anime series
|
basePath: Base directory containing anime series
|
||||||
loader: Loader instance for fetching series information
|
loader: Loader instance for fetching series information
|
||||||
db_lookup: Optional callable ``(folder_name) -> Serie | None``.
|
|
||||||
When provided, it is called as a fallback when neither a
|
|
||||||
``key`` file nor a ``data`` file is found in the folder.
|
|
||||||
This allows the database to supply the series key for
|
|
||||||
folders that have never had a local key file.
|
|
||||||
scan_key_overrides: Optional dict mapping folder names to provider
|
|
||||||
keys. When a folder name is found in this dict, the override
|
|
||||||
key is used instead of auto-generating from folder name.
|
|
||||||
Format: {"Folder Name": "actual-provider-key"}
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If basePath is invalid or doesn't exist
|
ValueError: If basePath is invalid or doesn't exist
|
||||||
@@ -102,10 +81,8 @@ class SerieScanner:
|
|||||||
raise ValueError(f"Base path is not a directory: {abs_path}")
|
raise ValueError(f"Base path is not a directory: {abs_path}")
|
||||||
|
|
||||||
self.directory: str = abs_path
|
self.directory: str = abs_path
|
||||||
self.keyDict: dict[str, Serie] = {}
|
self.keyDict: dict[str, AnimeSeries] = {}
|
||||||
self.loader: Loader = loader
|
self.loader: Loader = loader
|
||||||
self._db_lookup: Optional[Callable[[str], Optional[Serie]]] = db_lookup
|
|
||||||
self._scan_key_overrides: Optional[dict[str, str]] = scan_key_overrides
|
|
||||||
self._current_operation_id: Optional[str] = None
|
self._current_operation_id: Optional[str] = None
|
||||||
self.events = Events()
|
self.events = Events()
|
||||||
|
|
||||||
@@ -242,64 +219,63 @@ class SerieScanner:
|
|||||||
self.events.on_completion.remove(handler)
|
self.events.on_completion.remove(handler)
|
||||||
|
|
||||||
def reinit(self) -> None:
|
def reinit(self) -> None:
|
||||||
"""Reinitialize the series dictionary (keyed by serie.key)."""
|
"""Reinitialize the series dictionary (keyed by anime.key)."""
|
||||||
self.keyDict: dict[str, Serie] = {}
|
self.keyDict: dict[str, AnimeSeries] = {}
|
||||||
|
|
||||||
async def _persist_serie_to_db(self, serie: Serie) -> None:
|
async def _persist_serie_to_db(self, anime: AnimeSeries) -> None:
|
||||||
"""Persist serie to database (create or update).
|
"""Persist anime to database (create or update).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
serie: Serie domain object to persist
|
anime: AnimeSeries model to persist
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from src.server.database.connection import get_async_session_factory
|
from src.server.database.connection import get_async_session_factory
|
||||||
|
|
||||||
db = get_async_session_factory()
|
db = get_async_session_factory()
|
||||||
try:
|
try:
|
||||||
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
existing = await AnimeSeriesService.get_by_key(db, anime.key)
|
||||||
if existing:
|
if existing:
|
||||||
await AnimeSeriesService.update(
|
await AnimeSeriesService.update(
|
||||||
db, existing.id,
|
db, existing.id,
|
||||||
name=serie.name,
|
name=anime.name,
|
||||||
folder=serie.folder,
|
folder=anime.folder,
|
||||||
year=serie.year
|
year=anime.year
|
||||||
)
|
)
|
||||||
await self._sync_episodes_to_db(db, existing.id, serie.episodeDict)
|
await self._sync_episodes_to_db(db, existing.id, anime.episodeDict)
|
||||||
else:
|
else:
|
||||||
anime_series = await AnimeSeriesService.create(
|
db_anime = await AnimeSeriesService.create(
|
||||||
db=db,
|
db=db,
|
||||||
key=serie.key,
|
key=anime.key,
|
||||||
name=serie.name,
|
name=anime.name,
|
||||||
site=serie.site,
|
site=anime.site,
|
||||||
folder=serie.folder,
|
folder=anime.folder,
|
||||||
year=serie.year
|
year=anime.year
|
||||||
)
|
)
|
||||||
for season, eps in serie.episodeDict.items():
|
for ep in anime.episodes:
|
||||||
for ep in eps:
|
await EpisodeService.create(
|
||||||
await EpisodeService.create(
|
db=db,
|
||||||
db=db,
|
series_id=db_anime.id,
|
||||||
series_id=anime_series.id,
|
season=ep.season,
|
||||||
season=season,
|
episode_number=ep.episode_number
|
||||||
episode_number=ep
|
)
|
||||||
)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Persisted serie '%s' (key=%s) to database",
|
"Persisted anime '%s' (key=%s) to database",
|
||||||
serie.name, serie.key
|
anime.name, anime.key
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to persist serie '%s' to DB: %s",
|
"Failed to persist anime '%s' to DB: %s",
|
||||||
serie.key, e, exc_info=True
|
anime.key, e, exc_info=True
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
await db.close()
|
await db.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Could not persist serie '%s' to DB (DB unavailable?): %s",
|
"Could not persist anime '%s' to DB (DB unavailable?): %s",
|
||||||
serie.key, e
|
anime.key, e
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _sync_episodes_to_db(
|
async def _sync_episodes_to_db(
|
||||||
@@ -419,59 +395,15 @@ class SerieScanner:
|
|||||||
serie = self.__read_data_from_file(folder)
|
serie = self.__read_data_from_file(folder)
|
||||||
if serie is None or not serie.key or not serie.key.strip():
|
if serie is None or not serie.key or not serie.key.strip():
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"No key or data file found for folder '%s', skipping",
|
"No series found in DB for folder '%s', skipping",
|
||||||
folder,
|
folder,
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
if (
|
if (
|
||||||
serie is not None
|
serie is not None
|
||||||
and serie.key
|
and serie.key
|
||||||
and serie.key.strip()
|
and serie.key.strip()
|
||||||
):
|
):
|
||||||
# Try to extract year from folder name first
|
|
||||||
if not hasattr(serie, 'year') or not serie.year:
|
|
||||||
year_from_folder = self._extract_year_from_folder_name(folder)
|
|
||||||
if year_from_folder:
|
|
||||||
serie.year = year_from_folder
|
|
||||||
logger.info(
|
|
||||||
"Using year from folder name: %s (year=%d)",
|
|
||||||
folder,
|
|
||||||
year_from_folder
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# If not in folder name, fetch from provider
|
|
||||||
try:
|
|
||||||
serie.year = self.loader.get_year(serie.key)
|
|
||||||
if serie.year:
|
|
||||||
logger.info(
|
|
||||||
"Fetched year from provider: %s (year=%d)",
|
|
||||||
serie.key,
|
|
||||||
serie.year
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
"Could not fetch year for %s: %s",
|
|
||||||
serie.key,
|
|
||||||
str(e)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fetch series name from provider if not already set
|
|
||||||
if not serie.name:
|
|
||||||
try:
|
|
||||||
fetched_name = self.loader.get_title(serie.key)
|
|
||||||
if fetched_name:
|
|
||||||
serie.name = fetched_name
|
|
||||||
logger.info(
|
|
||||||
"Fetched name from provider: %s (name=%s)",
|
|
||||||
serie.key,
|
|
||||||
serie.name
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
"Could not fetch name for %s: %s",
|
|
||||||
serie.key,
|
|
||||||
str(e)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delegate the provider to compare local files with
|
# Delegate the provider to compare local files with
|
||||||
# remote metadata, yielding missing episodes per
|
# remote metadata, yielding missing episodes per
|
||||||
# season. Results are saved back to disk so that both
|
# season. Results are saved back to disk so that both
|
||||||
@@ -536,21 +468,6 @@ class SerieScanner:
|
|||||||
"Saved Serie: '%s'", str(serie)
|
"Saved Serie: '%s'", str(serie)
|
||||||
)
|
)
|
||||||
|
|
||||||
except NoKeyFoundException as nkfe:
|
|
||||||
# Log error and notify via callback
|
|
||||||
error_msg = f"Error processing folder '{folder}': {nkfe}"
|
|
||||||
logger.error(error_msg)
|
|
||||||
|
|
||||||
self._safe_call_event(
|
|
||||||
self.events.on_error,
|
|
||||||
{
|
|
||||||
"operation_id": self._current_operation_id,
|
|
||||||
"error": nkfe,
|
|
||||||
"message": error_msg,
|
|
||||||
"recoverable": True,
|
|
||||||
"metadata": {"folder": folder, "key": None}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log error and notify via callback
|
# Log error and notify via callback
|
||||||
error_msg = (
|
error_msg = (
|
||||||
@@ -639,49 +556,25 @@ class SerieScanner:
|
|||||||
has_files = True
|
has_files = True
|
||||||
yield anime_name, mp4_files if has_files else []
|
yield anime_name, mp4_files if has_files else []
|
||||||
|
|
||||||
def __read_data_from_file(self, folder_name: str) -> Optional[Serie]:
|
def __read_data_from_file(self, folder_name: str) -> Optional[AnimeSeries]:
|
||||||
"""Load or discover a Serie for the given folder.
|
"""Load or discover an AnimeSeries for the given folder.
|
||||||
|
|
||||||
Strategy:
|
Strategy:
|
||||||
1. Query DB by folder name
|
1. Query DB by folder name
|
||||||
2. If found, return cached Serie object
|
2. If not found in DB, return None (no file fallback)
|
||||||
3. If not in DB, fall back to provider search via _db_lookup callback
|
|
||||||
4. If still not found, try reading 'data' file for legacy deployments
|
|
||||||
5. Check user-provided key overrides in scan_key_overrides
|
|
||||||
6. Generate key from folder name as last resort
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
folder_name: Filesystem folder name
|
folder_name: Filesystem folder name
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Serie object with valid key if found, None otherwise
|
AnimeSeries object if found in DB, None otherwise
|
||||||
|
|
||||||
Note:
|
|
||||||
DB is the source of truth. File-based lookups (data files)
|
|
||||||
are temporary backward compatibility for CLI-only deployments.
|
|
||||||
"""
|
"""
|
||||||
# Step 1: Try DB lookup by folder name
|
# Step 1: Try DB lookup by folder name
|
||||||
try:
|
try:
|
||||||
session = get_sync_session()
|
session = get_sync_session()
|
||||||
try:
|
try:
|
||||||
anime_series = AnimeSeriesService.get_by_folder_sync(session, folder_name)
|
anime_series = AnimeSeriesService.get_by_folder_sync(session, folder_name)
|
||||||
if anime_series:
|
return anime_series
|
||||||
# Reconstruct Serie from DB record
|
|
||||||
episode_dict: dict[int, list[int]] = {}
|
|
||||||
if anime_series.episodes:
|
|
||||||
for ep in anime_series.episodes:
|
|
||||||
season = ep.season or 1
|
|
||||||
if season not in episode_dict:
|
|
||||||
episode_dict[season] = []
|
|
||||||
episode_dict[season].append(ep.episode_number or ep.number or 0)
|
|
||||||
return Serie(
|
|
||||||
key=anime_series.key,
|
|
||||||
name=anime_series.name,
|
|
||||||
site=anime_series.site,
|
|
||||||
folder=anime_series.folder,
|
|
||||||
episodeDict=episode_dict,
|
|
||||||
year=anime_series.year
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -691,79 +584,6 @@ class SerieScanner:
|
|||||||
exc
|
exc
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 2: Fall back to provider search callback
|
|
||||||
if self._db_lookup is not None:
|
|
||||||
try:
|
|
||||||
serie = self._db_lookup(folder_name)
|
|
||||||
if serie and serie.key and serie.key.strip():
|
|
||||||
logger.info(
|
|
||||||
"Provider lookup resolved folder '%s' -> key='%s'",
|
|
||||||
folder_name,
|
|
||||||
serie.key
|
|
||||||
)
|
|
||||||
return serie
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Provider lookup failed for folder '%s': %s",
|
|
||||||
folder_name,
|
|
||||||
exc
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 3: Legacy data file fallback (CLI-only deployments)
|
|
||||||
folder_path = os.path.join(self.directory, folder_name)
|
|
||||||
serie_file = os.path.join(folder_path, 'data')
|
|
||||||
if os.path.exists(serie_file):
|
|
||||||
with open(serie_file, "rb") as file:
|
|
||||||
logger.info(
|
|
||||||
"load serie_file from '%s': %s",
|
|
||||||
folder_name,
|
|
||||||
serie_file
|
|
||||||
)
|
|
||||||
return Serie.load_from_file(serie_file)
|
|
||||||
|
|
||||||
# Step 4: Check for user-provided key overrides before generating
|
|
||||||
if self._scan_key_overrides and folder_name in self._scan_key_overrides:
|
|
||||||
override_key = self._scan_key_overrides[folder_name]
|
|
||||||
year_from_folder = self._extract_year_from_folder_name(folder_name)
|
|
||||||
logger.info(
|
|
||||||
"Using scan key override for folder '%s' -> key='%s'",
|
|
||||||
folder_name,
|
|
||||||
override_key
|
|
||||||
)
|
|
||||||
return Serie(
|
|
||||||
key=override_key,
|
|
||||||
name="", # Name will be fetched from provider if needed
|
|
||||||
site="aniworld.to",
|
|
||||||
folder=folder_name,
|
|
||||||
episodeDict=dict(),
|
|
||||||
year=year_from_folder
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 5: Generate key from folder name as last resort
|
|
||||||
# This handles edge cases like non-Latin characters or special symbols
|
|
||||||
try:
|
|
||||||
generated_key = generate_key_from_folder(folder_name)
|
|
||||||
year_from_folder = self._extract_year_from_folder_name(folder_name)
|
|
||||||
logger.info(
|
|
||||||
"Generated key for folder '%s' -> key='%s'",
|
|
||||||
folder_name,
|
|
||||||
generated_key
|
|
||||||
)
|
|
||||||
return Serie(
|
|
||||||
key=generated_key,
|
|
||||||
name="", # Name will be fetched from provider if needed
|
|
||||||
site="aniworld.to",
|
|
||||||
folder=folder_name,
|
|
||||||
episodeDict=dict(),
|
|
||||||
year=year_from_folder
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to generate key for folder '%s': %s",
|
|
||||||
folder_name,
|
|
||||||
exc
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def __get_episode_and_season(self, filename: str) -> tuple[int, int]:
|
def __get_episode_and_season(self, filename: str) -> tuple[int, int]:
|
||||||
@@ -957,51 +777,38 @@ class SerieScanner:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create or update Serie in keyDict
|
# Create or update AnimeSeries in keyDict
|
||||||
if key in self.keyDict:
|
if key in self.keyDict:
|
||||||
# Update existing serie
|
# Update existing anime - rebuild episodeDict from episodes
|
||||||
self.keyDict[key].episodeDict = missing_episodes
|
existing = self.keyDict[key]
|
||||||
|
existing_ep_dict = existing.episodeDict
|
||||||
|
# Merge missing episodes
|
||||||
|
for season, eps in missing_episodes.items():
|
||||||
|
if season not in existing_ep_dict:
|
||||||
|
existing_ep_dict[season] = []
|
||||||
|
existing_ep_dict[season].extend(eps)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Updated existing series %s with %d missing episodes",
|
"Updated existing series %s with %d missing episodes",
|
||||||
key,
|
key,
|
||||||
sum(len(eps) for eps in missing_episodes.values())
|
sum(len(eps) for eps in missing_episodes.values())
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Try to extract year from folder name first
|
# Extract year from folder name if present, otherwise leave as None
|
||||||
year = self._extract_year_from_folder_name(folder)
|
year = self._extract_year_from_folder_name(folder)
|
||||||
if year:
|
|
||||||
logger.info(
|
# Create new AnimeSeries entry (minimal, fields populated later)
|
||||||
"Using year from folder name: %s (year=%d)",
|
from src.server.database.models import AnimeSeries
|
||||||
folder,
|
anime_series = AnimeSeries(
|
||||||
year
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# If not in folder name, fetch from provider
|
|
||||||
try:
|
|
||||||
year = self.loader.get_year(key)
|
|
||||||
if year:
|
|
||||||
logger.info(
|
|
||||||
"Fetched year from provider: %s (year=%d)",
|
|
||||||
key,
|
|
||||||
year
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
"Could not fetch year for %s: %s",
|
|
||||||
key,
|
|
||||||
str(e)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create new serie entry
|
|
||||||
serie = Serie(
|
|
||||||
key=key,
|
key=key,
|
||||||
name="", # Will be populated by caller if needed
|
name=folder, # Use folder as fallback name since we don't have actual name
|
||||||
site=site,
|
site=site,
|
||||||
folder=folder,
|
folder=folder,
|
||||||
episodeDict=missing_episodes,
|
|
||||||
year=year
|
year=year
|
||||||
)
|
)
|
||||||
self.keyDict[key] = serie
|
# Set episodeDict cache directly since AnimeSeries doesn't persist missing episodes
|
||||||
|
# (they get synced to DB via _persist_serie_to_db later)
|
||||||
|
anime_series._episode_dict_cache = missing_episodes.copy()
|
||||||
|
self.keyDict[key] = anime_series
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Created new series entry for %s with %d missing episodes (year=%s)",
|
"Created new series entry for %s with %d missing episodes (year=%s)",
|
||||||
key,
|
key,
|
||||||
@@ -19,12 +19,10 @@ from typing import Any, Callable, Dict, List, Optional
|
|||||||
from events import Events
|
from events import Events
|
||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.core.entities.SerieList import SerieList
|
from src.server.database.SerieList import SerieList
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
from src.core.providers.provider_factory import Loaders
|
from src.server.providers.provider_factory import Loaders
|
||||||
from src.core.SerieScanner import SerieScanner
|
from src.server.SerieScanner import SerieScanner
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
from src.core.services.tmdb_client import TMDBAPIError
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -143,16 +141,12 @@ class SeriesApp:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
directory_to_search: str,
|
directory_to_search: str,
|
||||||
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize SeriesApp.
|
Initialize SeriesApp.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
directory_to_search: Base directory for anime series
|
directory_to_search: Base directory for anime series
|
||||||
db_lookup: Optional callable ``(folder_name) -> Serie | None``
|
|
||||||
passed through to ``SerieScanner`` as a fallback key source
|
|
||||||
when no local ``key`` or ``data`` file exists.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.directory_to_search = directory_to_search
|
self.directory_to_search = directory_to_search
|
||||||
@@ -168,33 +162,16 @@ class SeriesApp:
|
|||||||
self.serie_scanner = SerieScanner(
|
self.serie_scanner = SerieScanner(
|
||||||
directory_to_search,
|
directory_to_search,
|
||||||
self.loader,
|
self.loader,
|
||||||
db_lookup=db_lookup,
|
|
||||||
scan_key_overrides=settings.scan_key_overrides,
|
|
||||||
)
|
)
|
||||||
# Skip automatic loading from data files - series will be loaded
|
# Series will be loaded from database by the service layer during application setup
|
||||||
# from database by the service layer during application setup
|
self.list = SerieList(self.directory_to_search)
|
||||||
self.list = SerieList(self.directory_to_search, skip_load=True)
|
|
||||||
self.series_list: List[Any] = []
|
self.series_list: List[Any] = []
|
||||||
# Initialize empty list - series loaded later via load_series_from_list()
|
# Initialize empty list - series loaded later via load_series_from_list()
|
||||||
# No need to call _init_list_sync() anymore
|
# No need to call _init_list_sync() anymore
|
||||||
|
|
||||||
# Initialize NFO service if a TMDB API key is configured
|
# NFO service removed - metadata handling moved to server layer
|
||||||
self.nfo_service: Optional[NFOService] = None
|
self.nfo_service = 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"
|
|
||||||
)
|
|
||||||
self.nfo_service = None
|
|
||||||
except Exception as e: # pylint: disable=broad-except
|
|
||||||
logger.warning("Failed to initialize NFO service: %s", str(e))
|
|
||||||
self.nfo_service = None
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"SeriesApp initialized for directory: %s",
|
"SeriesApp initialized for directory: %s",
|
||||||
directory_to_search,
|
directory_to_search,
|
||||||
@@ -356,95 +333,6 @@ class SeriesApp:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check and create NFO files if needed
|
|
||||||
if self.nfo_service and settings.nfo_auto_create:
|
|
||||||
try:
|
|
||||||
# Check if NFO exists
|
|
||||||
nfo_exists = await self.nfo_service.check_nfo_exists(
|
|
||||||
serie_folder
|
|
||||||
)
|
|
||||||
|
|
||||||
if not nfo_exists:
|
|
||||||
logger.info(
|
|
||||||
"NFO not found for %s, creating metadata...",
|
|
||||||
serie_folder
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fire NFO creation started event
|
|
||||||
self._events.download_status(
|
|
||||||
DownloadStatusEventArgs(
|
|
||||||
serie_folder=serie_folder,
|
|
||||||
key=key,
|
|
||||||
season=season,
|
|
||||||
episode=episode,
|
|
||||||
status="nfo_creating",
|
|
||||||
message="Creating NFO metadata...",
|
|
||||||
item_id=item_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create NFO and download media files
|
|
||||||
try:
|
|
||||||
# Use folder name as series name
|
|
||||||
await self.nfo_service.create_tvshow_nfo(
|
|
||||||
serie_name=serie_folder,
|
|
||||||
serie_folder=serie_folder,
|
|
||||||
download_poster=settings.nfo_download_poster,
|
|
||||||
download_logo=settings.nfo_download_logo,
|
|
||||||
download_fanart=settings.nfo_download_fanart
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"NFO and media files created for %s",
|
|
||||||
serie_folder
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fire NFO creation completed event
|
|
||||||
self._events.download_status(
|
|
||||||
DownloadStatusEventArgs(
|
|
||||||
serie_folder=serie_folder,
|
|
||||||
key=key,
|
|
||||||
season=season,
|
|
||||||
episode=episode,
|
|
||||||
status="nfo_completed",
|
|
||||||
message="NFO metadata created",
|
|
||||||
item_id=item_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
except TMDBAPIError as tmdb_error:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to create NFO for %s: %s",
|
|
||||||
serie_folder,
|
|
||||||
str(tmdb_error)
|
|
||||||
)
|
|
||||||
# Fire failed event (but continue with download)
|
|
||||||
self._events.download_status(
|
|
||||||
DownloadStatusEventArgs(
|
|
||||||
serie_folder=serie_folder,
|
|
||||||
key=key,
|
|
||||||
season=season,
|
|
||||||
episode=episode,
|
|
||||||
status="nfo_failed",
|
|
||||||
message=(
|
|
||||||
f"NFO creation failed: "
|
|
||||||
f"{str(tmdb_error)}"
|
|
||||||
),
|
|
||||||
item_id=item_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.debug("NFO already exists for %s", serie_folder)
|
|
||||||
|
|
||||||
except Exception as nfo_error: # pylint: disable=broad-except
|
|
||||||
logger.error(
|
|
||||||
"Error checking/creating NFO for %s: %s",
|
|
||||||
serie_folder,
|
|
||||||
str(nfo_error),
|
|
||||||
exc_info=True
|
|
||||||
)
|
|
||||||
# Don't fail the download if NFO creation fails
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
def download_progress_handler(progress_info):
|
def download_progress_handler(progress_info):
|
||||||
"""Handle download progress events from loader."""
|
"""Handle download progress events from loader."""
|
||||||
@@ -765,7 +653,7 @@ class SeriesApp:
|
|||||||
"""
|
"""
|
||||||
await self._init_list()
|
await self._init_list()
|
||||||
|
|
||||||
def _get_serie_by_key(self, key: str) -> Optional[Serie]:
|
def _get_serie_by_key(self, key: str) -> Optional[AnimeSeries]:
|
||||||
"""
|
"""
|
||||||
Get a series by its unique provider key.
|
Get a series by its unique provider key.
|
||||||
|
|
||||||
@@ -776,7 +664,7 @@ class SeriesApp:
|
|||||||
"attack-on-titan")
|
"attack-on-titan")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The Serie instance if found, None otherwise
|
The AnimeSeries instance if found, None otherwise
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This method uses the SerieList.get_by_key() method which
|
This method uses the SerieList.get_by_key() method which
|
||||||
@@ -784,39 +672,40 @@ class SeriesApp:
|
|||||||
"""
|
"""
|
||||||
return self.list.get_by_key(key)
|
return self.list.get_by_key(key)
|
||||||
|
|
||||||
def get_all_series_from_data_files(self) -> List[Serie]:
|
def get_all_series_from_data_files(self) -> List[AnimeSeries]:
|
||||||
"""
|
"""
|
||||||
Get all series from data files in the anime directory.
|
Get all series from data files in the anime directory.
|
||||||
|
|
||||||
Scans the directory_to_search for all 'data' files and loads
|
Scans the directory_to_search for all 'data' files and loads
|
||||||
the Serie metadata from each file. This method is synchronous
|
the AnimeSeries metadata from each file. This method is synchronous
|
||||||
and can be wrapped with asyncio.to_thread if needed for async
|
and can be wrapped with asyncio.to_thread if needed for async
|
||||||
contexts.
|
contexts.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of Serie objects found in data files. Returns an empty
|
List of AnimeSeries objects found in data files. Returns an empty
|
||||||
list if no data files are found or if the directory doesn't
|
list if no data files are found or if the directory doesn't
|
||||||
exist.
|
exist.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
series_app = SeriesApp("/path/to/anime")
|
series_app = SeriesApp("/path/to/anime")
|
||||||
all_series = series_app.get_all_series_from_data_files()
|
all_series = series_app.get_all_series_from_data_files()
|
||||||
for serie in all_series:
|
for anime in all_series:
|
||||||
print(f"Found: {serie.name} (key={serie.key})")
|
print(f"Found: {anime.name} (key={anime.key})")
|
||||||
"""
|
"""
|
||||||
logger.info(
|
logger.info(
|
||||||
"Scanning for data files in directory: %s",
|
"Scanning for data files in directory: %s",
|
||||||
self.directory_to_search
|
self.directory_to_search
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a fresh SerieList instance for file-based loading
|
all_series: List[AnimeSeries] = []
|
||||||
# This ensures we get all series from data files without
|
|
||||||
# interfering with the main instance's state
|
|
||||||
try:
|
try:
|
||||||
temp_list = SerieList(
|
if not os.path.isdir(self.directory_to_search):
|
||||||
self.directory_to_search,
|
logger.warning(
|
||||||
skip_load=False # Allow automatic loading
|
"Directory does not exist: %s",
|
||||||
)
|
self.directory_to_search
|
||||||
|
)
|
||||||
|
return []
|
||||||
except (OSError, ValueError) as e:
|
except (OSError, ValueError) as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to scan directory for data files: %s",
|
"Failed to scan directory for data files: %s",
|
||||||
@@ -825,8 +714,53 @@ class SeriesApp:
|
|||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Get all series from the temporary list
|
try:
|
||||||
all_series = temp_list.get_all()
|
for folder_name in os.listdir(self.directory_to_search):
|
||||||
|
folder_path = os.path.join(
|
||||||
|
self.directory_to_search, folder_name
|
||||||
|
)
|
||||||
|
if not os.path.isdir(folder_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
data_file = os.path.join(folder_path, "data")
|
||||||
|
if not os.path.isfile(data_file):
|
||||||
|
continue
|
||||||
|
|
||||||
|
series_data = _load_data_file(data_file)
|
||||||
|
if series_data is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = series_data.get("key")
|
||||||
|
if not key:
|
||||||
|
logger.warning(
|
||||||
|
"Data file missing key, skipping: %s",
|
||||||
|
data_file
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
anime = AnimeSeries(
|
||||||
|
key=key,
|
||||||
|
name=series_data.get("name") or folder_name,
|
||||||
|
site=series_data.get("site", "https://aniworld.to"),
|
||||||
|
folder=series_data.get("folder", folder_name),
|
||||||
|
year=series_data.get("year"),
|
||||||
|
)
|
||||||
|
|
||||||
|
episode_dict = series_data.get("episodeDict", {})
|
||||||
|
if episode_dict:
|
||||||
|
anime._episode_dict_cache = {
|
||||||
|
int(season): episodes
|
||||||
|
for season, episodes in episode_dict.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
all_series.append(anime)
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to scan directory for data files: %s",
|
||||||
|
str(e),
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Found %d series from data files in %s",
|
"Found %d series from data files in %s",
|
||||||
@@ -846,3 +780,38 @@ class SeriesApp:
|
|||||||
if hasattr(self, 'executor'):
|
if hasattr(self, 'executor'):
|
||||||
self.executor.shutdown(wait=True)
|
self.executor.shutdown(wait=True)
|
||||||
logger.info("ThreadPoolExecutor shut down successfully")
|
logger.info("ThreadPoolExecutor shut down successfully")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_data_file(data_file_path: str) -> Optional[dict]:
|
||||||
|
"""Load and parse a legacy 'data' file (JSON).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_file_path: Path to the data file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed data dict or None if parsing fails
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(data_file_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
logger.warning("Data file is not a dictionary: %s", data_file_path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to parse legacy data file (JSON error): %s - %s",
|
||||||
|
data_file_path, str(e)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to read legacy data file: %s - %s",
|
||||||
|
data_file_path, str(e)
|
||||||
|
)
|
||||||
|
return None
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import warnings
|
import warnings
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
@@ -8,8 +9,7 @@ from pydantic import BaseModel, Field
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
from src.core.utils.key_utils import generate_key_from_folder, is_valid_key
|
|
||||||
from src.server.database.service import AnimeSeriesService
|
from src.server.database.service import AnimeSeriesService
|
||||||
from src.server.exceptions import (
|
from src.server.exceptions import (
|
||||||
BadRequestError,
|
BadRequestError,
|
||||||
@@ -17,10 +17,15 @@ from src.server.exceptions import (
|
|||||||
ServerError,
|
ServerError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
)
|
)
|
||||||
from src.server.models.anime import AnimeMetadataUpdate
|
from src.server.models.anime import (
|
||||||
|
AnimeDetailsResponse,
|
||||||
|
AnimeSettingsRegenerateNfoResponse,
|
||||||
|
AnimeSettingsResponse,
|
||||||
|
AnimeSettingsUpdateRequest,
|
||||||
|
TMDBSearchResult,
|
||||||
|
)
|
||||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||||
from src.server.services.background_loader_service import BackgroundLoaderService
|
from src.server.services.background_loader_service import BackgroundLoaderService
|
||||||
from src.server.services.folder_rename_service import _scan_for_pre_existing_duplicates
|
|
||||||
from src.server.utils.dependencies import (
|
from src.server.utils.dependencies import (
|
||||||
get_anime_service,
|
get_anime_service,
|
||||||
get_background_loader_service,
|
get_background_loader_service,
|
||||||
@@ -29,7 +34,8 @@ from src.server.utils.dependencies import (
|
|||||||
get_series_app,
|
get_series_app,
|
||||||
require_auth,
|
require_auth,
|
||||||
)
|
)
|
||||||
from src.server.utils.filesystem import sanitize_folder_name
|
from src.server.utils.filesystem import is_safe_path, sanitize_folder_name
|
||||||
|
from src.server.utils.key_utils import generate_key_from_folder, is_valid_key
|
||||||
from src.server.utils.validators import validate_filter_value, validate_search_query
|
from src.server.utils.validators import validate_filter_value, validate_search_query
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -37,6 +43,31 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api/anime", tags=["anime"])
|
router = APIRouter(prefix="/api/anime", tags=["anime"])
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_folder_name(name: str, year: Optional[int]) -> str:
|
||||||
|
"""Compute sanitized folder name from display name and year.
|
||||||
|
|
||||||
|
If year is provided, strips any existing year in (YYYY) format to avoid
|
||||||
|
duplicates, then appends the new year. If year is None, preserves the
|
||||||
|
original name (with any existing year).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Display name of the series
|
||||||
|
year: Release year from provider, or None
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized folder name in format "Name (YYYY)" or just "Name"
|
||||||
|
"""
|
||||||
|
if year:
|
||||||
|
# Strip any existing year in (YYYY) format before adding new year
|
||||||
|
clean_name = re.sub(r'\s*\(\d{4}\)\s*$', '', name).strip()
|
||||||
|
folder_name_with_year = f"{clean_name} ({year})"
|
||||||
|
else:
|
||||||
|
# No new year provided, preserve original name (with any existing year)
|
||||||
|
folder_name_with_year = name
|
||||||
|
|
||||||
|
return sanitize_folder_name(folder_name_with_year)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def get_anime_status(
|
async def get_anime_status(
|
||||||
_auth: dict = Depends(require_auth),
|
_auth: dict = Depends(require_auth),
|
||||||
@@ -77,26 +108,14 @@ async def get_anime_status(
|
|||||||
|
|
||||||
|
|
||||||
class DuplicateFolderGroup(BaseModel):
|
class DuplicateFolderGroup(BaseModel):
|
||||||
"""A group of duplicate folders for the same series.
|
"""Placeholder - duplicates functionality removed."""
|
||||||
|
|
||||||
Attributes:
|
|
||||||
key: Series key (provider-assigned unique identifier)
|
|
||||||
folders: List of folder names that are duplicates
|
|
||||||
folder_count: Number of duplicate folders
|
|
||||||
"""
|
|
||||||
key: str = Field(..., description="Series key (unique identifier)")
|
key: str = Field(..., description="Series key (unique identifier)")
|
||||||
folders: List[str] = Field(..., description="List of duplicate folder names")
|
folders: List[str] = Field(..., description="List of duplicate folder names")
|
||||||
folder_count: int = Field(..., description="Number of duplicate folders")
|
folder_count: int = Field(..., description="Number of duplicate folders")
|
||||||
|
|
||||||
|
|
||||||
class DuplicateFoldersResponse(BaseModel):
|
class DuplicateFoldersResponse(BaseModel):
|
||||||
"""Response model for duplicate folders listing.
|
"""Placeholder - duplicates functionality removed."""
|
||||||
|
|
||||||
Attributes:
|
|
||||||
total_groups: Total number of duplicate groups found
|
|
||||||
duplicate_groups: List of duplicate folder groups
|
|
||||||
message: Human-readable summary
|
|
||||||
"""
|
|
||||||
total_groups: int = Field(..., description="Total number of duplicate groups")
|
total_groups: int = Field(..., description="Total number of duplicate groups")
|
||||||
duplicate_groups: List[DuplicateFolderGroup] = Field(
|
duplicate_groups: List[DuplicateFolderGroup] = Field(
|
||||||
..., description="List of duplicate folder groups"
|
..., description="List of duplicate folder groups"
|
||||||
@@ -110,64 +129,13 @@ async def get_duplicate_folders(
|
|||||||
) -> DuplicateFoldersResponse:
|
) -> DuplicateFoldersResponse:
|
||||||
"""List all pre-existing duplicate folder groups.
|
"""List all pre-existing duplicate folder groups.
|
||||||
|
|
||||||
Scans the anime directory for folders with tvshow.nfo files that
|
Note: Duplicate folder scanning has been removed. Returns empty response.
|
||||||
map to the same series key. Returns groups of duplicates for
|
|
||||||
manual review and cleanup.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
DuplicateFoldersResponse with groups of duplicate folders
|
|
||||||
|
|
||||||
Note:
|
|
||||||
Not all duplicate folders are safe to merge - some may belong
|
|
||||||
to different releases (e.g., dubbed vs. subbed). Review carefully
|
|
||||||
before taking action.
|
|
||||||
"""
|
"""
|
||||||
try:
|
return DuplicateFoldersResponse(
|
||||||
if not settings.anime_directory:
|
total_groups=0,
|
||||||
return DuplicateFoldersResponse(
|
duplicate_groups=[],
|
||||||
total_groups=0,
|
message="Duplicate folder scanning has been removed.",
|
||||||
duplicate_groups=[],
|
)
|
||||||
message="Anime directory not configured",
|
|
||||||
)
|
|
||||||
|
|
||||||
anime_dir = Path(settings.anime_directory)
|
|
||||||
if not anime_dir.is_dir():
|
|
||||||
return DuplicateFoldersResponse(
|
|
||||||
total_groups=0,
|
|
||||||
duplicate_groups=[],
|
|
||||||
message=f"Anime directory not found: {anime_dir}",
|
|
||||||
)
|
|
||||||
|
|
||||||
duplicates = _scan_for_pre_existing_duplicates(anime_dir)
|
|
||||||
|
|
||||||
groups = [
|
|
||||||
DuplicateFolderGroup(
|
|
||||||
key=dup.key,
|
|
||||||
folders=dup.folders,
|
|
||||||
folder_count=dup.count,
|
|
||||||
)
|
|
||||||
for dup in duplicates
|
|
||||||
]
|
|
||||||
|
|
||||||
if groups:
|
|
||||||
message = (
|
|
||||||
f"Found {len(groups)} duplicate group(s). "
|
|
||||||
"Review carefully - some duplicates may be different releases "
|
|
||||||
"(e.g., dubbed vs. subbed)."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
message = "No duplicate folders found."
|
|
||||||
|
|
||||||
return DuplicateFoldersResponse(
|
|
||||||
total_groups=len(groups),
|
|
||||||
duplicate_groups=groups,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Failed to scan for duplicate folders: %s", str(exc))
|
|
||||||
raise ServerError(
|
|
||||||
message=f"Failed to scan for duplicates: {str(exc)}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
class AnimeSummary(BaseModel):
|
class AnimeSummary(BaseModel):
|
||||||
@@ -487,11 +455,11 @@ async def trigger_rescan(
|
|||||||
}
|
}
|
||||||
except AnimeServiceError as e:
|
except AnimeServiceError as e:
|
||||||
raise ServerError(
|
raise ServerError(
|
||||||
message=f"Rescan failed: {str(e)}"
|
message=str(e)
|
||||||
) from e
|
) from e
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ServerError(
|
raise ServerError(
|
||||||
message="Failed to start rescan"
|
message=f"Failed to start rescan: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@@ -828,18 +796,9 @@ async def add_series(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Could not fetch year for %s: %s", key, e)
|
logger.warning("Could not fetch year for %s: %s", key, e)
|
||||||
|
|
||||||
# Create folder name with year if available
|
# Step B: Compute sanitized folder name with year (deduplicates if year already in name)
|
||||||
if year:
|
|
||||||
year_suffix = f" ({year})"
|
|
||||||
if name.endswith(year_suffix):
|
|
||||||
folder_name_with_year = name
|
|
||||||
else:
|
|
||||||
folder_name_with_year = f"{name}{year_suffix}"
|
|
||||||
else:
|
|
||||||
folder_name_with_year = name
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
folder = sanitize_folder_name(folder_name_with_year)
|
folder = _compute_folder_name(name, year)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -848,7 +807,37 @@ async def add_series(
|
|||||||
|
|
||||||
db_id = None
|
db_id = None
|
||||||
|
|
||||||
# Step C: Save to database if available
|
# Step C: Create folder on disk if it doesn't exist, and rename if needed
|
||||||
|
# Determine the anime directory path
|
||||||
|
anime_dir = settings.anime_directory if hasattr(settings, 'anime_directory') else None
|
||||||
|
current_folder_on_disk = None
|
||||||
|
|
||||||
|
if anime_dir:
|
||||||
|
import os
|
||||||
|
anime_path = os.path.join(anime_dir, folder)
|
||||||
|
|
||||||
|
# Check if an existing folder (without year) needs renaming
|
||||||
|
# Look for folder that matches name without year
|
||||||
|
if year:
|
||||||
|
potential_old_name = sanitize_folder_name(name)
|
||||||
|
potential_old_path = os.path.join(anime_dir, potential_old_name)
|
||||||
|
if potential_old_path != anime_path and os.path.exists(potential_old_path):
|
||||||
|
current_folder_on_disk = potential_old_name
|
||||||
|
logger.info(
|
||||||
|
"Found existing folder without year for %s: %s, renaming to %s",
|
||||||
|
key,
|
||||||
|
potential_old_name,
|
||||||
|
folder
|
||||||
|
)
|
||||||
|
elif not os.path.exists(anime_path):
|
||||||
|
# No existing folder to rename, create new one
|
||||||
|
os.makedirs(anime_path, exist_ok=True)
|
||||||
|
else:
|
||||||
|
# No year, just ensure folder exists
|
||||||
|
if not os.path.exists(anime_path):
|
||||||
|
os.makedirs(anime_path, exist_ok=True)
|
||||||
|
|
||||||
|
# Step D: Save to database if available
|
||||||
if db is not None:
|
if db is not None:
|
||||||
# Check if series already exists in database
|
# Check if series already exists in database
|
||||||
existing = await AnimeSeriesService.get_by_key(db, key)
|
existing = await AnimeSeriesService.get_by_key(db, key)
|
||||||
@@ -894,18 +883,18 @@ async def add_series(
|
|||||||
|
|
||||||
# Step D: Add to SerieList (in-memory only, no folder creation)
|
# Step D: Add to SerieList (in-memory only, no folder creation)
|
||||||
if series_app and hasattr(series_app, "list"):
|
if series_app and hasattr(series_app, "list"):
|
||||||
serie = Serie(
|
from src.server.database.models import AnimeSeries
|
||||||
|
anime = AnimeSeries(
|
||||||
key=key,
|
key=key,
|
||||||
name=name,
|
name=name,
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder=folder,
|
folder=folder,
|
||||||
episodeDict={},
|
|
||||||
year=year
|
year=year
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add to in-memory cache without creating folder on disk
|
# Add to in-memory cache without creating folder on disk
|
||||||
if hasattr(series_app.list, 'keyDict'):
|
if hasattr(series_app.list, 'keyDict'):
|
||||||
series_app.list.keyDict[key] = serie
|
series_app.list.keyDict[key] = anime
|
||||||
logger.info(
|
logger.info(
|
||||||
"Added series to in-memory cache: %s (key=%s, folder=%s, year=%s)",
|
"Added series to in-memory cache: %s (key=%s, folder=%s, year=%s)",
|
||||||
name,
|
name,
|
||||||
@@ -914,7 +903,32 @@ async def add_series(
|
|||||||
year
|
year
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step E: Queue background loading task for episodes, NFO, and images
|
# Step E: Rename existing folder if needed (e.g., folder existed without year)
|
||||||
|
if current_folder_on_disk:
|
||||||
|
try:
|
||||||
|
renamed = await anime_service.rename_folder_if_needed(
|
||||||
|
key=key,
|
||||||
|
current_folder=current_folder_on_disk,
|
||||||
|
target_folder=folder,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
if renamed:
|
||||||
|
logger.info(
|
||||||
|
"Successfully renamed folder for %s: %s -> %s",
|
||||||
|
key,
|
||||||
|
current_folder_on_disk,
|
||||||
|
folder
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to rename folder for %s: %s -> %s: %s",
|
||||||
|
key,
|
||||||
|
current_folder_on_disk,
|
||||||
|
folder,
|
||||||
|
e
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step F: Queue background loading task for episodes, NFO, and images
|
||||||
try:
|
try:
|
||||||
await background_loader.add_series_loading_task(
|
await background_loader.add_series_loading_task(
|
||||||
key=key,
|
key=key,
|
||||||
@@ -935,16 +949,13 @@ async def add_series(
|
|||||||
e
|
e
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step F: Scan missing episodes immediately if background loader is not running
|
# Step G: Scan missing episodes immediately
|
||||||
# Uses existing SerieScanner and AnimeService sync to avoid duplicates
|
# Always scan synchronously to ensure episodes are available when
|
||||||
|
# get_anime is called right after add_series returns.
|
||||||
|
# Background loader handles any additional work and rescan logic.
|
||||||
try:
|
try:
|
||||||
loader_running = bool(
|
|
||||||
background_loader.worker_tasks
|
|
||||||
and any(not t.done() for t in background_loader.worker_tasks)
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
not loader_running
|
series_app
|
||||||
and series_app
|
|
||||||
and hasattr(series_app, "serie_scanner")
|
and hasattr(series_app, "serie_scanner")
|
||||||
):
|
):
|
||||||
missing_episodes = series_app.serie_scanner.scan_single_series(
|
missing_episodes = series_app.serie_scanner.scan_single_series(
|
||||||
@@ -1097,7 +1108,8 @@ async def get_loading_status(
|
|||||||
@router.get("/{anime_id}", response_model=AnimeDetail)
|
@router.get("/{anime_id}", response_model=AnimeDetail)
|
||||||
async def get_anime(
|
async def get_anime(
|
||||||
anime_id: str,
|
anime_id: str,
|
||||||
series_app: Optional[Any] = Depends(get_series_app)
|
series_app: Optional[Any] = Depends(get_series_app),
|
||||||
|
db: Optional[AsyncSession] = Depends(get_optional_database_session),
|
||||||
) -> AnimeDetail:
|
) -> AnimeDetail:
|
||||||
"""Return detailed information about a specific series.
|
"""Return detailed information about a specific series.
|
||||||
|
|
||||||
@@ -1164,6 +1176,22 @@ async def get_anime(
|
|||||||
|
|
||||||
episodes: List[str] = []
|
episodes: List[str] = []
|
||||||
episode_dict = getattr(found, "episodeDict", {}) or {}
|
episode_dict = getattr(found, "episodeDict", {}) or {}
|
||||||
|
|
||||||
|
# If in-memory episodeDict is empty, try fetching from database directly
|
||||||
|
if not episode_dict and db is not None:
|
||||||
|
try:
|
||||||
|
db_series = await AnimeSeriesService.get_by_key(
|
||||||
|
db, anime_id, with_episodes=True
|
||||||
|
)
|
||||||
|
if db_series:
|
||||||
|
episode_dict = db_series.episodeDict or {}
|
||||||
|
except Exception as db_exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to fetch episodes from DB for '%s': %s",
|
||||||
|
anime_id,
|
||||||
|
db_exc,
|
||||||
|
)
|
||||||
|
|
||||||
for season, episode_numbers in episode_dict.items():
|
for season, episode_numbers in episode_dict.items():
|
||||||
for episode in episode_numbers:
|
for episode in episode_numbers:
|
||||||
episodes.append(f"{season}-{episode}")
|
episodes.append(f"{season}-{episode}")
|
||||||
@@ -1179,39 +1207,41 @@ async def get_anime(
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to retrieve series details for '%s': %s",
|
||||||
|
anime_id,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Failed to retrieve series details",
|
detail="Failed to retrieve series details",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
# Maximum allowed input size for security
|
@router.get("/{anime_key}/details", response_model=AnimeDetailsResponse)
|
||||||
MAX_INPUT_LENGTH = 100000 # 100KB
|
async def get_anime_details(
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{anime_key}")
|
|
||||||
async def update_anime_metadata(
|
|
||||||
anime_key: str,
|
anime_key: str,
|
||||||
body: AnimeMetadataUpdate,
|
|
||||||
_auth: dict = Depends(require_auth),
|
_auth: dict = Depends(require_auth),
|
||||||
db: AsyncSession = Depends(get_database_session),
|
db: AsyncSession = Depends(get_database_session),
|
||||||
) -> dict:
|
) -> AnimeDetailsResponse:
|
||||||
"""Update anime metadata (key, tmdb_id, tvdb_id).
|
"""Get detailed information about a specific anime series for the edit modal.
|
||||||
|
|
||||||
|
Returns comprehensive series metadata including TMDB/TVDB IDs, NFO status,
|
||||||
|
and other details needed to pre-fill the edit form.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
anime_key: Current series key to update
|
anime_key: Series key (primary identifier)
|
||||||
body: Fields to update (all optional)
|
|
||||||
_auth: Authentication dependency
|
_auth: Authentication dependency
|
||||||
db: Database session
|
db: Database session
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Updated series metadata
|
AnimeDetailsResponse: Full series details for edit modal
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException 404: Series not found
|
HTTPException 404: Series not found
|
||||||
HTTPException 409: Key conflict (new key already exists)
|
|
||||||
HTTPException 422: Validation error
|
|
||||||
"""
|
"""
|
||||||
|
# Get series from database (authoritative source for IDs and NFO status)
|
||||||
series = await AnimeSeriesService.get_by_key(db, anime_key)
|
series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
if not series:
|
if not series:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1219,44 +1249,509 @@ async def update_anime_metadata(
|
|||||||
detail=f"Series with key '{anime_key}' not found",
|
detail=f"Series with key '{anime_key}' not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
updates = {}
|
# Format timestamps
|
||||||
|
nfo_created = None
|
||||||
|
nfo_updated = None
|
||||||
|
if series.nfo_created_at:
|
||||||
|
nfo_created = series.nfo_created_at.isoformat()
|
||||||
|
if series.nfo_updated_at:
|
||||||
|
nfo_updated = series.nfo_updated_at.isoformat()
|
||||||
|
|
||||||
if body.key is not None and body.key != anime_key:
|
return AnimeDetailsResponse(
|
||||||
existing = await AnimeSeriesService.get_by_key(db, body.key)
|
key=series.key,
|
||||||
if existing:
|
name=series.name,
|
||||||
raise HTTPException(
|
folder=series.folder,
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
year=series.year,
|
||||||
detail=f"A series with key '{body.key}' already exists",
|
status=None, # Status not stored in DB, only in NFO/TMDB
|
||||||
)
|
plot=None, # Plot not stored in DB, only in NFO/TMDB
|
||||||
updates["key"] = body.key
|
genres=[],
|
||||||
|
studio=[],
|
||||||
if body.tmdb_id is not None:
|
premiered=None,
|
||||||
updates["tmdb_id"] = body.tmdb_id
|
rating=None,
|
||||||
|
rating_votes=None,
|
||||||
if body.tvdb_id is not None:
|
tmdb_id=series.tmdb_id,
|
||||||
updates["tvdb_id"] = body.tvdb_id
|
tvdb_id=series.tvdb_id,
|
||||||
|
has_nfo=series.has_nfo,
|
||||||
if not updates:
|
nfo_created_at=nfo_created,
|
||||||
return {
|
nfo_updated_at=nfo_updated,
|
||||||
"key": series.key,
|
|
||||||
"tmdb_id": series.tmdb_id,
|
|
||||||
"tvdb_id": series.tvdb_id,
|
|
||||||
"message": "No changes",
|
|
||||||
}
|
|
||||||
|
|
||||||
updated = await AnimeSeriesService.update(db, series.id, **updates)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Updated metadata for '%s': %s",
|
|
||||||
anime_key,
|
|
||||||
updates,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
|
||||||
"key": updated.key,
|
|
||||||
"tmdb_id": updated.tmdb_id,
|
|
||||||
"tvdb_id": updated.tvdb_id,
|
|
||||||
"message": "Metadata updated successfully",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@router.get("/{anime_key}/tmdb-search", response_model=List[TMDBSearchResult])
|
||||||
|
async def search_tmdb_for_series(
|
||||||
|
anime_key: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: AsyncSession = Depends(get_database_session),
|
||||||
|
) -> List[TMDBSearchResult]:
|
||||||
|
"""Search TMDB for a series by its name to find matching metadata.
|
||||||
|
|
||||||
|
Used by the edit modal's "Fetch from TMDB" feature when no TMDB ID
|
||||||
|
is set. Searches TMDB using the series name and returns matches.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Series key to look up
|
||||||
|
_auth: Authentication dependency
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[TMDBSearchResult]: Matching TMDB results
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Series not found
|
||||||
|
HTTPException 400: TMDB not configured
|
||||||
|
"""
|
||||||
|
from src.server.nfo.tmdb_client import TMDBClient
|
||||||
|
|
||||||
|
# Get series from database
|
||||||
|
series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
|
if not series:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series with key '{anime_key}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if TMDB is configured
|
||||||
|
if not settings.tmdb_api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="TMDB API key not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Search TMDB by series name
|
||||||
|
tmdb_client = TMDBClient(api_key=settings.tmdb_api_key)
|
||||||
|
results = await tmdb_client.search_tv_series(series.name)
|
||||||
|
|
||||||
|
return [
|
||||||
|
TMDBSearchResult(
|
||||||
|
tmdb_id=r["id"],
|
||||||
|
title=r.get("name", ""),
|
||||||
|
year=int(r.get("first_air_date", "0000")[:4]) if r.get("first_air_date") else None,
|
||||||
|
overview=r.get("overview"),
|
||||||
|
vote_average=r.get("vote_average"),
|
||||||
|
)
|
||||||
|
for r in results
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Anime Settings endpoints (rename of NFO Diagnostic page)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_anime_settings_payload(
|
||||||
|
anime_key: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
anime_service: AnimeService,
|
||||||
|
) -> AnimeSettingsResponse:
|
||||||
|
"""Build the AnimeSettingsResponse payload for a given series.
|
||||||
|
|
||||||
|
Combines data from the in-memory SeriesApp (folder/name/site/year) with
|
||||||
|
the authoritative database row (tmdb_id, tvdb_id, has_nfo, nfo_*,
|
||||||
|
loading_status) and episode counts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Series unique key
|
||||||
|
db: Database session
|
||||||
|
anime_service: AnimeService for in-memory fallback
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AnimeSettingsResponse with all editable fields populated
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: If series not found
|
||||||
|
"""
|
||||||
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||||
|
|
||||||
|
db_series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
|
if not db_series:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series not found: {anime_key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Episode counts (authoritative DB source)
|
||||||
|
episodes = await EpisodeService.get_by_series(db, db_series.id)
|
||||||
|
episode_count = len(episodes)
|
||||||
|
missing_episode_count = sum(
|
||||||
|
1 for ep in episodes if not ep.is_downloaded
|
||||||
|
)
|
||||||
|
|
||||||
|
# In-memory fallback for folder/name/site/year (DB is authoritative)
|
||||||
|
name = db_series.name
|
||||||
|
site = db_series.site
|
||||||
|
folder = db_series.folder
|
||||||
|
year = db_series.year
|
||||||
|
if hasattr(anime_service, "_app") and hasattr(anime_service._app, "list"):
|
||||||
|
try:
|
||||||
|
for serie in anime_service._app.list.GetList():
|
||||||
|
if getattr(serie, "key", None) == anime_key:
|
||||||
|
name = getattr(serie, "name", name) or name
|
||||||
|
site = getattr(serie, "site", site) or site
|
||||||
|
folder = getattr(serie, "folder", folder) or folder
|
||||||
|
year = getattr(serie, "year", year) or year
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
nfo_created = (
|
||||||
|
db_series.nfo_created_at.isoformat()
|
||||||
|
if db_series.nfo_created_at else None
|
||||||
|
)
|
||||||
|
nfo_updated = (
|
||||||
|
db_series.nfo_updated_at.isoformat()
|
||||||
|
if db_series.nfo_updated_at else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return AnimeSettingsResponse(
|
||||||
|
key=db_series.key,
|
||||||
|
name=name,
|
||||||
|
site=site,
|
||||||
|
folder=folder,
|
||||||
|
year=year,
|
||||||
|
tmdb_id=db_series.tmdb_id,
|
||||||
|
tvdb_id=db_series.tvdb_id,
|
||||||
|
has_nfo=bool(db_series.has_nfo),
|
||||||
|
nfo_path=db_series.nfo_path,
|
||||||
|
nfo_created_at=nfo_created,
|
||||||
|
nfo_updated_at=nfo_updated,
|
||||||
|
loading_status=db_series.loading_status,
|
||||||
|
episode_count=episode_count,
|
||||||
|
missing_episode_count=missing_episode_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{anime_key}/settings", response_model=AnimeSettingsResponse)
|
||||||
|
async def get_anime_settings(
|
||||||
|
anime_key: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: AsyncSession = Depends(get_database_session),
|
||||||
|
anime_service: AnimeService = Depends(get_anime_service),
|
||||||
|
) -> AnimeSettingsResponse:
|
||||||
|
"""Return the full Anime Settings payload for a single series.
|
||||||
|
|
||||||
|
Powers the per-anime settings page reached from the right-click context
|
||||||
|
menu. Returns every field the user can view or edit, plus episode counts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Series unique key
|
||||||
|
_auth: Authentication dependency
|
||||||
|
db: Database session
|
||||||
|
anime_service: AnimeService dependency
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AnimeSettingsResponse with key, name, site, folder, year, tmdb_id,
|
||||||
|
tvdb_id, NFO status and episode counts.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: If series not found.
|
||||||
|
"""
|
||||||
|
return await _build_anime_settings_payload(anime_key, db, anime_service)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_folder_value(folder: str, anime_dir: Optional[str]) -> str:
|
||||||
|
"""Validate and sanitize a folder name.
|
||||||
|
|
||||||
|
Raises HTTPException(422) on empty / invalid folder, 422 on path
|
||||||
|
traversal, 422 if folder escapes anime_dir.
|
||||||
|
"""
|
||||||
|
if not folder or not folder.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Folder cannot be empty",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
sanitized = sanitize_folder_name(folder)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Invalid folder name: {exc}",
|
||||||
|
)
|
||||||
|
if anime_dir:
|
||||||
|
full_path = os.path.join(anime_dir, sanitized)
|
||||||
|
if not is_safe_path(anime_dir, full_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Folder path is not safe",
|
||||||
|
)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_tmdb_id(tmdb_id: Optional[int]) -> None:
|
||||||
|
"""Validate TMDB ID is positive and within 10 digits."""
|
||||||
|
if tmdb_id is None:
|
||||||
|
return
|
||||||
|
if tmdb_id <= 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="TMDB ID must be a positive integer",
|
||||||
|
)
|
||||||
|
if tmdb_id > 9999999999:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="TMDB ID exceeds maximum length (10 digits)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_tvdb_id(tvdb_id: Optional[int]) -> None:
|
||||||
|
"""Validate TVDB ID is positive and within 10 digits."""
|
||||||
|
if tvdb_id is None:
|
||||||
|
return
|
||||||
|
if tvdb_id <= 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="TVDB ID must be a positive integer",
|
||||||
|
)
|
||||||
|
if tvdb_id > 9999999999:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="TVDB ID exceeds maximum length (10 digits)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{anime_key}/settings", response_model=AnimeSettingsResponse)
|
||||||
|
async def update_anime_settings(
|
||||||
|
anime_key: str,
|
||||||
|
request: AnimeSettingsUpdateRequest,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: AsyncSession = Depends(get_database_session),
|
||||||
|
anime_service: AnimeService = Depends(get_anime_service),
|
||||||
|
) -> AnimeSettingsResponse:
|
||||||
|
"""Update editable fields for a single anime series.
|
||||||
|
|
||||||
|
Performs validation on each supplied field, writes the changes to the
|
||||||
|
database (and optionally to tvshow.nfo when ``apply_to_nfo`` is true),
|
||||||
|
then returns the fresh payload.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Series unique key (path param)
|
||||||
|
request: Update payload. All fields optional except as documented
|
||||||
|
in AnimeSettingsUpdateRequest.
|
||||||
|
_auth: Authentication dependency
|
||||||
|
db: Database session
|
||||||
|
anime_service: AnimeService for disk rename + NFO regeneration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AnimeSettingsResponse: Updated payload reflecting new values.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Series not found.
|
||||||
|
HTTPException 422: Validation failure (empty name, invalid folder,
|
||||||
|
non-positive tmdb_id/tvdb_id, oversized id, path traversal).
|
||||||
|
"""
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
db_series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
|
if not db_series:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series not found: {anime_key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Field-level validation
|
||||||
|
anime_dir = (
|
||||||
|
settings.anime_directory
|
||||||
|
if hasattr(settings, "anime_directory") else None
|
||||||
|
)
|
||||||
|
|
||||||
|
update_fields: dict = {}
|
||||||
|
|
||||||
|
if request.name is not None:
|
||||||
|
new_name = request.name.strip()
|
||||||
|
if not new_name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Name cannot be empty",
|
||||||
|
)
|
||||||
|
if len(new_name) > 500:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Name exceeds 500 characters",
|
||||||
|
)
|
||||||
|
update_fields["name"] = new_name
|
||||||
|
|
||||||
|
if request.folder is not None:
|
||||||
|
update_fields["folder"] = _validate_folder_value(
|
||||||
|
request.folder, anime_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_tmdb_id(request.tmdb_id)
|
||||||
|
if request.tmdb_id is not None:
|
||||||
|
update_fields["tmdb_id"] = request.tmdb_id
|
||||||
|
|
||||||
|
_validate_tvdb_id(request.tvdb_id)
|
||||||
|
if request.tvdb_id is not None:
|
||||||
|
update_fields["tvdb_id"] = request.tvdb_id
|
||||||
|
|
||||||
|
if request.site is not None:
|
||||||
|
update_fields["site"] = request.site
|
||||||
|
|
||||||
|
if not update_fields and not request.apply_to_nfo and not request.rename_disk:
|
||||||
|
# Nothing to do — return current state
|
||||||
|
return await _build_anime_settings_payload(anime_key, db, anime_service)
|
||||||
|
|
||||||
|
# Apply DB update
|
||||||
|
if update_fields:
|
||||||
|
await AnimeSeriesService.update(
|
||||||
|
db, db_series.id, **update_fields
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(db_series)
|
||||||
|
logger.info(
|
||||||
|
"Updated anime settings for %s: %s",
|
||||||
|
anime_key,
|
||||||
|
sorted(update_fields.keys()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update in-memory SerieList so the UI sees the changes immediately
|
||||||
|
if hasattr(anime_service, "_app") and hasattr(anime_service._app, "list"):
|
||||||
|
try:
|
||||||
|
in_mem = anime_service._app.list.keyDict.get(anime_key)
|
||||||
|
if in_mem is not None:
|
||||||
|
if "name" in update_fields:
|
||||||
|
in_mem.name = update_fields["name"]
|
||||||
|
if "folder" in update_fields:
|
||||||
|
in_mem.folder = update_fields["folder"]
|
||||||
|
if "site" in update_fields:
|
||||||
|
in_mem.site = update_fields["site"]
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Could not update in-memory serie: %s", exc)
|
||||||
|
|
||||||
|
# Optionally rename the on-disk folder
|
||||||
|
if request.rename_disk and "folder" in update_fields:
|
||||||
|
try:
|
||||||
|
await anime_service.rename_folder_if_needed(
|
||||||
|
key=anime_key,
|
||||||
|
current_folder=db_series.folder,
|
||||||
|
target_folder=update_fields["folder"],
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Folder rename failed for %s: %s",
|
||||||
|
anime_key,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optionally regenerate tvshow.nfo with the new values
|
||||||
|
if request.apply_to_nfo:
|
||||||
|
if not db_series.tmdb_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
"Cannot regenerate NFO without a TMDB ID. "
|
||||||
|
"Set tmdb_id first or use the Repair flow."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# Lazy-import to avoid heavy deps when not used
|
||||||
|
from src.server.api.nfo import _create_or_update_nfo
|
||||||
|
|
||||||
|
series_data = {
|
||||||
|
"key": anime_key,
|
||||||
|
"name": db_series.name,
|
||||||
|
"folder": db_series.folder,
|
||||||
|
"tmdb_id": db_series.tmdb_id,
|
||||||
|
}
|
||||||
|
await _create_or_update_nfo(
|
||||||
|
key=anime_key,
|
||||||
|
folder=db_series.folder,
|
||||||
|
tmdb_id=db_series.tmdb_id,
|
||||||
|
series_data=series_data,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"NFO regeneration failed for %s: %s",
|
||||||
|
anime_key,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"NFO regeneration failed: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return await _build_anime_settings_payload(anime_key, db, anime_service)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{anime_key}/regenerate-nfo",
|
||||||
|
response_model=AnimeSettingsRegenerateNfoResponse,
|
||||||
|
)
|
||||||
|
async def regenerate_anime_nfo(
|
||||||
|
anime_key: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: AsyncSession = Depends(get_database_session),
|
||||||
|
anime_service: AnimeService = Depends(get_anime_service),
|
||||||
|
) -> AnimeSettingsRegenerateNfoResponse:
|
||||||
|
"""Regenerate tvshow.nfo for a single anime using TMDB.
|
||||||
|
|
||||||
|
Thin wrapper around the existing NFO repair flow, exposed under
|
||||||
|
/api/anime/{key}/ for symmetry with the settings page UI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Series unique key
|
||||||
|
_auth: Authentication dependency
|
||||||
|
db: Database session
|
||||||
|
anime_service: AnimeService dependency
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AnimeSettingsRegenerateNfoResponse with success flag, message,
|
||||||
|
regenerated nfo_path and the tags that were missing before.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Series not found.
|
||||||
|
HTTPException 400: No TMDB ID configured.
|
||||||
|
HTTPException 500: TMDB / NFO regeneration failure.
|
||||||
|
"""
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
db_series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
|
if not db_series:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series not found: {anime_key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
tmdb_id = db_series.tmdb_id
|
||||||
|
if not tmdb_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Series has no TMDB ID — set one before regenerating NFO",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.server.api.nfo import _create_or_update_nfo
|
||||||
|
|
||||||
|
series_data = {
|
||||||
|
"key": anime_key,
|
||||||
|
"name": db_series.name,
|
||||||
|
"folder": db_series.folder,
|
||||||
|
"tmdb_id": tmdb_id,
|
||||||
|
}
|
||||||
|
repaired_tags = await _create_or_update_nfo(
|
||||||
|
key=anime_key,
|
||||||
|
folder=db_series.folder,
|
||||||
|
tmdb_id=tmdb_id,
|
||||||
|
series_data=series_data,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("NFO regeneration failed for %s: %s", anime_key, exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"NFO regeneration failed: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return AnimeSettingsRegenerateNfoResponse(
|
||||||
|
success=True,
|
||||||
|
message=(
|
||||||
|
f"NFO regenerated. {len(repaired_tags)} tags updated."
|
||||||
|
if repaired_tags else "NFO already complete."
|
||||||
|
),
|
||||||
|
nfo_path=db_series.nfo_path,
|
||||||
|
repaired_tags=repaired_tags,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Authentication API endpoints for Aniworld."""
|
"""Authentication API endpoints for Aniworld."""
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import structlog
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi import status as http_status
|
from fastapi import status as http_status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
@@ -15,6 +16,9 @@ from src.server.models.auth import (
|
|||||||
from src.server.models.config import AppConfig
|
from src.server.models.config import AppConfig
|
||||||
from src.server.services.auth_service import AuthError, LockedOutError, auth_service
|
from src.server.services.auth_service import AuthError, LockedOutError, auth_service
|
||||||
from src.server.services.config_service import get_config_service
|
from src.server.services.config_service import get_config_service
|
||||||
|
from src.server.services.progress_service import ProgressType
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
# NOTE: import dependencies (optional_auth, security) lazily inside handlers
|
# NOTE: import dependencies (optional_auth, security) lazily inside handlers
|
||||||
# to avoid importing heavyweight modules (e.g. sqlalchemy) at import time.
|
# to avoid importing heavyweight modules (e.g. sqlalchemy) at import time.
|
||||||
@@ -76,8 +80,6 @@ async def setup_auth(req: SetupRequest):
|
|||||||
config.scheduler.schedule_days = req.scheduler_schedule_days
|
config.scheduler.schedule_days = req.scheduler_schedule_days
|
||||||
if req.scheduler_auto_download_after_rescan is not None:
|
if req.scheduler_auto_download_after_rescan is not None:
|
||||||
config.scheduler.auto_download_after_rescan = req.scheduler_auto_download_after_rescan
|
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
|
# Update logging configuration
|
||||||
if req.logging_level:
|
if req.logging_level:
|
||||||
@@ -116,6 +118,10 @@ async def setup_auth(req: SetupRequest):
|
|||||||
# Store master password hash in config's other field
|
# Store master password hash in config's other field
|
||||||
config.other['master_password_hash'] = password_hash
|
config.other['master_password_hash'] = password_hash
|
||||||
|
|
||||||
|
# Mark that loading has been initiated (used by middleware to prevent
|
||||||
|
# premature redirect to /login after setup)
|
||||||
|
config.other['loading_started'] = True
|
||||||
|
|
||||||
# Store anime directory in config's other field if provided
|
# Store anime directory in config's other field if provided
|
||||||
anime_directory = None
|
anime_directory = None
|
||||||
if req.anime_directory:
|
if req.anime_directory:
|
||||||
@@ -146,10 +152,7 @@ async def setup_auth(req: SetupRequest):
|
|||||||
# Trigger initialization in background task
|
# Trigger initialization in background task
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from src.server.services.initialization_service import (
|
from src.server.services.initialization_service import perform_initial_setup
|
||||||
perform_initial_setup,
|
|
||||||
perform_nfo_scan_if_needed,
|
|
||||||
)
|
|
||||||
from src.server.services.progress_service import get_progress_service
|
from src.server.services.progress_service import get_progress_service
|
||||||
|
|
||||||
progress_service = get_progress_service()
|
progress_service = get_progress_service()
|
||||||
@@ -160,12 +163,9 @@ async def setup_auth(req: SetupRequest):
|
|||||||
# Perform the initial series sync and mark as completed
|
# Perform the initial series sync and mark as completed
|
||||||
await perform_initial_setup(progress_service)
|
await perform_initial_setup(progress_service)
|
||||||
|
|
||||||
# Perform NFO scan if configured
|
|
||||||
await perform_nfo_scan_if_needed(progress_service)
|
|
||||||
|
|
||||||
# Start scheduler if anime_directory is now set
|
# Start scheduler if anime_directory is now set
|
||||||
try:
|
try:
|
||||||
from src.server.services.scheduler_service import (
|
from src.server.services.scheduler.scheduler_service import (
|
||||||
get_scheduler_service,
|
get_scheduler_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -180,7 +180,6 @@ async def setup_auth(req: SetupRequest):
|
|||||||
# Continue — scheduler failure should not break initialization
|
# Continue — scheduler failure should not break initialization
|
||||||
|
|
||||||
# Send completion event
|
# Send completion event
|
||||||
from src.server.services.progress_service import ProgressType
|
|
||||||
await progress_service.start_progress(
|
await progress_service.start_progress(
|
||||||
progress_id="initialization_complete",
|
progress_id="initialization_complete",
|
||||||
progress_type=ProgressType.SYSTEM,
|
progress_type=ProgressType.SYSTEM,
|
||||||
@@ -196,7 +195,6 @@ async def setup_auth(req: SetupRequest):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Send error event
|
# Send error event
|
||||||
from src.server.services.progress_service import ProgressType
|
|
||||||
await progress_service.start_progress(
|
await progress_service.start_progress(
|
||||||
progress_id="initialization_error",
|
progress_id="initialization_error",
|
||||||
progress_type=ProgressType.ERROR,
|
progress_type=ProgressType.ERROR,
|
||||||
@@ -214,8 +212,9 @@ async def setup_auth(req: SetupRequest):
|
|||||||
# Start initialization in background
|
# Start initialization in background
|
||||||
asyncio.create_task(run_initialization())
|
asyncio.create_task(run_initialization())
|
||||||
|
|
||||||
# Return redirect to loading page
|
# Return redirect to loading page with phase=initial
|
||||||
return {"status": "ok", "redirect": "/loading"}
|
# The loading page will show ONLY series_sync step, then redirect to /setup/unresolved
|
||||||
|
return {"status": "ok", "redirect": "/loading?phase=initial"}
|
||||||
# Note: Media scan is skipped during setup as it requires
|
# Note: Media scan is skipped during setup as it requires
|
||||||
# background_loader service which is only available during
|
# background_loader service which is only available during
|
||||||
# application lifespan. It will run on first application startup.
|
# application lifespan. It will run on first application startup.
|
||||||
@@ -246,13 +245,13 @@ def login(req: LoginRequest):
|
|||||||
# This prevents information leakage about system configuration
|
# This prevents information leakage about system configuration
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=http_status.HTTP_401_UNAUTHORIZED,
|
status_code=http_status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid credentials"
|
detail="invalid credentials"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
if not valid:
|
if not valid:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=http_status.HTTP_401_UNAUTHORIZED,
|
status_code=http_status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid credentials"
|
detail="invalid credentials"
|
||||||
)
|
)
|
||||||
|
|
||||||
token = auth_service.create_access_token(
|
token = auth_service.create_access_token(
|
||||||
|
|||||||
@@ -47,8 +47,19 @@ async def update_config(
|
|||||||
from src.config.settings import settings as app_settings
|
from src.config.settings import settings as app_settings
|
||||||
|
|
||||||
anime_dir_changed = False
|
anime_dir_changed = False
|
||||||
if update.other and update.other.get("anime_directory"):
|
other_data = update.other
|
||||||
anime_dir = update.other.get("anime_directory")
|
if isinstance(other_data, str):
|
||||||
|
try:
|
||||||
|
import ast
|
||||||
|
other_data = ast.literal_eval(other_data)
|
||||||
|
except (ValueError, SyntaxError):
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
other_data = json.loads(other_data)
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
other_data = None
|
||||||
|
if other_data and other_data.get("anime_directory"):
|
||||||
|
anime_dir = other_data.get("anime_directory")
|
||||||
if anime_dir and not app_settings.anime_directory:
|
if anime_dir and not app_settings.anime_directory:
|
||||||
app_settings.anime_directory = str(anime_dir)
|
app_settings.anime_directory = str(anime_dir)
|
||||||
anime_dir_changed = True
|
anime_dir_changed = True
|
||||||
@@ -57,7 +68,7 @@ async def update_config(
|
|||||||
# Start scheduler if anime_directory was just configured
|
# Start scheduler if anime_directory was just configured
|
||||||
if anime_dir_changed:
|
if anime_dir_changed:
|
||||||
try:
|
try:
|
||||||
from src.server.services.scheduler_service import (
|
from src.server.services.scheduler.scheduler_service import (
|
||||||
get_scheduler_service,
|
get_scheduler_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,17 +119,18 @@ def validate_config(
|
|||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.get("/backups", response_model=List[Dict[str, object]])
|
@router.get("/backups", response_model=Dict[str, List[Dict[str, object]]])
|
||||||
def list_backups(
|
def list_backups(
|
||||||
auth: dict = Depends(require_auth)
|
auth: dict = Depends(require_auth)
|
||||||
) -> List[Dict[str, object]]:
|
) -> Dict[str, List[Dict[str, object]]]:
|
||||||
"""List all available configuration backups.
|
"""List all available configuration backups.
|
||||||
|
|
||||||
Returns list of backup metadata including name, size, and created time.
|
Returns list of backup metadata including name, size, and created time.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
config_service = get_config_service()
|
config_service = get_config_service()
|
||||||
return config_service.list_backups()
|
backups = config_service.list_backups()
|
||||||
|
return {"backups": backups}
|
||||||
except ConfigServiceError as e:
|
except ConfigServiceError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
@@ -126,7 +138,7 @@ def list_backups(
|
|||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.post("/backups", response_model=Dict[str, str])
|
@router.post("/backups", response_model=Dict[str, str], status_code=status.HTTP_201_CREATED)
|
||||||
def create_backup(
|
def create_backup(
|
||||||
name: Optional[str] = None, auth: dict = Depends(require_auth)
|
name: Optional[str] = None, auth: dict = Depends(require_auth)
|
||||||
) -> Dict[str, str]:
|
) -> Dict[str, str]:
|
||||||
@@ -279,30 +291,15 @@ async def update_directory(
|
|||||||
|
|
||||||
config_service.save_config(app_config)
|
config_service.save_config(app_config)
|
||||||
|
|
||||||
# Sync series from data files to database
|
# Series are now loaded directly from database, no sync needed
|
||||||
sync_count = 0
|
logger.info(
|
||||||
try:
|
"Directory updated successfully",
|
||||||
import structlog
|
directory=directory
|
||||||
|
)
|
||||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
sync_count = await sync_legacy_series_to_db(directory, logger)
|
|
||||||
logger.info(
|
|
||||||
"Directory updated: synced series from data files",
|
|
||||||
directory=directory,
|
|
||||||
count=sync_count
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
# Log but don't fail the directory update if sync fails
|
|
||||||
import structlog
|
|
||||||
structlog.get_logger(__name__).warning(
|
|
||||||
"Failed to sync series after directory update",
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
|
|
||||||
response: Dict[str, Any] = {
|
response: Dict[str, Any] = {
|
||||||
"message": "Anime directory updated successfully",
|
"message": "Anime directory updated successfully",
|
||||||
"synced_series": sync_count
|
"synced_series": 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@@ -412,6 +409,44 @@ def reset_config(
|
|||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
# Unauthenticated admin reset endpoint for test isolation
|
||||||
|
@router.post("/admin/reset", response_model=Dict[str, str])
|
||||||
|
def admin_reset_config() -> Dict[str, str]:
|
||||||
|
"""Reset application to unconfigured state.
|
||||||
|
|
||||||
|
WARNING: This endpoint has no authentication and should only be used
|
||||||
|
for testing. It clears the master password hash and resets auth state.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success message
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config_service = get_config_service()
|
||||||
|
|
||||||
|
# Load current config
|
||||||
|
config = config_service.load_config()
|
||||||
|
|
||||||
|
# Clear master password hash from other
|
||||||
|
if "master_password_hash" in config.other:
|
||||||
|
del config.other["master_password_hash"]
|
||||||
|
|
||||||
|
# Save config
|
||||||
|
config_service.save_config(config)
|
||||||
|
|
||||||
|
# Reset auth service in-memory state
|
||||||
|
from src.server.services.auth_service import auth_service
|
||||||
|
auth_service.reset()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Application reset to unconfigured state successfully"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to reset application: {e}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tmdb/validate", response_model=Dict[str, Any])
|
@router.post("/tmdb/validate", response_model=Dict[str, Any])
|
||||||
async def validate_tmdb_key(
|
async def validate_tmdb_key(
|
||||||
api_key_data: Dict[str, str], auth: dict = Depends(require_auth)
|
api_key_data: Dict[str, str], auth: dict = Depends(require_auth)
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ async def clear_pending(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{item_id}", status_code=status.HTTP_200_OK)
|
||||||
async def remove_from_queue(
|
async def remove_from_queue(
|
||||||
item_id: str = Path(..., description="Download item ID to remove"),
|
item_id: str = Path(..., description="Download item ID to remove"),
|
||||||
_: dict = Depends(require_auth),
|
_: dict = Depends(require_auth),
|
||||||
@@ -246,6 +246,9 @@ async def remove_from_queue(
|
|||||||
Args:
|
Args:
|
||||||
item_id: Unique identifier of the download item to remove
|
item_id: Unique identifier of the download item to remove
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Status message confirming removal
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: 401 if not authenticated, 404 if item not found,
|
HTTPException: 401 if not authenticated, 404 if item not found,
|
||||||
500 on service error
|
500 on service error
|
||||||
@@ -260,6 +263,12 @@ async def remove_from_queue(
|
|||||||
resource_id=item_id
|
resource_id=item_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Removed item {item_id} from queue",
|
||||||
|
"removed_id": item_id,
|
||||||
|
}
|
||||||
|
|
||||||
except DownloadServiceError as e:
|
except DownloadServiceError as e:
|
||||||
raise BadRequestError(message=str(e))
|
raise BadRequestError(message=str(e))
|
||||||
except (BadRequestError, NotFoundError, ServerError):
|
except (BadRequestError, NotFoundError, ServerError):
|
||||||
@@ -339,9 +348,10 @@ async def start_queue(
|
|||||||
result = await download_service.start_queue_processing()
|
result = await download_service.start_queue_processing()
|
||||||
|
|
||||||
if result is None:
|
if result is None:
|
||||||
raise BadRequestError(
|
return {
|
||||||
message="No pending downloads in queue"
|
"status": "success",
|
||||||
)
|
"message": "No pending downloads in queue",
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@@ -426,6 +436,48 @@ async def pause_queue(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/resume", status_code=status.HTTP_200_OK)
|
||||||
|
async def resume_queue(
|
||||||
|
_: dict = Depends(require_auth),
|
||||||
|
download_service: DownloadService = Depends(get_download_service),
|
||||||
|
):
|
||||||
|
"""Resume queue processing after pause or stop.
|
||||||
|
|
||||||
|
Restarts queue processing from the paused/stopped state. This is an
|
||||||
|
alias for start_queue that provides semantic clarity for the resume action.
|
||||||
|
|
||||||
|
Requires authentication.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Status message confirming queue processing resumed
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 401 if not authenticated, 500 on service error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await download_service.start_queue_processing()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "No pending downloads in queue",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Queue processing resumed",
|
||||||
|
}
|
||||||
|
|
||||||
|
except DownloadServiceError as e:
|
||||||
|
raise BadRequestError(message=str(e))
|
||||||
|
except (BadRequestError, NotFoundError, ServerError):
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise ServerError(
|
||||||
|
message=f"Failed to resume queue processing: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reorder", status_code=status.HTTP_200_OK)
|
@router.post("/reorder", status_code=status.HTTP_200_OK)
|
||||||
async def reorder_queue(
|
async def reorder_queue(
|
||||||
request: QueueOperationRequest,
|
request: QueueOperationRequest,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class DetailedHealthStatus(BaseModel):
|
|||||||
version: str = APP_VERSION
|
version: str = APP_VERSION
|
||||||
dependencies: DependencyHealth
|
dependencies: DependencyHealth
|
||||||
startup_time: datetime
|
startup_time: datetime
|
||||||
|
uptime: str
|
||||||
|
|
||||||
|
|
||||||
# Global startup time
|
# Global startup time
|
||||||
@@ -195,7 +196,9 @@ async def basic_health_check(request: Request) -> HealthStatus:
|
|||||||
# Get scheduler status for health monitoring
|
# Get scheduler status for health monitoring
|
||||||
scheduler_status: dict = {}
|
scheduler_status: dict = {}
|
||||||
try:
|
try:
|
||||||
from src.server.services.scheduler_service import get_scheduler_service
|
from src.server.services.scheduler.scheduler_service import (
|
||||||
|
get_scheduler_service,
|
||||||
|
)
|
||||||
scheduler_status = get_scheduler_service().get_status()
|
scheduler_status = get_scheduler_service().get_status()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -296,11 +299,16 @@ async def detailed_health_check(
|
|||||||
system=system_metrics,
|
system=system_metrics,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Calculate uptime
|
||||||
|
uptime_delta = datetime.now() - startup_time
|
||||||
|
uptime_str = str(uptime_delta).split('.')[0] # Remove microseconds
|
||||||
|
|
||||||
return DetailedHealthStatus(
|
return DetailedHealthStatus(
|
||||||
status=overall_status,
|
status=overall_status,
|
||||||
timestamp=datetime.now().isoformat(),
|
timestamp=datetime.now().isoformat(),
|
||||||
dependencies=dependencies,
|
dependencies=dependencies,
|
||||||
startup_time=startup_time,
|
startup_time=startup_time,
|
||||||
|
uptime=uptime_str,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Detailed health check failed: %s", e)
|
logger.error("Detailed health check failed: %s", e)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
@@ -72,6 +72,7 @@ def get_logging_config(
|
|||||||
"success": True,
|
"success": True,
|
||||||
"config": {
|
"config": {
|
||||||
# Primary fields (match the model)
|
# Primary fields (match the model)
|
||||||
|
"level": lc.level,
|
||||||
"log_level": lc.level,
|
"log_level": lc.level,
|
||||||
"log_file": lc.file,
|
"log_file": lc.file,
|
||||||
"max_bytes": lc.max_bytes,
|
"max_bytes": lc.max_bytes,
|
||||||
@@ -162,7 +163,7 @@ def download_file(
|
|||||||
return FileResponse(
|
return FileResponse(
|
||||||
path=str(file_path),
|
path=str(file_path),
|
||||||
filename=safe_name,
|
filename=safe_name,
|
||||||
media_type="text/plain",
|
media_type="application/octet-stream",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -180,20 +181,23 @@ def test_logging(
|
|||||||
|
|
||||||
@router.post("/cleanup")
|
@router.post("/cleanup")
|
||||||
def cleanup_logs(
|
def cleanup_logs(
|
||||||
payload: Dict[str, Any],
|
payload: Optional[Dict[str, Any]] = None,
|
||||||
auth: dict = Depends(require_auth),
|
auth: dict = Depends(require_auth),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Delete log files older than *days* days.
|
"""Delete log files older than *days* days.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
payload: JSON body with ``days`` (int) field.
|
payload: Optional JSON body with ``days`` (int) field. Defaults to 30.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with ``success`` and ``message`` describing what was deleted.
|
Dict with ``success`` and ``message`` describing what was deleted.
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
days = payload.get("days", 30)
|
if payload is None:
|
||||||
|
days = 30
|
||||||
|
else:
|
||||||
|
days = payload.get("days", 30)
|
||||||
try:
|
try:
|
||||||
days = int(days)
|
days = int(days)
|
||||||
if days < 1:
|
if days < 1:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,8 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
|
|
||||||
from src.server.models.config import SchedulerConfig
|
from src.server.models.config import SchedulerConfig
|
||||||
from src.server.services.config_service import ConfigServiceError, get_config_service
|
from src.server.services.config_service import ConfigServiceError, get_config_service
|
||||||
from src.server.services.scheduler_service import get_scheduler_service
|
from src.server.services.scheduler.scheduler_service import get_scheduler_service
|
||||||
from src.server.utils.dependencies import require_auth
|
from src.server.utils.dependencies import get_anime_service, require_auth
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -31,7 +31,9 @@ def _build_response(config: SchedulerConfig) -> Dict[str, Any]:
|
|||||||
"schedule_time": config.schedule_time,
|
"schedule_time": config.schedule_time,
|
||||||
"schedule_days": config.schedule_days,
|
"schedule_days": config.schedule_days,
|
||||||
"auto_download_after_rescan": config.auto_download_after_rescan,
|
"auto_download_after_rescan": config.auto_download_after_rescan,
|
||||||
"folder_scan_enabled": config.folder_scan_enabled,
|
"nfo_scan_after_rescan": config.nfo_scan_after_rescan,
|
||||||
|
"image_scan_after_rescan": config.image_scan_after_rescan,
|
||||||
|
"folder_naming_after_nfo_scan": config.folder_naming_after_nfo_scan,
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"is_running": runtime.get("is_running", False),
|
"is_running": runtime.get("is_running", False),
|
||||||
@@ -142,9 +144,10 @@ async def trigger_rescan(auth: dict = Depends(require_auth)) -> Dict[str, str]:
|
|||||||
"Manual rescan triggered by %s", auth.get("username", "unknown")
|
"Manual rescan triggered by %s", auth.get("username", "unknown")
|
||||||
)
|
)
|
||||||
|
|
||||||
from src.server.api.anime import trigger_rescan as do_rescan # noqa: PLC0415
|
anime_service = get_anime_service()
|
||||||
|
await anime_service.rescan()
|
||||||
|
|
||||||
return await do_rescan()
|
return {"success": "True", "message": "Rescan started successfully"}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
446
src/server/api/setup_endpoints.py
Normal file
446
src/server/api/setup_endpoints.py
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
"""API endpoints for setup and unresolved folder management.
|
||||||
|
|
||||||
|
Provides endpoints to:
|
||||||
|
- List unresolved folders that couldn't be auto-resolved during setup
|
||||||
|
- Get suggestions/search results for an unresolved folder
|
||||||
|
- Resolve an unresolved folder by providing a provider key
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from src.server.database.connection import get_db_session
|
||||||
|
from src.server.database.service import AnimeSeriesService, UnresolvedFolderService
|
||||||
|
from src.server.services.background_loader_service import BackgroundLoaderService
|
||||||
|
from src.server.utils.dependencies import (
|
||||||
|
get_background_loader_service,
|
||||||
|
get_database_session,
|
||||||
|
get_series_app,
|
||||||
|
require_auth,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||||
|
|
||||||
|
|
||||||
|
class UnresolvedFolderResponse(BaseModel):
|
||||||
|
"""Response model for an unresolved folder."""
|
||||||
|
|
||||||
|
folder_name: str = Field(..., description="Original filesystem folder name")
|
||||||
|
title: str = Field(..., description="Extracted title from folder name")
|
||||||
|
year: Optional[int] = Field(None, description="Extracted release year")
|
||||||
|
search_attempts: int = Field(..., description="Number of search attempts made")
|
||||||
|
search_suggestions: list[dict[str, Any]] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Cached search results for potential matches"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveFolderRequest(BaseModel):
|
||||||
|
"""Request model for resolving an unresolved folder."""
|
||||||
|
|
||||||
|
provider_key: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
max_length=255,
|
||||||
|
description="Provider key to associate with this folder"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveFolderResponse(BaseModel):
|
||||||
|
"""Response model for resolving an unresolved folder."""
|
||||||
|
|
||||||
|
status: str = Field(..., description="Operation status")
|
||||||
|
message: str = Field(..., description="Human-readable message")
|
||||||
|
folder_name: str = Field(..., description="Folder name that was resolved")
|
||||||
|
key: str = Field(..., description="Provider key that was used")
|
||||||
|
series_id: int = Field(..., description="Database ID of the created series")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unresolved", response_model=list[UnresolvedFolderResponse])
|
||||||
|
async def list_unresolved_folders(
|
||||||
|
db=Depends(get_database_session),
|
||||||
|
) -> list[UnresolvedFolderResponse]:
|
||||||
|
"""List all unresolved folders that need manual key resolution.
|
||||||
|
|
||||||
|
Returns folders that couldn't be auto-resolved during setup,
|
||||||
|
including cached search suggestions when available.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of UnresolvedFolderResponse objects
|
||||||
|
"""
|
||||||
|
folders = await UnresolvedFolderService.get_all_unresolved(db)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for folder in folders:
|
||||||
|
suggestions = []
|
||||||
|
if folder.last_search_result:
|
||||||
|
try:
|
||||||
|
suggestions = json.loads(folder.last_search_result)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to parse search result for folder: %s",
|
||||||
|
folder.folder_name
|
||||||
|
)
|
||||||
|
|
||||||
|
result.append(UnresolvedFolderResponse(
|
||||||
|
folder_name=folder.folder_name,
|
||||||
|
title=folder.title,
|
||||||
|
year=folder.year,
|
||||||
|
search_attempts=folder.search_attempts,
|
||||||
|
search_suggestions=suggestions,
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unresolved/{folder_name}", response_model=UnresolvedFolderResponse)
|
||||||
|
async def get_unresolved_folder(
|
||||||
|
folder_name: str,
|
||||||
|
db=Depends(get_database_session),
|
||||||
|
) -> UnresolvedFolderResponse:
|
||||||
|
"""Get details for a specific unresolved folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_name: URL-encoded folder name to look up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UnresolvedFolderResponse for the specified folder
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if folder not found or already resolved
|
||||||
|
"""
|
||||||
|
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
|
||||||
|
|
||||||
|
if not folder:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Unresolved folder not found: {folder_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if folder.is_resolved:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Folder already resolved: {folder_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
suggestions = []
|
||||||
|
if folder.last_search_result:
|
||||||
|
try:
|
||||||
|
suggestions = json.loads(folder.last_search_result)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return UnresolvedFolderResponse(
|
||||||
|
folder_name=folder.folder_name,
|
||||||
|
title=folder.title,
|
||||||
|
year=folder.year,
|
||||||
|
search_attempts=folder.search_attempts,
|
||||||
|
search_suggestions=suggestions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unresolved/{folder_name}/resolve", response_model=ResolveFolderResponse)
|
||||||
|
async def resolve_unresolved_folder(
|
||||||
|
folder_name: str,
|
||||||
|
request: ResolveFolderRequest,
|
||||||
|
db=Depends(get_database_session),
|
||||||
|
background_loader: BackgroundLoaderService = Depends(get_background_loader_service),
|
||||||
|
) -> ResolveFolderResponse:
|
||||||
|
"""Resolve an unresolved folder by providing the correct provider key.
|
||||||
|
|
||||||
|
This endpoint:
|
||||||
|
1. Validates the provider key format
|
||||||
|
2. Updates the UnresolvedFolder record as resolved
|
||||||
|
3. Creates the AnimeSeries record in the database
|
||||||
|
4. Returns the created series information
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_name: URL-encoded folder name to resolve
|
||||||
|
request: ResolveFolderRequest with the provider_key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResolveFolderResponse with created series details
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if folder not found
|
||||||
|
HTTPException: 400 if key is invalid or series already exists
|
||||||
|
"""
|
||||||
|
# Check if folder exists and is unresolved
|
||||||
|
unresolved = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
|
||||||
|
|
||||||
|
if not unresolved:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Unresolved folder not found: {folder_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if unresolved.is_resolved:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Folder already resolved: {folder_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if a series with this key already exists
|
||||||
|
existing_series = await AnimeSeriesService.get_by_key(db, request.provider_key)
|
||||||
|
if existing_series:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Series with key '{request.provider_key}' already exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark as resolved
|
||||||
|
await UnresolvedFolderService.resolve(db, folder_name, request.provider_key)
|
||||||
|
|
||||||
|
# Create the AnimeSeries record
|
||||||
|
series = await AnimeSeriesService.create(
|
||||||
|
db=db,
|
||||||
|
key=request.provider_key,
|
||||||
|
name=unresolved.title,
|
||||||
|
site="https://aniworld.to",
|
||||||
|
folder=folder_name,
|
||||||
|
year=unresolved.year,
|
||||||
|
loading_status="pending",
|
||||||
|
episodes_loaded=False,
|
||||||
|
logo_loaded=False,
|
||||||
|
images_loaded=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Resolved unresolved folder via API: %s -> key=%s (series_id=%d)",
|
||||||
|
folder_name, request.provider_key, series.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Queue background loading task for episodes, NFO, and images
|
||||||
|
try:
|
||||||
|
await background_loader.add_series_loading_task(
|
||||||
|
key=request.provider_key,
|
||||||
|
folder=folder_name,
|
||||||
|
name=unresolved.title,
|
||||||
|
year=unresolved.year,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Queued background loading for resolved folder: %s (key=%s)",
|
||||||
|
folder_name,
|
||||||
|
request.provider_key
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to queue background loading for resolved folder %s: %s",
|
||||||
|
folder_name,
|
||||||
|
e
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResolveFolderResponse(
|
||||||
|
status="success",
|
||||||
|
message=f"Successfully resolved and added series: {unresolved.title}",
|
||||||
|
folder_name=folder_name,
|
||||||
|
key=request.provider_key,
|
||||||
|
series_id=series.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchFolderRequest(BaseModel):
|
||||||
|
"""Request model for searching an unresolved folder with custom query."""
|
||||||
|
query: Optional[str] = Field(None, description="Custom search query override")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unresolved/{folder_name}/search", response_model=UnresolvedFolderResponse)
|
||||||
|
async def search_unresolved_folder(
|
||||||
|
folder_name: str,
|
||||||
|
request: Optional[SearchFolderRequest] = None,
|
||||||
|
db=Depends(get_database_session),
|
||||||
|
) -> UnresolvedFolderResponse:
|
||||||
|
"""Re-search for a specific unresolved folder to get fresh suggestions.
|
||||||
|
|
||||||
|
Performs a new search using the folder's title or a custom query.
|
||||||
|
Caches the results for subsequent display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_name: URL-encoded folder name to search for
|
||||||
|
request: Optional SearchFolderRequest with custom query override
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UnresolvedFolderResponse with updated search suggestions
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if folder not found or already resolved
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
|
||||||
|
|
||||||
|
if not folder:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Unresolved folder not found: {folder_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if folder.is_resolved:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Folder already resolved: {folder_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use custom query if provided, otherwise fall back to folder title
|
||||||
|
search_query = request.query if request and request.query else folder.title
|
||||||
|
|
||||||
|
# Perform search
|
||||||
|
series_app = get_series_app()
|
||||||
|
try:
|
||||||
|
results = await series_app.search(search_query)
|
||||||
|
search_result_json = json.dumps(results) if results else "[]"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Search failed for unresolved folder: %s, error: %s",
|
||||||
|
folder_name, str(e)
|
||||||
|
)
|
||||||
|
search_result_json = "[]"
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Update the folder with new search results
|
||||||
|
await UnresolvedFolderService.update_search_result(db, folder_name, search_result_json)
|
||||||
|
|
||||||
|
return UnresolvedFolderResponse(
|
||||||
|
folder_name=folder.folder_name,
|
||||||
|
title=folder.title,
|
||||||
|
year=folder.year,
|
||||||
|
search_attempts=folder.search_attempts + 1,
|
||||||
|
search_suggestions=results,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/unresolved/{folder_name}")
|
||||||
|
async def delete_unresolved_folder(
|
||||||
|
folder_name: str,
|
||||||
|
db=Depends(get_database_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Delete an unresolved folder tracking record.
|
||||||
|
|
||||||
|
Use this when you've manually added the series outside of this flow
|
||||||
|
(e.g., via POST /api/anime/add) to clean up the unresolved tracker.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_name: URL-encoded folder name to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status message
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if folder not found
|
||||||
|
"""
|
||||||
|
deleted = await UnresolvedFolderService.delete(db, folder_name)
|
||||||
|
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Unresolved folder not found: {folder_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "success", "message": f"Deleted unresolved folder: {folder_name}"}
|
||||||
|
|
||||||
|
|
||||||
|
class DoneResponse(BaseModel):
|
||||||
|
"""Response model for completing unresolved folders."""
|
||||||
|
status: str = Field(..., description="Operation status")
|
||||||
|
message: str = Field(..., description="Human-readable message")
|
||||||
|
count: int = Field(..., description="Number of folders marked as done")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unresolved/done", response_model=DoneResponse)
|
||||||
|
async def complete_unresolved_folders(
|
||||||
|
db=Depends(get_database_session),
|
||||||
|
) -> DoneResponse:
|
||||||
|
"""Mark all unresolved folders as handled and complete the unresolved phase.
|
||||||
|
|
||||||
|
This endpoint:
|
||||||
|
1. Marks the unresolved phase as completed in config
|
||||||
|
2. Returns the count of folders that were handled
|
||||||
|
|
||||||
|
After this, /setup/unresolved will redirect to /loading.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DoneResponse with status and count of handled folders
|
||||||
|
"""
|
||||||
|
from src.server.services.config_service import get_config_service
|
||||||
|
|
||||||
|
# Get all unresolved folders
|
||||||
|
folders = await UnresolvedFolderService.get_all_unresolved(db)
|
||||||
|
count = len(folders)
|
||||||
|
|
||||||
|
# Mark unresolved as completed in config
|
||||||
|
config_service = get_config_service()
|
||||||
|
try:
|
||||||
|
config = config_service.load_config()
|
||||||
|
if config.other is None:
|
||||||
|
config.other = {}
|
||||||
|
config.other['unresolved_completed'] = True
|
||||||
|
config_service.save_config(config, create_backup=False)
|
||||||
|
logger.info("Marked unresolved phase as completed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to save unresolved_completed flag: %s", e)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Completed unresolved phase: %d folders handled",
|
||||||
|
count
|
||||||
|
)
|
||||||
|
|
||||||
|
return DoneResponse(
|
||||||
|
status="success",
|
||||||
|
message=f"Marked {count} folders as handled. Unresolved phase completed.",
|
||||||
|
count=count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NfoScanPhaseResponse(BaseModel):
|
||||||
|
"""Response model for NFO scan phase trigger."""
|
||||||
|
status: str = Field(..., description="Status of the operation")
|
||||||
|
message: str = Field(..., description="Human-readable message")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/nfo-scan-phase", response_model=NfoScanPhaseResponse)
|
||||||
|
async def trigger_nfo_scan_phase() -> NfoScanPhaseResponse:
|
||||||
|
"""Trigger the NFO scan phase.
|
||||||
|
|
||||||
|
This endpoint is called by the loading page when accessed with ?phase=nfo.
|
||||||
|
It starts the NFO scan in the background and returns immediately.
|
||||||
|
The loading page then connects via WebSocket to receive progress updates.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NfoScanPhaseResponse with status and message
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from src.server.services.initialization_service import perform_nfo_scan_phase
|
||||||
|
from src.server.services.progress_service import get_progress_service
|
||||||
|
|
||||||
|
progress_service = get_progress_service()
|
||||||
|
|
||||||
|
async def run_nfo_scan():
|
||||||
|
"""Run NFO scan phase with progress updates."""
|
||||||
|
try:
|
||||||
|
await perform_nfo_scan_phase(progress_service)
|
||||||
|
logger.info("NFO scan phase completed via API trigger")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("NFO scan phase failed: %s", e, exc_info=True)
|
||||||
|
if progress_service:
|
||||||
|
await progress_service.fail_progress(
|
||||||
|
progress_id="nfo_scan",
|
||||||
|
error_message=f"NFO scan failed: {str(e)}",
|
||||||
|
metadata={"step_id": "nfo_scan", "phase": "nfo"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start NFO scan in background
|
||||||
|
asyncio.create_task(run_nfo_scan())
|
||||||
|
|
||||||
|
return NfoScanPhaseResponse(
|
||||||
|
status="started",
|
||||||
|
message="NFO scan phase started. Check progress via WebSocket."
|
||||||
|
)
|
||||||
@@ -342,7 +342,7 @@ async def websocket_endpoint(
|
|||||||
# Cleanup connection and rate limit record
|
# Cleanup connection and rate limit record
|
||||||
_cleanup_ws_rate_limits(connection_id)
|
_cleanup_ws_rate_limits(connection_id)
|
||||||
await ws_service.disconnect(connection_id)
|
await ws_service.disconnect(connection_id)
|
||||||
logger.info("WebSocket connection closed", connection_id=connection_id)
|
logger.info("WebSocket connection closed connection_id=%s", connection_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
|
|||||||
@@ -38,12 +38,11 @@ class DevelopmentSettings(BaseSettings):
|
|||||||
)
|
)
|
||||||
"""Password salt (non-production value for development)."""
|
"""Password salt (non-production value for development)."""
|
||||||
|
|
||||||
master_password_hash: str = Field(
|
master_password_hash: Optional[str] = Field(
|
||||||
default="$2b$12$wP0KBVbJKVAb8CdSSXw0NeGTKCk"
|
default=None,
|
||||||
"bw4fSAFXIqR2/wDqPSEBn9w7lS",
|
|
||||||
env="MASTER_PASSWORD_HASH"
|
env="MASTER_PASSWORD_HASH"
|
||||||
)
|
)
|
||||||
"""Hash of the master password (dev: 'password')."""
|
"""Hash of the master password. None means not configured (env var overrides)."""
|
||||||
|
|
||||||
master_password: str = Field(default="password", env="MASTER_PASSWORD")
|
master_password: str = Field(default="password", env="MASTER_PASSWORD")
|
||||||
"""Master password for development (NEVER use in production)."""
|
"""Master password for development (NEVER use in production)."""
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ async def not_found_handler(request: Request, exc: HTTPException):
|
|||||||
if request.url.path.startswith("/api/"):
|
if request.url.path.startswith("/api/"):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
content={"detail": "API endpoint not found"}
|
content={"detail": exc.detail}
|
||||||
)
|
)
|
||||||
return render_template(
|
return render_template(
|
||||||
"error.html",
|
"error.html",
|
||||||
|
|||||||
@@ -59,3 +59,40 @@ async def loading_page(request: Request):
|
|||||||
request,
|
request,
|
||||||
title="Initializing - Aniworld"
|
title="Initializing - Aniworld"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/setup/unresolved", response_class=HTMLResponse)
|
||||||
|
async def unresolved_page(request: Request):
|
||||||
|
"""Serve the unresolved folders resolution page."""
|
||||||
|
return render_template(
|
||||||
|
"unresolved.html",
|
||||||
|
request,
|
||||||
|
title="Resolve Series - Aniworld"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings/nfo", response_class=HTMLResponse)
|
||||||
|
async def nfo_settings_page_redirect():
|
||||||
|
"""Backwards-compatible redirect from the old NFO settings URL.
|
||||||
|
|
||||||
|
Older bookmarks and open tabs may still point at /settings/nfo —
|
||||||
|
redirect them to the new per-anime Anime Settings page.
|
||||||
|
"""
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
return RedirectResponse(url="/anime/settings", status_code=301)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/anime/settings", response_class=HTMLResponse)
|
||||||
|
async def anime_settings_page(request: Request):
|
||||||
|
"""Serve the per-anime Anime Settings page.
|
||||||
|
|
||||||
|
Replaces the old NFO Diagnostics page. The same template is used
|
||||||
|
for all series — the active series key is passed via the
|
||||||
|
``?key=...`` query parameter and consumed by the page's JS.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"anime-settings.html",
|
||||||
|
request,
|
||||||
|
title="Anime Settings - Aniworld"
|
||||||
|
)
|
||||||
|
|||||||
288
src/server/database/SerieList.py
Normal file
288
src/server/database/SerieList.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
"""Utilities for loading and managing stored anime series metadata.
|
||||||
|
|
||||||
|
This module provides the SerieList class for managing collections of anime
|
||||||
|
series metadata loaded from the database.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This module is part of the server database layer. All persistence
|
||||||
|
is handled by the service layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from src.server.database.models import AnimeSeries
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SerieList:
|
||||||
|
"""
|
||||||
|
Represents the collection of cached series loaded from database.
|
||||||
|
|
||||||
|
Series are identified by their unique 'key' (provider identifier).
|
||||||
|
The 'folder' is metadata only and not used for lookups.
|
||||||
|
|
||||||
|
This class manages in-memory series data loaded from database.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
# Load from database
|
||||||
|
serie_list = SerieList("/path/to/anime")
|
||||||
|
await serie_list.load_all_from_db()
|
||||||
|
series = serie_list.get_all()
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
directory: Path to the anime directory
|
||||||
|
keyDict: Internal dictionary mapping serie.key to AnimeSeries objects
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base_path: str) -> None:
|
||||||
|
"""Initialize the SerieList.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_path: Path to the anime directory
|
||||||
|
"""
|
||||||
|
self.directory: str = base_path
|
||||||
|
# Internal storage using serie.key as the dictionary key
|
||||||
|
self.keyDict: Dict[str, AnimeSeries] = {}
|
||||||
|
|
||||||
|
async def add_to_db(self, anime: AnimeSeries) -> bool:
|
||||||
|
"""Persist a new series to the database.
|
||||||
|
|
||||||
|
Creates the filesystem folder using anime.folder, then persists
|
||||||
|
the series metadata to the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime: The AnimeSeries instance to add
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.server.database.connection import get_async_session_factory
|
||||||
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||||
|
|
||||||
|
folder_name = anime.folder
|
||||||
|
anime_path = self.directory + "/" + folder_name
|
||||||
|
import os
|
||||||
|
os.makedirs(anime_path, exist_ok=True)
|
||||||
|
|
||||||
|
session_factory = get_async_session_factory()
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
existing = await AnimeSeriesService.get_by_key(db, anime.key)
|
||||||
|
if existing:
|
||||||
|
logger.debug(
|
||||||
|
"Series '%s' (key=%s) already exists in DB, skipping",
|
||||||
|
anime.name, anime.key
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
db_anime_series = await AnimeSeriesService.create(
|
||||||
|
db=db,
|
||||||
|
key=anime.key,
|
||||||
|
name=anime.name,
|
||||||
|
site=anime.site,
|
||||||
|
folder=folder_name,
|
||||||
|
year=anime.year
|
||||||
|
)
|
||||||
|
for ep in anime.episodes:
|
||||||
|
await EpisodeService.create(
|
||||||
|
db=db,
|
||||||
|
series_id=db_anime_series.id,
|
||||||
|
season=ep.season,
|
||||||
|
episode_number=ep.episode_number
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
self.keyDict[anime.key] = anime
|
||||||
|
logger.info(
|
||||||
|
"Persisted series '%s' to database",
|
||||||
|
anime.name
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(
|
||||||
|
"Failed to persist series '%s' to DB: %s",
|
||||||
|
anime.key, e, exc_info=True
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Could not add series '%s' to DB (DB unavailable?): %s",
|
||||||
|
anime.key, e
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def contains(self, key: str) -> bool:
|
||||||
|
"""
|
||||||
|
Return True when a series identified by ``key`` already exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The unique provider identifier for the series
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the series exists in the collection
|
||||||
|
"""
|
||||||
|
return key in self.keyDict
|
||||||
|
|
||||||
|
def GetMissingEpisode(self) -> List[AnimeSeries]:
|
||||||
|
"""Return all series that still contain missing episodes."""
|
||||||
|
return [
|
||||||
|
anime for anime in self.keyDict.values()
|
||||||
|
if anime.episodeDict
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_missing_episodes(self) -> List[AnimeSeries]:
|
||||||
|
"""PEP8-friendly alias for :meth:`GetMissingEpisode`."""
|
||||||
|
return self.GetMissingEpisode()
|
||||||
|
|
||||||
|
def GetList(self) -> List[AnimeSeries]:
|
||||||
|
"""Return all series instances stored in the list."""
|
||||||
|
return list(self.keyDict.values())
|
||||||
|
|
||||||
|
def get_all(self) -> List[AnimeSeries]:
|
||||||
|
"""PEP8-friendly alias for :meth:`GetList`."""
|
||||||
|
return self.GetList()
|
||||||
|
|
||||||
|
def get_by_key(self, key: str) -> Optional[AnimeSeries]:
|
||||||
|
"""
|
||||||
|
Get a series by its unique provider key.
|
||||||
|
|
||||||
|
This is the primary method for series lookup.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The unique provider identifier (e.g., "attack-on-titan")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The AnimeSeries instance if found, None otherwise
|
||||||
|
"""
|
||||||
|
return self.keyDict.get(key)
|
||||||
|
|
||||||
|
def get_by_folder(self, folder: str) -> Optional[AnimeSeries]:
|
||||||
|
"""
|
||||||
|
Get a series by its folder name.
|
||||||
|
|
||||||
|
.. deprecated:: 2.0.0
|
||||||
|
Use :meth:`get_by_key` instead. Folder-based lookups will be
|
||||||
|
removed in version 3.0.0. The `folder` field is metadata only
|
||||||
|
and should not be used for identification.
|
||||||
|
|
||||||
|
This method is provided for backward compatibility only.
|
||||||
|
Prefer using get_by_key() for new code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder: The filesystem folder name (e.g., "Attack on Titan (2013)")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The AnimeSeries instance if found, None otherwise
|
||||||
|
"""
|
||||||
|
import warnings
|
||||||
|
warnings.warn(
|
||||||
|
"get_by_folder() is deprecated and will be removed in v3.0.0. "
|
||||||
|
"Use get_by_key() instead. The 'folder' field is metadata only.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2
|
||||||
|
)
|
||||||
|
for anime in self.keyDict.values():
|
||||||
|
if anime.folder == folder:
|
||||||
|
return anime
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def load_all_from_db(self) -> int:
|
||||||
|
"""Load all series from database into in-memory cache.
|
||||||
|
|
||||||
|
Retrieves all anime series from the database with their episodes
|
||||||
|
and populates the in-memory keyDict for fast access.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Number of series loaded into cache
|
||||||
|
"""
|
||||||
|
from src.server.database.connection import get_async_session_factory
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
try:
|
||||||
|
session_factory = get_async_session_factory()
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
anime_series_list = await AnimeSeriesService.get_all(
|
||||||
|
db, with_episodes=True
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for anime_series in anime_series_list:
|
||||||
|
self.keyDict[anime_series.key] = anime_series
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Loaded %d series from database into in-memory cache",
|
||||||
|
count
|
||||||
|
)
|
||||||
|
return count
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning(
|
||||||
|
"Database not available, skipping DB load"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def _load_single_series_from_db(
|
||||||
|
self,
|
||||||
|
anime_folder: str
|
||||||
|
) -> Optional[AnimeSeries]:
|
||||||
|
"""Load a single series from database by folder name.
|
||||||
|
|
||||||
|
Looks up a series in the database by its folder name and adds
|
||||||
|
it to the in-memory cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_folder: The filesystem folder name to look up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AnimeSeries if found and loaded, None otherwise
|
||||||
|
"""
|
||||||
|
from src.server.database.connection import get_async_session_factory
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
try:
|
||||||
|
session_factory = get_async_session_factory()
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
anime_series = await AnimeSeriesService.get_by_folder(
|
||||||
|
db, anime_folder
|
||||||
|
)
|
||||||
|
if not anime_series:
|
||||||
|
logger.debug(
|
||||||
|
"Series with folder '%s' not found in DB",
|
||||||
|
anime_folder
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.keyDict[anime_series.key] = anime_series
|
||||||
|
logger.debug(
|
||||||
|
"Loaded series '%s' (key=%s) from DB",
|
||||||
|
anime_series.name, anime_series.key
|
||||||
|
)
|
||||||
|
return anime_series
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning(
|
||||||
|
"Database not available, cannot load series '%s'",
|
||||||
|
anime_folder
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def invalidate_cache(self) -> None:
|
||||||
|
"""Clear the in-memory cache.
|
||||||
|
|
||||||
|
Use after database modifications to force reload from DB
|
||||||
|
on next access.
|
||||||
|
"""
|
||||||
|
self.keyDict.clear()
|
||||||
|
logger.debug("SerieList in-memory cache invalidated")
|
||||||
@@ -48,6 +48,7 @@ from src.server.database.service import (
|
|||||||
EpisodeService,
|
EpisodeService,
|
||||||
UserSessionService,
|
UserSessionService,
|
||||||
)
|
)
|
||||||
|
from src.server.database.SerieList import SerieList
|
||||||
from src.server.database.system_settings_service import SystemSettingsService
|
from src.server.database.system_settings_service import SystemSettingsService
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -79,4 +80,6 @@ __all__ = [
|
|||||||
"DownloadQueueService",
|
"DownloadQueueService",
|
||||||
"SystemSettingsService",
|
"SystemSettingsService",
|
||||||
"UserSessionService",
|
"UserSessionService",
|
||||||
|
# SerieList
|
||||||
|
"SerieList",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ EXPECTED_TABLES = {
|
|||||||
"download_queue",
|
"download_queue",
|
||||||
"user_sessions",
|
"user_sessions",
|
||||||
"system_settings",
|
"system_settings",
|
||||||
|
"unresolved_folders",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Expected indexes for performance
|
# Expected indexes for performance
|
||||||
@@ -119,6 +120,11 @@ async def initialize_database(
|
|||||||
result["tables_created"] = tables
|
result["tables_created"] = tables
|
||||||
logger.info("Created %s tables", len(tables))
|
logger.info("Created %s tables", len(tables))
|
||||||
|
|
||||||
|
# Migrate schema if needed (add missing columns to existing tables)
|
||||||
|
migrations = await migrate_schema_if_needed(engine)
|
||||||
|
if migrations:
|
||||||
|
logger.info("Applied %s schema migrations", len(migrations))
|
||||||
|
|
||||||
# Validate schema if requested
|
# Validate schema if requested
|
||||||
if validate_schema:
|
if validate_schema:
|
||||||
validation = await validate_database_schema(engine)
|
validation = await validate_database_schema(engine)
|
||||||
@@ -305,6 +311,66 @@ async def validate_database_schema(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Schema Migration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_schema_if_needed(
|
||||||
|
engine: Optional[AsyncEngine] = None
|
||||||
|
) -> List[str]:
|
||||||
|
"""Migrate database schema to current version if needed.
|
||||||
|
|
||||||
|
Handles adding missing columns to existing tables for backward
|
||||||
|
compatibility with older database schemas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
engine: Optional database engine (uses default if not provided)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of migration operations performed
|
||||||
|
"""
|
||||||
|
if engine is None:
|
||||||
|
engine = get_engine()
|
||||||
|
|
||||||
|
migrations_applied = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
# Get existing columns in system_settings table
|
||||||
|
existing_columns = await conn.run_sync(
|
||||||
|
lambda sync_conn: [
|
||||||
|
col["name"]
|
||||||
|
for col in inspect(sync_conn).get_columns("system_settings")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Migration: Add legacy_key_cleanup_completed column if missing
|
||||||
|
if "legacy_key_cleanup_completed" not in existing_columns:
|
||||||
|
logger.info(
|
||||||
|
"Migrating system_settings table: "
|
||||||
|
"adding legacy_key_cleanup_completed column"
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text("""
|
||||||
|
ALTER TABLE system_settings
|
||||||
|
ADD COLUMN legacy_key_cleanup_completed BOOLEAN
|
||||||
|
NOT NULL DEFAULT 0
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
migrations_applied.append("added legacy_key_cleanup_completed")
|
||||||
|
logger.info(
|
||||||
|
"Migration complete: added legacy_key_cleanup_completed column"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Schema migration failed: %s", e)
|
||||||
|
# Don't raise - migration failures shouldn't block startup
|
||||||
|
# The missing column will be handled gracefully by the application
|
||||||
|
|
||||||
|
return migrations_applied
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Schema Version Management
|
# Schema Version Management
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||||
@@ -190,6 +190,84 @@ class AnimeSeries(Base, TimestampMixin):
|
|||||||
f"name='{self.name}')>"
|
f"name='{self.name}')>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def episodeDict(self) -> dict[int, list[int]]:
|
||||||
|
"""Build episode dictionary from episodes relationship or private cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping season numbers to lists of episode numbers
|
||||||
|
"""
|
||||||
|
# Check for private cache first (set when loading from JSON without DB)
|
||||||
|
if hasattr(self, '_episode_dict_cache') and self._episode_dict_cache is not None:
|
||||||
|
return self._episode_dict_cache
|
||||||
|
|
||||||
|
episode_dict: dict[int, list[int]] = {}
|
||||||
|
try:
|
||||||
|
if self.episodes:
|
||||||
|
for ep in self.episodes:
|
||||||
|
season = ep.season or 1
|
||||||
|
if season not in episode_dict:
|
||||||
|
episode_dict[season] = []
|
||||||
|
episode_dict[season].append(ep.episode_number or 0)
|
||||||
|
except Exception:
|
||||||
|
# DetachedInstanceError or other DB errors - return empty dict
|
||||||
|
# This can happen when accessing episodes on a newly created
|
||||||
|
# or recently synced series that isn't fully attached
|
||||||
|
return {}
|
||||||
|
return episode_dict
|
||||||
|
|
||||||
|
@episodeDict.setter
|
||||||
|
def episodeDict(self, value: dict[int, list[int]]) -> None:
|
||||||
|
"""Set the episode dictionary via private cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Dictionary mapping season numbers to lists of episode numbers
|
||||||
|
"""
|
||||||
|
self._episode_dict_cache = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name_with_year(self) -> str:
|
||||||
|
"""Get series name with year appended if available.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Name in format "Name (Year)" if year is available, else just name
|
||||||
|
"""
|
||||||
|
if self.year:
|
||||||
|
import re
|
||||||
|
year_suffix = f" ({self.year})"
|
||||||
|
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', self.name or '').strip()
|
||||||
|
return f"{clean_name}{year_suffix}"
|
||||||
|
return self.name or ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sanitized_folder(self) -> str:
|
||||||
|
"""Get filesystem-safe folder name from display name with year.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized folder name based on display name with year
|
||||||
|
"""
|
||||||
|
from src.server.utils.filesystem import sanitize_folder_name
|
||||||
|
name_to_sanitize = self.name_with_year or self.folder or self.key
|
||||||
|
try:
|
||||||
|
return sanitize_folder_name(name_to_sanitize)
|
||||||
|
except ValueError:
|
||||||
|
return sanitize_folder_name(self.key)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary for cache serialization.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with series data including episodeDict for
|
||||||
|
auto-download functionality.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"key": self.key,
|
||||||
|
"name": self.name,
|
||||||
|
"site": self.site,
|
||||||
|
"folder": self.folder,
|
||||||
|
"episodeDict": self.episodeDict,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Episode(Base, TimestampMixin):
|
class Episode(Base, TimestampMixin):
|
||||||
"""SQLAlchemy model for anime episodes.
|
"""SQLAlchemy model for anime episodes.
|
||||||
@@ -578,6 +656,96 @@ class UserSession(Base, TimestampMixin):
|
|||||||
self.is_active = False
|
self.is_active = False
|
||||||
|
|
||||||
|
|
||||||
|
class UnresolvedFolder(Base, TimestampMixin):
|
||||||
|
"""SQLAlchemy model for folders that couldn't be resolved during setup.
|
||||||
|
|
||||||
|
Tracks anime folders whose provider key couldn't be auto-resolved
|
||||||
|
during the initial setup scan. Users can provide the correct key
|
||||||
|
via the API to complete the series registration.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Primary key
|
||||||
|
folder_name: Original filesystem folder name
|
||||||
|
title: Extracted title from folder name
|
||||||
|
year: Extracted release year (optional)
|
||||||
|
provider_key: User-provided provider key to resolve this folder
|
||||||
|
search_attempts: Number of auto-search attempts made
|
||||||
|
last_search_result: Cached search results (JSON string) for UI suggestions
|
||||||
|
resolved_at: Timestamp when provider_key was provided
|
||||||
|
created_at: Creation timestamp (from TimestampMixin)
|
||||||
|
updated_at: Last update timestamp (from TimestampMixin)
|
||||||
|
"""
|
||||||
|
__tablename__ = "unresolved_folders"
|
||||||
|
|
||||||
|
# Primary key
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
Integer, primary_key=True, autoincrement=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Folder metadata
|
||||||
|
folder_name: Mapped[str] = mapped_column(
|
||||||
|
String(1000), unique=True, nullable=False, index=True,
|
||||||
|
doc="Original filesystem folder name"
|
||||||
|
)
|
||||||
|
title: Mapped[str] = mapped_column(
|
||||||
|
String(500), nullable=False,
|
||||||
|
doc="Extracted title from folder name"
|
||||||
|
)
|
||||||
|
year: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, nullable=True,
|
||||||
|
doc="Extracted release year"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolution data
|
||||||
|
provider_key: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(255), nullable=True,
|
||||||
|
doc="User-provided provider key to resolve this folder"
|
||||||
|
)
|
||||||
|
search_attempts: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0, server_default="0",
|
||||||
|
doc="Number of auto-search attempts made"
|
||||||
|
)
|
||||||
|
last_search_result: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text, nullable=True,
|
||||||
|
doc="Cached search results (JSON) for UI display"
|
||||||
|
)
|
||||||
|
resolved_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True,
|
||||||
|
doc="Timestamp when this folder was resolved"
|
||||||
|
)
|
||||||
|
|
||||||
|
@validates('folder_name')
|
||||||
|
def validate_folder_name(self, key: str, value: str) -> str:
|
||||||
|
"""Validate folder name is not empty."""
|
||||||
|
if not value or not value.strip():
|
||||||
|
raise ValueError("Folder name cannot be empty")
|
||||||
|
if len(value) > 1000:
|
||||||
|
raise ValueError("Folder name must be 1000 characters or less")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
@validates('title')
|
||||||
|
def validate_title(self, key: str, value: str) -> str:
|
||||||
|
"""Validate title is not empty."""
|
||||||
|
if not value or not value.strip():
|
||||||
|
raise ValueError("Title cannot be empty")
|
||||||
|
if len(value) > 500:
|
||||||
|
raise ValueError("Title must be 500 characters or less")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_resolved(self) -> bool:
|
||||||
|
"""Check if this folder has been resolved with a provider key."""
|
||||||
|
return self.provider_key is not None and self.resolved_at is not None
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"<UnresolvedFolder(id={self.id}, "
|
||||||
|
f"folder_name='{self.folder_name}', "
|
||||||
|
f"title='{self.title}', "
|
||||||
|
f"resolved={self.is_resolved})>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SystemSettings(Base, TimestampMixin):
|
class SystemSettings(Base, TimestampMixin):
|
||||||
"""SQLAlchemy model for system-wide settings and state.
|
"""SQLAlchemy model for system-wide settings and state.
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from src.server.database.models import (
|
|||||||
AnimeSeries,
|
AnimeSeries,
|
||||||
DownloadQueueItem,
|
DownloadQueueItem,
|
||||||
Episode,
|
Episode,
|
||||||
|
UnresolvedFolder,
|
||||||
UserSession,
|
UserSession,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,6 +71,10 @@ class AnimeSeriesService:
|
|||||||
logo_loaded: bool = False,
|
logo_loaded: bool = False,
|
||||||
images_loaded: bool = False,
|
images_loaded: bool = False,
|
||||||
loading_started_at: datetime | None = None,
|
loading_started_at: datetime | None = None,
|
||||||
|
has_nfo: bool = False,
|
||||||
|
nfo_path: str | None = None,
|
||||||
|
nfo_created_at: datetime | None = None,
|
||||||
|
nfo_updated_at: datetime | None = None,
|
||||||
) -> AnimeSeries:
|
) -> AnimeSeries:
|
||||||
"""Create a new anime series.
|
"""Create a new anime series.
|
||||||
|
|
||||||
@@ -85,6 +90,10 @@ class AnimeSeriesService:
|
|||||||
logo_loaded: Whether logo is loaded (default: False)
|
logo_loaded: Whether logo is loaded (default: False)
|
||||||
images_loaded: Whether images are loaded (default: False)
|
images_loaded: Whether images are loaded (default: False)
|
||||||
loading_started_at: When loading started (optional)
|
loading_started_at: When loading started (optional)
|
||||||
|
has_nfo: Whether tvshow.nfo exists (default: False)
|
||||||
|
nfo_path: Path to tvshow.nfo file (optional)
|
||||||
|
nfo_created_at: When NFO file was created (optional)
|
||||||
|
nfo_updated_at: When NFO file was last updated (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created AnimeSeries instance
|
Created AnimeSeries instance
|
||||||
@@ -103,6 +112,10 @@ class AnimeSeriesService:
|
|||||||
logo_loaded=logo_loaded,
|
logo_loaded=logo_loaded,
|
||||||
images_loaded=images_loaded,
|
images_loaded=images_loaded,
|
||||||
loading_started_at=loading_started_at,
|
loading_started_at=loading_started_at,
|
||||||
|
has_nfo=has_nfo,
|
||||||
|
nfo_path=nfo_path,
|
||||||
|
nfo_created_at=nfo_created_at,
|
||||||
|
nfo_updated_at=nfo_updated_at,
|
||||||
)
|
)
|
||||||
db.add(series)
|
db.add(series)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -127,7 +140,11 @@ class AnimeSeriesService:
|
|||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_by_key(db: AsyncSession, key: str) -> Optional[AnimeSeries]:
|
async def get_by_key(
|
||||||
|
db: AsyncSession,
|
||||||
|
key: str,
|
||||||
|
with_episodes: bool = False,
|
||||||
|
) -> Optional[AnimeSeries]:
|
||||||
"""Get anime series by provider key.
|
"""Get anime series by provider key.
|
||||||
|
|
||||||
This is the PRIMARY lookup method for series identification.
|
This is the PRIMARY lookup method for series identification.
|
||||||
@@ -137,6 +154,7 @@ class AnimeSeriesService:
|
|||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
key: Unique provider key (e.g., "attack-on-titan")
|
key: Unique provider key (e.g., "attack-on-titan")
|
||||||
|
with_episodes: Whether to eagerly load episodes relationship
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AnimeSeries instance or None if not found
|
AnimeSeries instance or None if not found
|
||||||
@@ -144,9 +162,12 @@ class AnimeSeriesService:
|
|||||||
Note:
|
Note:
|
||||||
Do NOT use folder for lookups - it's metadata only.
|
Do NOT use folder for lookups - it's metadata only.
|
||||||
"""
|
"""
|
||||||
result = await db.execute(
|
query = select(AnimeSeries).where(AnimeSeries.key == key)
|
||||||
select(AnimeSeries).where(AnimeSeries.key == key)
|
|
||||||
)
|
if with_episodes:
|
||||||
|
query = query.options(selectinload(AnimeSeries.episodes))
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1352,3 +1373,176 @@ class UserSessionService:
|
|||||||
|
|
||||||
return new_session
|
return new_session
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Unresolved Folder Service
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class UnresolvedFolderService:
|
||||||
|
"""Service for tracking and resolving folders that couldn't be auto-resolved.
|
||||||
|
|
||||||
|
During initial setup, some folders may not resolve to a provider key
|
||||||
|
(no search match or multiple ambiguous matches). These are tracked as
|
||||||
|
UnresolvedFolder records and can later be resolved by the user providing
|
||||||
|
the correct provider key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create(
|
||||||
|
db: AsyncSession,
|
||||||
|
folder_name: str,
|
||||||
|
title: str,
|
||||||
|
year: int | None = None,
|
||||||
|
search_attempts: int = 1,
|
||||||
|
last_search_result: str | None = None,
|
||||||
|
) -> UnresolvedFolder:
|
||||||
|
"""Create a new unresolved folder tracking record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
folder_name: Original filesystem folder name
|
||||||
|
title: Extracted title from folder name
|
||||||
|
year: Extracted release year (optional)
|
||||||
|
search_attempts: Number of search attempts made (default: 1)
|
||||||
|
last_search_result: JSON string of search results for UI (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created UnresolvedFolder instance
|
||||||
|
"""
|
||||||
|
folder = UnresolvedFolder(
|
||||||
|
folder_name=folder_name,
|
||||||
|
title=title,
|
||||||
|
year=year,
|
||||||
|
search_attempts=search_attempts,
|
||||||
|
last_search_result=last_search_result,
|
||||||
|
)
|
||||||
|
db.add(folder)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(folder)
|
||||||
|
logger.info(
|
||||||
|
"Created unresolved folder tracking: %s (title=%s, year=%s)",
|
||||||
|
folder_name, title, year
|
||||||
|
)
|
||||||
|
return folder
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_by_folder_name(
|
||||||
|
db: AsyncSession,
|
||||||
|
folder_name: str,
|
||||||
|
) -> Optional[UnresolvedFolder]:
|
||||||
|
"""Get unresolved folder by folder name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
folder_name: Filesystem folder name to look up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UnresolvedFolder instance or None if not found
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(UnresolvedFolder).where(
|
||||||
|
UnresolvedFolder.folder_name == folder_name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_all_unresolved(
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> list[UnresolvedFolder]:
|
||||||
|
"""Get all unresolved folders that haven't been resolved yet.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of unresolved UnresolvedFolder instances
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(UnresolvedFolder)
|
||||||
|
.where(UnresolvedFolder.provider_key.is_(None))
|
||||||
|
.order_by(UnresolvedFolder.created_at)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def resolve(
|
||||||
|
db: AsyncSession,
|
||||||
|
folder_name: str,
|
||||||
|
provider_key: str,
|
||||||
|
) -> Optional[UnresolvedFolder]:
|
||||||
|
"""Mark an unresolved folder as resolved with the given provider key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
folder_name: Filesystem folder name to resolve
|
||||||
|
provider_key: Provider key to associate with this folder
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated UnresolvedFolder instance or None if not found
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
|
||||||
|
if not folder:
|
||||||
|
return None
|
||||||
|
|
||||||
|
folder.provider_key = provider_key
|
||||||
|
folder.resolved_at = datetime.now(timezone.utc)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(folder)
|
||||||
|
logger.info(
|
||||||
|
"Resolved unresolved folder: %s -> key=%s",
|
||||||
|
folder_name, provider_key
|
||||||
|
)
|
||||||
|
return folder
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def delete(
|
||||||
|
db: AsyncSession,
|
||||||
|
folder_name: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Delete an unresolved folder record (e.g., after manual add).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
folder_name: Filesystem folder name to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found
|
||||||
|
"""
|
||||||
|
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
|
||||||
|
if not folder:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await db.delete(folder)
|
||||||
|
await db.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def update_search_result(
|
||||||
|
db: AsyncSession,
|
||||||
|
folder_name: str,
|
||||||
|
search_result: str,
|
||||||
|
) -> Optional[UnresolvedFolder]:
|
||||||
|
"""Update the cached search result for an unresolved folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
folder_name: Filesystem folder name to update
|
||||||
|
search_result: JSON string of search results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated UnresolvedFolder instance or None if not found
|
||||||
|
"""
|
||||||
|
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
|
||||||
|
if not folder:
|
||||||
|
return None
|
||||||
|
|
||||||
|
folder.search_attempts += 1
|
||||||
|
folder.last_search_result = search_result
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(folder)
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|||||||
@@ -74,22 +74,28 @@ class RecoveryStrategies:
|
|||||||
delay = self.base_delay * (self.exponential_base ** attempt)
|
delay = self.base_delay * (self.exponential_base ** attempt)
|
||||||
return min(delay, self.max_delay)
|
return min(delay, self.max_delay)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def handle_network_failure(
|
def handle_network_failure(
|
||||||
self,
|
|
||||||
func: Callable, *args: Any, **kwargs: Any
|
func: Callable, *args: Any, **kwargs: Any
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Handle network failures with exponential backoff retry logic."""
|
"""Handle network failures with exponential backoff retry logic."""
|
||||||
last_error: Optional[Exception] = None
|
last_error: Optional[Exception] = None
|
||||||
for attempt in range(self.max_retries):
|
max_retries = 3
|
||||||
|
base_delay = 1.0
|
||||||
|
max_delay = 60.0
|
||||||
|
exponential_base = 2.0
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
except (NetworkError, ConnectionError, TimeoutError) as exc:
|
except (NetworkError, ConnectionError, TimeoutError) as exc:
|
||||||
last_error = exc
|
last_error = exc
|
||||||
if attempt < self.max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
delay = self._calculate_delay(attempt)
|
delay = base_delay * (exponential_base ** attempt)
|
||||||
|
delay = min(delay, max_delay)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Network error on attempt %d/%d, retrying in %.1fs: %s",
|
"Network error on attempt %d/%d, retrying in %.1fs: %s",
|
||||||
attempt + 1, self.max_retries, delay, exc
|
attempt + 1, max_retries, delay, exc
|
||||||
)
|
)
|
||||||
import time
|
import time
|
||||||
time.sleep(delay)
|
time.sleep(delay)
|
||||||
@@ -98,22 +104,28 @@ class RecoveryStrategies:
|
|||||||
raise last_error
|
raise last_error
|
||||||
raise NetworkError("Network failure after retries")
|
raise NetworkError("Network failure after retries")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def handle_download_failure(
|
def handle_download_failure(
|
||||||
self,
|
|
||||||
func: Callable, *args: Any, **kwargs: Any
|
func: Callable, *args: Any, **kwargs: Any
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Handle download failures with exponential backoff retry logic."""
|
"""Handle download failures with exponential backoff retry logic."""
|
||||||
last_error: Optional[Exception] = None
|
last_error: Optional[Exception] = None
|
||||||
for attempt in range(self.max_retries):
|
max_retries = 2
|
||||||
|
base_delay = 1.0
|
||||||
|
max_delay = 60.0
|
||||||
|
exponential_base = 2.0
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
except DownloadError as exc:
|
except DownloadError as exc:
|
||||||
last_error = exc
|
last_error = exc
|
||||||
if attempt < self.max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
delay = self._calculate_delay(attempt)
|
delay = base_delay * (exponential_base ** attempt)
|
||||||
|
delay = min(delay, max_delay)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Download error on attempt %d/%d, retrying in %.1fs: %s",
|
"Download error on attempt %d/%d, retrying in %.1fs: %s",
|
||||||
attempt + 1, self.max_retries, delay, exc
|
attempt + 1, max_retries, delay, exc
|
||||||
)
|
)
|
||||||
import time
|
import time
|
||||||
time.sleep(delay)
|
time.sleep(delay)
|
||||||
3
src/server/exceptions/exceptions/__init__.py
Normal file
3
src/server/exceptions/exceptions/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from src.server.exceptions.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
|
||||||
|
|
||||||
|
__all__ = ["MatchNotFoundError", "NoKeyFoundException"]
|
||||||
@@ -27,6 +27,7 @@ from src.server.api.health import router as health_router
|
|||||||
from src.server.api.logging import router as logging_router
|
from src.server.api.logging import router as logging_router
|
||||||
from src.server.api.nfo import router as nfo_router
|
from src.server.api.nfo import router as nfo_router
|
||||||
from src.server.api.scheduler import router as scheduler_router
|
from src.server.api.scheduler import router as scheduler_router
|
||||||
|
from src.server.api.setup_endpoints import router as setup_router
|
||||||
from src.server.api.websocket import router as websocket_router
|
from src.server.api.websocket import router as websocket_router
|
||||||
from src.server.controllers.error_controller import (
|
from src.server.controllers.error_controller import (
|
||||||
not_found_handler,
|
not_found_handler,
|
||||||
@@ -343,7 +344,6 @@ async def lifespan(_application: FastAPI):
|
|||||||
from src.server.services.initialization_service import (
|
from src.server.services.initialization_service import (
|
||||||
perform_initial_setup,
|
perform_initial_setup,
|
||||||
perform_media_scan_if_needed,
|
perform_media_scan_if_needed,
|
||||||
perform_nfo_scan_if_needed,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -372,9 +372,6 @@ async def lifespan(_application: FastAPI):
|
|||||||
"exist yet): %s", e
|
"exist yet): %s", e
|
||||||
)
|
)
|
||||||
|
|
||||||
# Run NFO scan only on first run (if configured)
|
|
||||||
await perform_nfo_scan_if_needed()
|
|
||||||
|
|
||||||
# Initialize download service
|
# Initialize download service
|
||||||
try:
|
try:
|
||||||
from src.server.utils.dependencies import get_download_service
|
from src.server.utils.dependencies import get_download_service
|
||||||
@@ -411,7 +408,9 @@ async def lifespan(_application: FastAPI):
|
|||||||
# anime_directory may be configured there even if the env var is empty.
|
# anime_directory may be configured there even if the env var is empty.
|
||||||
try:
|
try:
|
||||||
logger.info("Initializing scheduler service...")
|
logger.info("Initializing scheduler service...")
|
||||||
from src.server.services.scheduler_service import get_scheduler_service
|
from src.server.services.scheduler.scheduler_service import (
|
||||||
|
get_scheduler_service,
|
||||||
|
)
|
||||||
scheduler_service = get_scheduler_service()
|
scheduler_service = get_scheduler_service()
|
||||||
logger.info("Scheduler service instance obtained, starting...")
|
logger.info("Scheduler service instance obtained, starting...")
|
||||||
await scheduler_service.start()
|
await scheduler_service.start()
|
||||||
@@ -496,7 +495,9 @@ async def lifespan(_application: FastAPI):
|
|||||||
# 1. Stop scheduler service (only if initialized)
|
# 1. Stop scheduler service (only if initialized)
|
||||||
if initialized['scheduler']:
|
if initialized['scheduler']:
|
||||||
try:
|
try:
|
||||||
from src.server.services.scheduler_service import get_scheduler_service
|
from src.server.services.scheduler.scheduler_service import (
|
||||||
|
get_scheduler_service,
|
||||||
|
)
|
||||||
scheduler_service = get_scheduler_service()
|
scheduler_service = get_scheduler_service()
|
||||||
logger.info("Stopping scheduler service...")
|
logger.info("Stopping scheduler service...")
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(
|
||||||
@@ -633,7 +634,12 @@ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|||||||
app.add_middleware(SetupRedirectMiddleware)
|
app.add_middleware(SetupRedirectMiddleware)
|
||||||
|
|
||||||
# Attach authentication middleware (token parsing + simple rate limiter)
|
# Attach authentication middleware (token parsing + simple rate limiter)
|
||||||
app.add_middleware(AuthMiddleware, rate_limit_per_minute=5)
|
# Disable rate limiting in test mode to avoid 429 errors during rapid test execution
|
||||||
|
import os
|
||||||
|
|
||||||
|
_test_mode = os.getenv("ANIWORLD_TESTING") == "1"
|
||||||
|
_auth_rate_limit = 0 if _test_mode else 5
|
||||||
|
app.add_middleware(AuthMiddleware, rate_limit_per_minute=_auth_rate_limit)
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
@@ -644,6 +650,7 @@ app.include_router(scheduler_router)
|
|||||||
app.include_router(anime_router)
|
app.include_router(anime_router)
|
||||||
app.include_router(download_router)
|
app.include_router(download_router)
|
||||||
app.include_router(nfo_router)
|
app.include_router(nfo_router)
|
||||||
|
app.include_router(setup_router)
|
||||||
app.include_router(logging_router)
|
app.include_router(logging_router)
|
||||||
app.include_router(websocket_router)
|
app.include_router(websocket_router)
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
"/login", # Login page
|
"/login", # Login page
|
||||||
"/setup", # Setup page
|
"/setup", # Setup page
|
||||||
"/queue", # Queue page (needs to be accessible for initial load)
|
"/queue", # Queue page (needs to be accessible for initial load)
|
||||||
|
"/anime/settings", # Anime Settings page (auth handled by API, JS redirects to login)
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -144,7 +145,8 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
origin_rate_record["count"] += 1
|
origin_rate_record["count"] += 1
|
||||||
# Allow higher rate limit for origins (e.g., 60 req/min)
|
# Allow higher rate limit for origins (e.g., 60 req/min)
|
||||||
if origin_rate_record["count"] > self.rate_limit_per_minute * 12:
|
# Skip if rate limiting is disabled (rate_limit_per_minute = 0)
|
||||||
|
if self.rate_limit_per_minute > 0 and origin_rate_record["count"] > self.rate_limit_per_minute * 12:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
content={
|
content={
|
||||||
@@ -170,14 +172,15 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
rate_limit_record["count"] = 0
|
rate_limit_record["count"] = 0
|
||||||
|
|
||||||
rate_limit_record["count"] += 1
|
rate_limit_record["count"] += 1
|
||||||
if rate_limit_record["count"] > self.rate_limit_per_minute:
|
# Skip if rate limiting is disabled (rate_limit_per_minute = 0)
|
||||||
|
if self.rate_limit_per_minute > 0 and rate_limit_record["count"] > self.rate_limit_per_minute:
|
||||||
# Too many requests in window — return a JSON 429 response
|
# Too many requests in window — return a JSON 429 response
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
content={
|
content={
|
||||||
"detail": (
|
"detail": (
|
||||||
"Too many authentication attempts, "
|
"Too many authentication attempts, "
|
||||||
"try again later"
|
"try again later. IP lockout"
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -74,9 +74,8 @@ def register_exception_handlers(app: FastAPI) -> None:
|
|||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""Handle authentication errors (401)."""
|
"""Handle authentication errors (401)."""
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Authentication error: %s",
|
"Authentication error: %s details=%s path=%s",
|
||||||
exc.message,
|
exc.message, exc.details, str(request.url.path),
|
||||||
extra={"details": exc.details, "path": str(request.url.path)},
|
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
@@ -95,9 +94,8 @@ def register_exception_handlers(app: FastAPI) -> None:
|
|||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""Handle authorization errors (403)."""
|
"""Handle authorization errors (403)."""
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Authorization error: %s",
|
"Authorization error: %s details=%s path=%s",
|
||||||
exc.message,
|
exc.message, exc.details, str(request.url.path),
|
||||||
extra={"details": exc.details, "path": str(request.url.path)},
|
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
@@ -116,9 +114,8 @@ def register_exception_handlers(app: FastAPI) -> None:
|
|||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""Handle validation errors (422)."""
|
"""Handle validation errors (422)."""
|
||||||
logger.info(
|
logger.info(
|
||||||
"Validation error: %s",
|
"Validation error: %s details=%s path=%s",
|
||||||
exc.message,
|
exc.message, exc.details, str(request.url.path),
|
||||||
extra={"details": exc.details, "path": str(request.url.path)},
|
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
@@ -137,9 +134,8 @@ def register_exception_handlers(app: FastAPI) -> None:
|
|||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""Handle bad request errors (400)."""
|
"""Handle bad request errors (400)."""
|
||||||
logger.info(
|
logger.info(
|
||||||
"Bad request error: %s",
|
"Bad request error: %s details=%s path=%s",
|
||||||
exc.message,
|
exc.message, exc.details, str(request.url.path),
|
||||||
extra={"details": exc.details, "path": str(request.url.path)},
|
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
@@ -158,9 +154,8 @@ def register_exception_handlers(app: FastAPI) -> None:
|
|||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""Handle not found errors (404)."""
|
"""Handle not found errors (404)."""
|
||||||
logger.info(
|
logger.info(
|
||||||
"Not found error: %s",
|
"Not found error: %s details=%s path=%s",
|
||||||
exc.message,
|
exc.message, exc.details, str(request.url.path),
|
||||||
extra={"details": exc.details, "path": str(request.url.path)},
|
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
@@ -179,9 +174,8 @@ def register_exception_handlers(app: FastAPI) -> None:
|
|||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""Handle conflict errors (409)."""
|
"""Handle conflict errors (409)."""
|
||||||
logger.info(
|
logger.info(
|
||||||
"Conflict error: %s",
|
"Conflict error: %s details=%s path=%s",
|
||||||
exc.message,
|
exc.message, exc.details, str(request.url.path),
|
||||||
extra={"details": exc.details, "path": str(request.url.path)},
|
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
@@ -200,9 +194,8 @@ def register_exception_handlers(app: FastAPI) -> None:
|
|||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""Handle rate limit errors (429)."""
|
"""Handle rate limit errors (429)."""
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Rate limit exceeded: %s",
|
"Rate limit exceeded: %s details=%s path=%s",
|
||||||
exc.message,
|
exc.message, exc.details, str(request.url.path),
|
||||||
extra={"details": exc.details, "path": str(request.url.path)},
|
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
@@ -221,13 +214,8 @@ def register_exception_handlers(app: FastAPI) -> None:
|
|||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""Handle generic API exceptions."""
|
"""Handle generic API exceptions."""
|
||||||
logger.error(
|
logger.error(
|
||||||
"API error: %s",
|
"API error: %s error_code=%s details=%s path=%s",
|
||||||
exc.message,
|
exc.message, exc.error_code, exc.details, str(request.url.path),
|
||||||
extra={
|
|
||||||
"error_code": exc.error_code,
|
|
||||||
"details": exc.details,
|
|
||||||
"path": str(request.url.path),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
@@ -245,10 +233,9 @@ def register_exception_handlers(app: FastAPI) -> None:
|
|||||||
request: Request, exc: Exception
|
request: Request, exc: Exception
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""Handle unexpected exceptions."""
|
"""Handle unexpected exceptions."""
|
||||||
logger.exception(
|
logger.error(
|
||||||
"Unexpected error: %s",
|
"Unexpected error: %s path=%s",
|
||||||
str(exc),
|
str(exc), str(request.url.path),
|
||||||
extra={"path": str(request.url.path)},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log full traceback for debugging
|
# Log full traceback for debugging
|
||||||
|
|||||||
@@ -32,10 +32,12 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
|
|||||||
# Paths that should always be accessible, even without setup
|
# Paths that should always be accessible, even without setup
|
||||||
EXEMPT_PATHS = {
|
EXEMPT_PATHS = {
|
||||||
"/setup", # Setup page itself
|
"/setup", # Setup page itself
|
||||||
|
"/setup/unresolved", # Unresolved folders page (after setup)
|
||||||
"/loading", # Loading page (initialization progress)
|
"/loading", # Loading page (initialization progress)
|
||||||
"/login", # Login page (needs to be accessible after setup)
|
"/login", # Login page (needs to be accessible after setup)
|
||||||
"/queue", # Queue page (for initial load)
|
"/queue", # Queue page (for initial load)
|
||||||
"/api/auth/", # All auth endpoints (setup, login, logout, register)
|
"/api/auth/", # All auth endpoints (setup, login, logout, register)
|
||||||
|
"/api/setup/", # Setup API (unresolved folders, etc.)
|
||||||
"/ws/connect", # WebSocket connection (needed for loading page)
|
"/ws/connect", # WebSocket connection (needed for loading page)
|
||||||
"/api/queue/", # Queue API endpoints
|
"/api/queue/", # Queue API endpoints
|
||||||
"/api/downloads/", # Download API endpoints
|
"/api/downloads/", # Download API endpoints
|
||||||
@@ -79,30 +81,63 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
def _needs_setup(self) -> bool:
|
def _needs_setup(self) -> bool:
|
||||||
"""Check if the application needs initial setup.
|
"""Check if the application needs initial setup.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if setup is required, False otherwise
|
True if setup is required, False otherwise
|
||||||
"""
|
"""
|
||||||
# Check if master password is configured
|
# Check if master password is configured
|
||||||
if not auth_service.is_configured():
|
if not auth_service.is_configured():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check if config exists and is valid
|
# Check if config exists and is valid
|
||||||
try:
|
try:
|
||||||
config_service = get_config_service()
|
config_service = get_config_service()
|
||||||
config = config_service.load_config()
|
config = config_service.load_config()
|
||||||
|
|
||||||
|
# master_password_hash must exist in saved config (not just in-memory)
|
||||||
|
# This ensures reset actually puts app in unconfigured state
|
||||||
|
if not config.other.get('master_password_hash'):
|
||||||
|
return True
|
||||||
|
|
||||||
# Validate the loaded config
|
# Validate the loaded config
|
||||||
validation = config.validate_config()
|
validation = config.validate_config()
|
||||||
if not validation.valid:
|
if not validation.valid:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except (FileNotFoundError, ValueError, OSError, AttributeError):
|
except (FileNotFoundError, ValueError, OSError, AttributeError):
|
||||||
# If we can't load or validate config, setup is needed
|
# If we can't load or validate config, setup is needed
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _is_unresolved_completed(self) -> bool:
|
||||||
|
"""Check if the unresolved phase has been completed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if unresolved phase is complete, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config_service = get_config_service()
|
||||||
|
config = config_service.load_config()
|
||||||
|
other = config.other or {}
|
||||||
|
return bool(other.get('unresolved_completed', False))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_loading_complete(self) -> bool:
|
||||||
|
"""Check if initial loading has completed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if loading is complete, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config_service = get_config_service()
|
||||||
|
config = config_service.load_config()
|
||||||
|
other = config.other or {}
|
||||||
|
return bool(other.get('loading_complete', False))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
async def dispatch(
|
async def dispatch(
|
||||||
self, request: Request, call_next: Callable
|
self, request: Request, call_next: Callable
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -116,31 +151,34 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
|
|||||||
Either a redirect to /setup or the normal response
|
Either a redirect to /setup or the normal response
|
||||||
"""
|
"""
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
|
query_params = request.query_params
|
||||||
|
|
||||||
# Check if trying to access setup or loading page after completion
|
# Check if trying to access setup or loading page after completion
|
||||||
if path in ("/setup", "/loading"):
|
if path in ("/setup", "/loading", "/setup/unresolved"):
|
||||||
if not self._needs_setup():
|
if not self._needs_setup():
|
||||||
# Setup is complete, check loading status
|
|
||||||
if path == "/setup":
|
if path == "/setup":
|
||||||
# Redirect to loading if initialization is in progress
|
# Redirect to login if setup is already complete
|
||||||
# Otherwise redirect to login
|
|
||||||
return RedirectResponse(url="/login", status_code=302)
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
elif path == "/setup/unresolved":
|
||||||
|
# Check if unresolved phase is already completed
|
||||||
|
if self._is_unresolved_completed():
|
||||||
|
# Redirect to loading - unresolved phase already done
|
||||||
|
return RedirectResponse(url="/loading?phase=nfo", status_code=302)
|
||||||
elif path == "/loading":
|
elif path == "/loading":
|
||||||
# Check if initialization is complete
|
# Handle phase query parameter
|
||||||
try:
|
phase = query_params.get("phase")
|
||||||
from src.server.database.connection import get_db_session
|
if phase == "initial":
|
||||||
from src.server.database.system_settings_service import (
|
# Only redirect if loading has actually completed
|
||||||
SystemSettingsService,
|
# If loading_started=True but loading_complete=False, user should stay
|
||||||
)
|
# on loading page to see progress
|
||||||
|
if self._is_loading_complete():
|
||||||
async with get_db_session() as db:
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
is_complete = await SystemSettingsService.is_initial_scan_completed(db)
|
# Otherwise, allow access to loading page (loading in progress)
|
||||||
if is_complete:
|
elif not phase:
|
||||||
# Initialization complete, redirect to login
|
# No phase specified and loading is complete
|
||||||
return RedirectResponse(url="/login", status_code=302)
|
if self._is_loading_complete():
|
||||||
except Exception:
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
# If we can't check, allow access to loading page
|
# phase=nfo is always allowed - it triggers the NFO scan phase
|
||||||
pass
|
|
||||||
|
|
||||||
# Skip setup check for exempt paths
|
# Skip setup check for exempt paths
|
||||||
if self._is_path_exempt(path):
|
if self._is_path_exempt(path):
|
||||||
|
|||||||
@@ -10,15 +10,11 @@ Note on identifiers:
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
||||||
|
|
||||||
# Regex pattern for valid series keys (URL-safe, lowercase with hyphens)
|
|
||||||
KEY_PATTERN = re.compile(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$')
|
|
||||||
|
|
||||||
|
|
||||||
class EpisodeInfo(BaseModel):
|
class EpisodeInfo(BaseModel):
|
||||||
"""Information about a single episode."""
|
"""Information about a single episode."""
|
||||||
@@ -83,30 +79,6 @@ class AnimeSeriesResponse(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
class AnimeMetadataUpdate(BaseModel):
|
|
||||||
"""Request model for updating anime metadata (key, tmdb_id, tvdb_id)."""
|
|
||||||
|
|
||||||
key: Optional[str] = Field(None, description="New series key (URL-safe, lowercase)")
|
|
||||||
tmdb_id: Optional[int] = Field(None, ge=1, description="TMDB ID (positive integer)")
|
|
||||||
tvdb_id: Optional[int] = Field(None, ge=1, description="TVDB ID (positive integer)")
|
|
||||||
|
|
||||||
@field_validator('key', mode='before')
|
|
||||||
@classmethod
|
|
||||||
def validate_key_format(cls, v: Optional[str]) -> Optional[str]:
|
|
||||||
"""Validate key is URL-safe lowercase with hyphens only."""
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
v = v.strip().lower()
|
|
||||||
if not v:
|
|
||||||
raise ValueError("Key cannot be empty")
|
|
||||||
if not KEY_PATTERN.match(v):
|
|
||||||
raise ValueError(
|
|
||||||
"Key must contain only lowercase letters, numbers, and hyphens. "
|
|
||||||
"Cannot start or end with a hyphen."
|
|
||||||
)
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class SearchRequest(BaseModel):
|
class SearchRequest(BaseModel):
|
||||||
"""Request payload for searching series."""
|
"""Request payload for searching series."""
|
||||||
|
|
||||||
@@ -140,9 +112,14 @@ class SearchResult(BaseModel):
|
|||||||
"(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only."
|
"(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
snippet: Optional[str] = Field(None, description="Short description or snippet")
|
snippet: Optional[str] = Field(
|
||||||
thumbnail: Optional[HttpUrl] = Field(None, description="Thumbnail image URL")
|
None,
|
||||||
score: Optional[float] = Field(None, ge=0.0, le=1.0, description="Search relevance score (0-1)")
|
description="Search result snippet or description"
|
||||||
|
)
|
||||||
|
score: Optional[float] = Field(
|
||||||
|
None,
|
||||||
|
description="Search relevance score (0.0 to 1.0)"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator('key', mode='before')
|
@field_validator('key', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -151,3 +128,170 @@ class SearchResult(BaseModel):
|
|||||||
if isinstance(v, str):
|
if isinstance(v, str):
|
||||||
return v.lower().strip()
|
return v.lower().strip()
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class AnimeDetailsResponse(BaseModel):
|
||||||
|
"""Detailed response model for a single anime series with all metadata.
|
||||||
|
|
||||||
|
Used by the edit modal to pre-fill form fields with existing data.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
key: Unique series identifier
|
||||||
|
name: Display name
|
||||||
|
folder: Filesystem folder name
|
||||||
|
year: Release year
|
||||||
|
status: Show status (Continuing, Ended)
|
||||||
|
plot: Plot description
|
||||||
|
genres: List of genres
|
||||||
|
studio: List of studios
|
||||||
|
premiered: Premiere date
|
||||||
|
rating: Rating value (0-10)
|
||||||
|
rating_votes: Number of votes
|
||||||
|
tmdb_id: TMDB ID
|
||||||
|
tvdb_id: TVDB ID
|
||||||
|
has_nfo: Whether NFO file exists
|
||||||
|
nfo_created_at: NFO creation timestamp
|
||||||
|
nfo_updated_at: NFO update timestamp
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str = Field(..., description="Unique series identifier")
|
||||||
|
name: str = Field(..., description="Display name")
|
||||||
|
folder: Optional[str] = Field(None, description="Filesystem folder name")
|
||||||
|
year: Optional[int] = Field(None, ge=1900, le=2100, description="Release year")
|
||||||
|
status: Optional[str] = Field(None, description="Show status (Continuing, Ended)")
|
||||||
|
plot: Optional[str] = Field(None, description="Plot description")
|
||||||
|
genres: List[str] = Field(default_factory=list, description="List of genres")
|
||||||
|
studio: List[str] = Field(default_factory=list, description="List of studios")
|
||||||
|
premiered: Optional[str] = Field(None, description="Premiere date (YYYY-MM-DD)")
|
||||||
|
rating: Optional[float] = Field(None, ge=0, le=10, description="Rating value (0-10)")
|
||||||
|
rating_votes: Optional[int] = Field(None, ge=0, description="Number of votes")
|
||||||
|
tmdb_id: Optional[int] = Field(None, description="TMDB ID")
|
||||||
|
tvdb_id: Optional[int] = Field(None, description="TVDB ID")
|
||||||
|
has_nfo: bool = Field(False, description="Whether NFO file exists")
|
||||||
|
nfo_created_at: Optional[str] = Field(None, description="NFO creation timestamp")
|
||||||
|
nfo_updated_at: Optional[str] = Field(None, description="NFO update timestamp")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"key": "attack-on-titan",
|
||||||
|
"name": "Attack on Titan",
|
||||||
|
"folder": "Attack on Titan (2013)",
|
||||||
|
"year": 2013,
|
||||||
|
"status": "Ended",
|
||||||
|
"plot": "Humans fight against giant humanoid Titans.",
|
||||||
|
"genres": ["Animation", "Action", "Drama"],
|
||||||
|
"studio": ["Wit Studio", "MAPPA"],
|
||||||
|
"premiered": "2013-04-07",
|
||||||
|
"rating": 9.0,
|
||||||
|
"rating_votes": 500000,
|
||||||
|
"tmdb_id": 1429,
|
||||||
|
"tvdb_id": 267440,
|
||||||
|
"has_nfo": True,
|
||||||
|
"nfo_created_at": "2025-01-15T10:30:00Z",
|
||||||
|
"nfo_updated_at": "2025-01-15T10:30:00Z",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TMDBSearchResult(BaseModel):
|
||||||
|
"""TMDB search result for auto-lookup functionality.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
tmdb_id: TMDB ID of the matched series
|
||||||
|
title: Title from TMDB
|
||||||
|
year: Release year
|
||||||
|
overview: Short description
|
||||||
|
vote_average: TMDB rating
|
||||||
|
"""
|
||||||
|
|
||||||
|
tmdb_id: int = Field(..., description="TMDB ID")
|
||||||
|
title: str = Field(..., description="Title from TMDB")
|
||||||
|
year: Optional[int] = Field(None, description="Release year")
|
||||||
|
overview: Optional[str] = Field(None, description="Short description")
|
||||||
|
vote_average: Optional[float] = Field(None, description="TMDB rating")
|
||||||
|
|
||||||
|
|
||||||
|
class AnimeSettingsResponse(BaseModel):
|
||||||
|
"""Response payload for the Anime Settings page.
|
||||||
|
|
||||||
|
Surfaces every anime_series field that can be viewed or edited
|
||||||
|
by the user. Used by GET /api/anime/{key}/settings and the
|
||||||
|
PUT response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str = Field(..., description="Series unique key (primary identifier)")
|
||||||
|
name: str = Field(..., description="Series display name")
|
||||||
|
site: str = Field(..., description="Provider site URL")
|
||||||
|
folder: str = Field(..., description="Filesystem folder name")
|
||||||
|
year: Optional[int] = Field(None, description="Release year")
|
||||||
|
tmdb_id: Optional[int] = Field(None, description="TMDB ID")
|
||||||
|
tvdb_id: Optional[int] = Field(None, description="TVDB ID")
|
||||||
|
has_nfo: bool = Field(False, description="Whether tvshow.nfo exists")
|
||||||
|
nfo_path: Optional[str] = Field(None, description="Path to tvshow.nfo file")
|
||||||
|
nfo_created_at: Optional[str] = Field(None, description="ISO timestamp when NFO created")
|
||||||
|
nfo_updated_at: Optional[str] = Field(None, description="ISO timestamp when NFO updated")
|
||||||
|
loading_status: Optional[str] = Field(
|
||||||
|
None, description="Current loading status of the series"
|
||||||
|
)
|
||||||
|
episode_count: int = Field(0, description="Total number of episodes tracked")
|
||||||
|
missing_episode_count: int = Field(0, description="Number of missing episodes")
|
||||||
|
|
||||||
|
|
||||||
|
class AnimeSettingsUpdateRequest(BaseModel):
|
||||||
|
"""Request payload for PUT /api/anime/{key}/settings.
|
||||||
|
|
||||||
|
All fields are optional. Only the fields that are provided will
|
||||||
|
be updated. Field-level validation happens in the API endpoint
|
||||||
|
(e.g. folder sanitization, TMDB ID format).
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
min_length=1,
|
||||||
|
max_length=500,
|
||||||
|
description="Series display name",
|
||||||
|
)
|
||||||
|
folder: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
min_length=1,
|
||||||
|
max_length=1000,
|
||||||
|
description="Filesystem folder name",
|
||||||
|
)
|
||||||
|
tmdb_id: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
ge=1,
|
||||||
|
le=9999999999,
|
||||||
|
description="TMDB ID (positive integer, max 10 digits)",
|
||||||
|
)
|
||||||
|
tvdb_id: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
ge=1,
|
||||||
|
le=9999999999,
|
||||||
|
description="TVDB ID (positive integer, max 10 digits)",
|
||||||
|
)
|
||||||
|
site: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
max_length=500,
|
||||||
|
description="Provider site URL",
|
||||||
|
)
|
||||||
|
apply_to_nfo: bool = Field(
|
||||||
|
False,
|
||||||
|
description="If true, regenerate tvshow.nfo with the new values",
|
||||||
|
)
|
||||||
|
rename_disk: bool = Field(
|
||||||
|
False,
|
||||||
|
description="If true and folder changed, rename the folder on disk",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AnimeSettingsRegenerateNfoResponse(BaseModel):
|
||||||
|
"""Response payload for POST /api/anime/{key}/regenerate-nfo."""
|
||||||
|
|
||||||
|
success: bool = Field(..., description="Whether regeneration succeeded")
|
||||||
|
message: str = Field(..., description="Human-readable result message")
|
||||||
|
nfo_path: Optional[str] = Field(None, description="Path to regenerated NFO file")
|
||||||
|
repaired_tags: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Tags that were missing before regeneration",
|
||||||
|
)
|
||||||
|
|||||||
@@ -73,9 +73,6 @@ class SetupRequest(BaseModel):
|
|||||||
scheduler_auto_download_after_rescan: Optional[bool] = Field(
|
scheduler_auto_download_after_rescan: Optional[bool] = Field(
|
||||||
default=False, description="Auto-download missing episodes after rescan"
|
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 configuration
|
||||||
logging_level: Optional[str] = Field(
|
logging_level: Optional[str] = Field(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from typing import Dict, List, Optional
|
import ast
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
|
||||||
|
|
||||||
_VALID_DAYS = frozenset(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])
|
_VALID_DAYS = frozenset(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])
|
||||||
_ALL_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
_ALL_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||||
@@ -39,14 +41,25 @@ class SchedulerConfig(BaseModel):
|
|||||||
description="Automatically queue and start downloads for all missing "
|
description="Automatically queue and start downloads for all missing "
|
||||||
"episodes after a scheduled rescan completes.",
|
"episodes after a scheduled rescan completes.",
|
||||||
)
|
)
|
||||||
folder_scan_enabled: bool = Field(
|
nfo_scan_after_rescan: bool = Field(
|
||||||
default=False,
|
default=True,
|
||||||
description="Run folder maintenance (NFO repair, folder renaming, "
|
description="Run NFO validation and creation after a scheduled rescan "
|
||||||
"poster checks) during the scheduled run.",
|
"completes. Checks each series folder for tvshow.nfo and "
|
||||||
|
"creates or fills missing properties.",
|
||||||
|
)
|
||||||
|
image_scan_after_rescan: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Download series images (poster.jpg, fanart.jpg, logo.png) "
|
||||||
|
"from TMDB after a scheduled rescan completes.",
|
||||||
|
)
|
||||||
|
folder_naming_after_nfo_scan: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Fix missing years in folder names after NFO refresh. "
|
||||||
|
"Renames folders (e.g. 'Naruto' -> 'Naruto (1999)') using "
|
||||||
|
"the year from the database record.",
|
||||||
)
|
)
|
||||||
# Legacy alias fields — read via Pydantic alias
|
# Legacy alias fields — read via Pydantic alias
|
||||||
auto_download: Optional[bool] = Field(default=None, alias="auto_download")
|
auto_download: Optional[bool] = Field(default=None, alias="auto_download")
|
||||||
folder_scan: Optional[bool] = Field(default=None, alias="folder_scan")
|
|
||||||
|
|
||||||
def __init__(self, **data):
|
def __init__(self, **data):
|
||||||
super().__init__(**data)
|
super().__init__(**data)
|
||||||
@@ -54,8 +67,6 @@ class SchedulerConfig(BaseModel):
|
|||||||
# "key in data" checks for explicit presence (even False/None), not just truthiness.
|
# "key in data" checks for explicit presence (even False/None), not just truthiness.
|
||||||
if self.auto_download is not None and "auto_download_after_rescan" not in data:
|
if self.auto_download is not None and "auto_download_after_rescan" not in data:
|
||||||
object.__setattr__(self, "auto_download_after_rescan", self.auto_download)
|
object.__setattr__(self, "auto_download_after_rescan", self.auto_download)
|
||||||
if self.folder_scan is not None and "folder_scan_enabled" not in data:
|
|
||||||
object.__setattr__(self, "folder_scan_enabled", self.folder_scan)
|
|
||||||
|
|
||||||
@field_validator("schedule_time")
|
@field_validator("schedule_time")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -69,6 +80,40 @@ class SchedulerConfig(BaseModel):
|
|||||||
)
|
)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_schedule_days(cls, v):
|
||||||
|
"""Parse schedule_days that may arrive as a malformed string.
|
||||||
|
|
||||||
|
Robot Framework's Create Dictionary converts Python-style lists
|
||||||
|
like ['monday', 'tuesday'] into strings. Handle that here before
|
||||||
|
Pydantic's type validation runs.
|
||||||
|
"""
|
||||||
|
if not isinstance(v, str):
|
||||||
|
return v
|
||||||
|
# Try JSON first (double-quoted), then Python literal (single-quoted)
|
||||||
|
for parse_fn in (json.loads, ast.literal_eval):
|
||||||
|
try:
|
||||||
|
parsed = parse_fn(v)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return parsed
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Cannot parse - let Pydantic handle the error
|
||||||
|
return v
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _pre_validate(cls, data):
|
||||||
|
"""Handle malformed schedule_days from Robot Framework before type validation."""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
sd = data.get("schedule_days")
|
||||||
|
if isinstance(sd, str):
|
||||||
|
parsed = cls._parse_schedule_days(sd)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
data = dict(data)
|
||||||
|
data["schedule_days"] = parsed
|
||||||
|
return data
|
||||||
|
|
||||||
@field_validator("schedule_days")
|
@field_validator("schedule_days")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_schedule_days(cls, v: List[str]) -> List[str]:
|
def validate_schedule_days(cls, v: List[str]) -> List[str]:
|
||||||
@@ -122,10 +167,10 @@ class LoggingConfig(BaseModel):
|
|||||||
default=None, description="Optional file path for log output"
|
default=None, description="Optional file path for log output"
|
||||||
)
|
)
|
||||||
max_bytes: Optional[int] = Field(
|
max_bytes: Optional[int] = Field(
|
||||||
default=None, ge=0, description="Max bytes per log file for rotation"
|
default=None, description="Max bytes per log file for rotation"
|
||||||
)
|
)
|
||||||
backup_count: Optional[int] = Field(
|
backup_count: Optional[int] = Field(
|
||||||
default=3, ge=0, description="Number of rotated log files to keep"
|
default=3, description="Number of rotated log files to keep"
|
||||||
)
|
)
|
||||||
|
|
||||||
@field_validator("level")
|
@field_validator("level")
|
||||||
@@ -239,12 +284,67 @@ class AppConfig(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ConfigUpdate(BaseModel):
|
class ConfigUpdate(BaseModel):
|
||||||
scheduler: Optional[SchedulerConfig] = None
|
name: Optional[str] = None
|
||||||
logging: Optional[LoggingConfig] = None
|
data_dir: Optional[str] = None
|
||||||
backup: Optional[BackupConfig] = None
|
scheduler: Optional[Dict[str, Any]] = None
|
||||||
nfo: Optional[NFOConfig] = None
|
logging: Optional[Dict[str, Any]] = None
|
||||||
|
backup: Optional[Dict[str, Any]] = None
|
||||||
|
nfo: Optional[Dict[str, Any]] = None
|
||||||
scan_key_overrides: Optional[Dict[str, str]] = None
|
scan_key_overrides: Optional[Dict[str, str]] = None
|
||||||
other: Optional[Dict[str, object]] = None
|
other: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_dict_field(cls, v):
|
||||||
|
"""Parse a field that may arrive as a malformed string from Robot Framework.
|
||||||
|
|
||||||
|
Robot Framework's Create Dictionary converts Python-style nested dicts
|
||||||
|
like {'enabled': False} into their string representation. Handle that here
|
||||||
|
before Pydantic's type validation runs. Also handles Pydantic models being
|
||||||
|
passed directly (from unit tests).
|
||||||
|
"""
|
||||||
|
# Pydantic model - convert to dict first
|
||||||
|
if hasattr(v, 'model_dump'):
|
||||||
|
return v.model_dump()
|
||||||
|
if hasattr(v, 'dict'):
|
||||||
|
return v.dict()
|
||||||
|
# Already a dict
|
||||||
|
if isinstance(v, dict):
|
||||||
|
return v
|
||||||
|
# String - try parsing
|
||||||
|
if isinstance(v, str):
|
||||||
|
for parse_fn in (json.loads, ast.literal_eval):
|
||||||
|
try:
|
||||||
|
parsed = parse_fn(v)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return v
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _pre_validate(cls, data):
|
||||||
|
"""Handle malformed dict strings from Robot Framework and Pydantic models passed directly.
|
||||||
|
|
||||||
|
Robot Framework's Create Dictionary converts Python-style nested dicts
|
||||||
|
like {'enabled': False} into their string representation.
|
||||||
|
Unit tests may pass Pydantic model instances directly.
|
||||||
|
Both cases need conversion before type validation.
|
||||||
|
"""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = dict(data) # make mutable
|
||||||
|
for field in ("name", "data_dir", "scheduler", "logging", "backup", "nfo", "scan_key_overrides", "other"):
|
||||||
|
if field in data:
|
||||||
|
v = data[field]
|
||||||
|
# Pydantic model - convert to dict
|
||||||
|
if hasattr(v, "model_dump"):
|
||||||
|
data[field] = v.model_dump()
|
||||||
|
# String from Robot Framework - try parsing
|
||||||
|
elif isinstance(v, str):
|
||||||
|
parsed = cls._parse_dict_field(v)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
data[field] = parsed
|
||||||
|
return data
|
||||||
|
|
||||||
def apply_to(self, current: AppConfig) -> AppConfig:
|
def apply_to(self, current: AppConfig) -> AppConfig:
|
||||||
"""Return a new AppConfig with updates applied to the current config.
|
"""Return a new AppConfig with updates applied to the current config.
|
||||||
@@ -252,18 +352,64 @@ class ConfigUpdate(BaseModel):
|
|||||||
Performs a shallow merge for `other`.
|
Performs a shallow merge for `other`.
|
||||||
"""
|
"""
|
||||||
data = current.model_dump()
|
data = current.model_dump()
|
||||||
|
if self.name is not None:
|
||||||
|
data["name"] = self.name
|
||||||
|
if self.data_dir is not None:
|
||||||
|
data["data_dir"] = self.data_dir
|
||||||
if self.scheduler is not None:
|
if self.scheduler is not None:
|
||||||
data["scheduler"] = self.scheduler.model_dump()
|
scheduler_data = self.scheduler
|
||||||
|
if isinstance(scheduler_data, str):
|
||||||
|
try:
|
||||||
|
scheduler_data = json.loads(scheduler_data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
scheduler_data = ast.literal_eval(scheduler_data)
|
||||||
|
if isinstance(scheduler_data, dict):
|
||||||
|
try:
|
||||||
|
scheduler_data = SchedulerConfig(**scheduler_data)
|
||||||
|
except ValidationError:
|
||||||
|
raise
|
||||||
|
data["scheduler"] = scheduler_data.model_dump()
|
||||||
if self.logging is not None:
|
if self.logging is not None:
|
||||||
data["logging"] = self.logging.model_dump()
|
logging_data = self.logging
|
||||||
|
if isinstance(logging_data, str):
|
||||||
|
try:
|
||||||
|
logging_data = json.loads(logging_data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logging_data = ast.literal_eval(logging_data)
|
||||||
|
if isinstance(logging_data, dict):
|
||||||
|
logging_data = LoggingConfig(**logging_data)
|
||||||
|
data["logging"] = logging_data.model_dump()
|
||||||
if self.backup is not None:
|
if self.backup is not None:
|
||||||
data["backup"] = self.backup.model_dump()
|
backup_data = self.backup
|
||||||
|
if isinstance(backup_data, str):
|
||||||
|
try:
|
||||||
|
backup_data = json.loads(backup_data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
backup_data = ast.literal_eval(backup_data)
|
||||||
|
if isinstance(backup_data, dict):
|
||||||
|
backup_data = BackupConfig(**backup_data)
|
||||||
|
data["backup"] = backup_data.model_dump()
|
||||||
if self.nfo is not None:
|
if self.nfo is not None:
|
||||||
data["nfo"] = self.nfo.model_dump()
|
nfo_data = self.nfo
|
||||||
|
if isinstance(nfo_data, str):
|
||||||
|
try:
|
||||||
|
nfo_data = json.loads(nfo_data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
nfo_data = ast.literal_eval(nfo_data)
|
||||||
|
if isinstance(nfo_data, dict):
|
||||||
|
nfo_data = NFOConfig(**nfo_data)
|
||||||
|
data["nfo"] = nfo_data.model_dump()
|
||||||
if self.scan_key_overrides is not None:
|
if self.scan_key_overrides is not None:
|
||||||
data["scan_key_overrides"] = self.scan_key_overrides
|
data["scan_key_overrides"] = self.scan_key_overrides
|
||||||
if self.other is not None:
|
if self.other is not None:
|
||||||
merged = dict(current.other or {})
|
merged = dict(current.other or {})
|
||||||
merged.update(self.other)
|
other_data = self.other
|
||||||
|
if isinstance(other_data, str):
|
||||||
|
try:
|
||||||
|
other_data = json.loads(other_data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
other_data = ast.literal_eval(other_data)
|
||||||
|
if isinstance(other_data, dict):
|
||||||
|
merged.update(other_data)
|
||||||
data["other"] = merged
|
data["other"] = merged
|
||||||
return AppConfig(**data)
|
return AppConfig(**data)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ on serialization, validation, and OpenAPI documentation.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
@@ -42,6 +44,48 @@ class EpisodeIdentifier(BaseModel):
|
|||||||
)
|
)
|
||||||
title: Optional[str] = Field(None, description="Episode title if known")
|
title: Optional[str] = Field(None, description="Episode title if known")
|
||||||
|
|
||||||
|
@field_validator("season", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_season(cls, v):
|
||||||
|
"""Parse season from string JSON if needed."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(v)
|
||||||
|
return parsed.get("season") if isinstance(parsed, dict) else v
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return v
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("episode", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_episode(cls, v):
|
||||||
|
"""Parse episode from string JSON if needed."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(v)
|
||||||
|
return parsed.get("episode") if isinstance(parsed, dict) else v
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return v
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("title", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_title(cls, v):
|
||||||
|
"""Parse title from string JSON if needed."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(v)
|
||||||
|
return parsed.get("title") if isinstance(parsed, dict) else v
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return v
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class DownloadProgress(BaseModel):
|
class DownloadProgress(BaseModel):
|
||||||
"""Real-time progress information for an active download."""
|
"""Real-time progress information for an active download."""
|
||||||
@@ -218,7 +262,36 @@ class DownloadRequest(BaseModel):
|
|||||||
priority: DownloadPriority = Field(
|
priority: DownloadPriority = Field(
|
||||||
DownloadPriority.NORMAL, description="Priority level for queue items"
|
DownloadPriority.NORMAL, description="Priority level for queue items"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@field_validator('episodes', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def parse_episodes(cls, v):
|
||||||
|
"""Parse episodes list, handling potential string-encoded dicts."""
|
||||||
|
if not isinstance(v, list):
|
||||||
|
return v
|
||||||
|
result = []
|
||||||
|
for item in v:
|
||||||
|
if isinstance(item, str):
|
||||||
|
# Try to parse string as JSON dict first
|
||||||
|
parsed = None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(item)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
# If JSON failed, try Python dict string syntax
|
||||||
|
if parsed is None:
|
||||||
|
try:
|
||||||
|
parsed = ast.literal_eval(item)
|
||||||
|
except (ValueError, SyntaxError, TypeError):
|
||||||
|
pass
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
result.append(parsed)
|
||||||
|
else:
|
||||||
|
result.append(item)
|
||||||
|
else:
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
@field_validator('priority', mode='before')
|
@field_validator('priority', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
def normalize_priority(cls, v):
|
def normalize_priority(cls, v):
|
||||||
|
|||||||
@@ -357,8 +357,8 @@ class NFOMissingResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class NfoDiagnosticsResponse(BaseModel):
|
class NfoSettingsResponse(BaseModel):
|
||||||
"""Response for NFO diagnostics showing missing required tags."""
|
"""Response for NFO settings inspection showing missing required tags."""
|
||||||
|
|
||||||
has_nfo: bool = Field(..., description="Whether tvshow.nfo exists")
|
has_nfo: bool = Field(..., description="Whether tvshow.nfo exists")
|
||||||
nfo_path: Optional[str] = Field(None, description="Path to NFO file if exists")
|
nfo_path: Optional[str] = Field(None, description="Path to NFO file if exists")
|
||||||
@@ -372,6 +372,20 @@ class NfoDiagnosticsResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NfoSeriesSettings(BaseModel):
|
||||||
|
"""Settings summary for a single series in the needs-repair list."""
|
||||||
|
|
||||||
|
key: str = Field(..., description="Series unique key")
|
||||||
|
name: str = Field(..., description="Series display name")
|
||||||
|
folder: str = Field(..., description="Series folder name")
|
||||||
|
has_nfo: bool = Field(..., description="Whether tvshow.nfo exists")
|
||||||
|
missing_tags: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="List of missing required tag names"
|
||||||
|
)
|
||||||
|
tmdb_id: Optional[int] = Field(None, description="TMDB ID if available")
|
||||||
|
|
||||||
|
|
||||||
class NfoRepairResponse(BaseModel):
|
class NfoRepairResponse(BaseModel):
|
||||||
"""Response after NFO repair attempt."""
|
"""Response after NFO repair attempt."""
|
||||||
|
|
||||||
|
|||||||
9
src/server/nfo/__init__.py
Normal file
9
src/server/nfo/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""NFO package - TV show metadata generation for Kodi/XBMC.
|
||||||
|
|
||||||
|
Re-exports the public API for the nfo package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from src.server.nfo.nfo_models import TVShowNFO
|
||||||
|
from src.server.nfo.tmdb_client import TMDBClient, TMDBAPIError
|
||||||
|
from src.server.nfo.nfo_generator import generate_tvshow_nfo
|
||||||
|
from src.server.nfo.nfo_mapper import tmdb_to_nfo_model
|
||||||
@@ -4,7 +4,7 @@ This module provides functions to generate tvshow.nfo XML files from
|
|||||||
TVShowNFO Pydantic models, adapted from the scraper project.
|
TVShowNFO Pydantic models, adapted from the scraper project.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> from src.core.entities.nfo_models import TVShowNFO
|
>>> from src.server.nfo.nfo_models import TVShowNFO
|
||||||
>>> nfo = TVShowNFO(title="Test Show", year=2020, tmdbid=12345)
|
>>> nfo = TVShowNFO(title="Test Show", year=2020, tmdbid=12345)
|
||||||
>>> xml_string = generate_tvshow_nfo(nfo)
|
>>> xml_string = generate_tvshow_nfo(nfo)
|
||||||
"""
|
"""
|
||||||
@@ -15,7 +15,7 @@ from typing import Optional
|
|||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.core.entities.nfo_models import TVShowNFO
|
from src.server.nfo.nfo_models import TVShowNFO
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
from src.core.entities.nfo_models import (
|
from src.server.nfo.nfo_models import (
|
||||||
ActorInfo,
|
ActorInfo,
|
||||||
ImageInfo,
|
ImageInfo,
|
||||||
NamedSeason,
|
NamedSeason,
|
||||||
335
src/server/nfo/nfo_models.py
Normal file
335
src/server/nfo/nfo_models.py
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
"""Pydantic models for NFO metadata based on Kodi/XBMC standard.
|
||||||
|
|
||||||
|
This module provides data models for tvshow.nfo files that are compatible
|
||||||
|
with media center applications like Kodi, Plex, and Jellyfin.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> nfo = TVShowNFO(
|
||||||
|
... title="Attack on Titan",
|
||||||
|
... year=2013,
|
||||||
|
... tmdbid=1429
|
||||||
|
... )
|
||||||
|
>>> nfo.premiered = "2013-04-07"
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class RatingInfo(BaseModel):
|
||||||
|
"""Rating information from various sources.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: Source of the rating (e.g., 'themoviedb', 'imdb')
|
||||||
|
value: Rating value (typically 0-10)
|
||||||
|
votes: Number of votes
|
||||||
|
max_rating: Maximum possible rating (default: 10)
|
||||||
|
default: Whether this is the default rating to display
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = Field(..., description="Rating source name")
|
||||||
|
value: float = Field(..., ge=0, description="Rating value")
|
||||||
|
votes: Optional[int] = Field(None, ge=0, description="Number of votes")
|
||||||
|
max_rating: int = Field(10, ge=1, description="Maximum rating value")
|
||||||
|
default: bool = Field(False, description="Is this the default rating")
|
||||||
|
|
||||||
|
@field_validator('value')
|
||||||
|
@classmethod
|
||||||
|
def validate_value(cls, v: float, info) -> float:
|
||||||
|
"""Ensure rating value doesn't exceed max_rating."""
|
||||||
|
# Note: max_rating is not available yet during validation,
|
||||||
|
# so we use a reasonable default check
|
||||||
|
if v > 10:
|
||||||
|
raise ValueError("Rating value cannot exceed 10")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ActorInfo(BaseModel):
|
||||||
|
"""Actor/cast member information.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: Actor's name
|
||||||
|
role: Character name/role
|
||||||
|
thumb: URL to actor's photo
|
||||||
|
profile: URL to actor's profile page
|
||||||
|
tmdbid: TMDB ID for the actor
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = Field(..., description="Actor's name")
|
||||||
|
role: Optional[str] = Field(None, description="Character role")
|
||||||
|
thumb: Optional[HttpUrl] = Field(None, description="Actor photo URL")
|
||||||
|
profile: Optional[HttpUrl] = Field(None, description="Actor profile URL")
|
||||||
|
tmdbid: Optional[int] = Field(None, description="TMDB actor ID")
|
||||||
|
|
||||||
|
|
||||||
|
class ImageInfo(BaseModel):
|
||||||
|
"""Image information for posters, fanart, and logos.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
url: URL to the image
|
||||||
|
aspect: Image aspect/type (e.g., 'poster', 'clearlogo', 'logo')
|
||||||
|
season: Season number for season-specific images
|
||||||
|
type: Image type (e.g., 'season')
|
||||||
|
"""
|
||||||
|
|
||||||
|
url: HttpUrl = Field(..., description="Image URL")
|
||||||
|
aspect: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Image aspect (poster, clearlogo, logo)"
|
||||||
|
)
|
||||||
|
season: Optional[int] = Field(None, ge=-1, description="Season number")
|
||||||
|
type: Optional[str] = Field(None, description="Image type")
|
||||||
|
|
||||||
|
|
||||||
|
class NamedSeason(BaseModel):
|
||||||
|
"""Named season information.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
number: Season number
|
||||||
|
name: Season name/title
|
||||||
|
"""
|
||||||
|
|
||||||
|
number: int = Field(..., ge=0, description="Season number")
|
||||||
|
name: str = Field(..., description="Season name")
|
||||||
|
|
||||||
|
|
||||||
|
class UniqueID(BaseModel):
|
||||||
|
"""Unique identifier from various sources.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
type: ID source type (tmdb, imdb, tvdb)
|
||||||
|
value: The ID value
|
||||||
|
default: Whether this is the default ID
|
||||||
|
"""
|
||||||
|
|
||||||
|
type: str = Field(..., description="ID type (tmdb, imdb, tvdb)")
|
||||||
|
value: str = Field(..., description="ID value")
|
||||||
|
default: bool = Field(False, description="Is default ID")
|
||||||
|
|
||||||
|
|
||||||
|
class TVShowNFO(BaseModel):
|
||||||
|
"""Main tvshow.nfo structure following Kodi/XBMC standard.
|
||||||
|
|
||||||
|
This model represents the complete metadata for a TV show that can be
|
||||||
|
serialized to XML for use with media center applications.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
title: Main title of the show
|
||||||
|
originaltitle: Original title (e.g., in original language)
|
||||||
|
showtitle: Show title (often same as title)
|
||||||
|
sorttitle: Title used for sorting
|
||||||
|
year: Release year
|
||||||
|
plot: Full plot description
|
||||||
|
outline: Short plot summary
|
||||||
|
tagline: Show tagline/slogan
|
||||||
|
runtime: Episode runtime in minutes
|
||||||
|
mpaa: Content rating (e.g., TV-14, TV-MA)
|
||||||
|
certification: Additional certification info
|
||||||
|
premiered: Premiere date (YYYY-MM-DD format)
|
||||||
|
status: Show status (e.g., 'Continuing', 'Ended')
|
||||||
|
studio: List of production studios
|
||||||
|
genre: List of genres
|
||||||
|
country: List of countries
|
||||||
|
tag: List of tags/keywords
|
||||||
|
ratings: List of ratings from various sources
|
||||||
|
userrating: User's personal rating
|
||||||
|
watched: Whether the show has been watched
|
||||||
|
playcount: Number of times watched
|
||||||
|
tmdbid: TMDB ID
|
||||||
|
imdbid: IMDB ID
|
||||||
|
tvdbid: TVDB ID
|
||||||
|
uniqueid: List of unique IDs
|
||||||
|
thumb: List of thumbnail/poster images
|
||||||
|
fanart: List of fanart/backdrop images
|
||||||
|
actors: List of cast members
|
||||||
|
namedseason: List of named seasons
|
||||||
|
trailer: Trailer URL
|
||||||
|
dateadded: Date when added to library
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Required fields
|
||||||
|
title: str = Field(..., description="Show title", min_length=1)
|
||||||
|
|
||||||
|
# Basic information (optional)
|
||||||
|
originaltitle: Optional[str] = Field(None, description="Original title")
|
||||||
|
showtitle: Optional[str] = Field(None, description="Show title")
|
||||||
|
sorttitle: Optional[str] = Field(None, description="Sort title")
|
||||||
|
year: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
ge=1900,
|
||||||
|
le=2100,
|
||||||
|
description="Release year"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Plot and description
|
||||||
|
plot: Optional[str] = Field(None, description="Full plot description")
|
||||||
|
outline: Optional[str] = Field(None, description="Short plot summary")
|
||||||
|
tagline: Optional[str] = Field(None, description="Show tagline")
|
||||||
|
|
||||||
|
# Technical details
|
||||||
|
runtime: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
ge=0,
|
||||||
|
description="Episode runtime in minutes"
|
||||||
|
)
|
||||||
|
mpaa: Optional[str] = Field(None, description="Content rating")
|
||||||
|
fsk: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="German FSK rating (e.g., 'FSK 12', 'FSK 16')"
|
||||||
|
)
|
||||||
|
certification: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Certification info"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status and dates
|
||||||
|
premiered: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Premiere date (YYYY-MM-DD)"
|
||||||
|
)
|
||||||
|
status: Optional[str] = Field(None, description="Show status")
|
||||||
|
dateadded: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Date added to library"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Multi-value fields
|
||||||
|
studio: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Production studios"
|
||||||
|
)
|
||||||
|
genre: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Genres"
|
||||||
|
)
|
||||||
|
country: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Countries"
|
||||||
|
)
|
||||||
|
tag: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Tags/keywords"
|
||||||
|
)
|
||||||
|
|
||||||
|
# IDs
|
||||||
|
tmdbid: Optional[int] = Field(None, description="TMDB ID")
|
||||||
|
imdbid: Optional[str] = Field(None, description="IMDB ID")
|
||||||
|
tvdbid: Optional[int] = Field(None, description="TVDB ID")
|
||||||
|
uniqueid: List[UniqueID] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Unique IDs"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ratings and viewing info
|
||||||
|
ratings: List[RatingInfo] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Ratings"
|
||||||
|
)
|
||||||
|
userrating: Optional[float] = Field(
|
||||||
|
None,
|
||||||
|
ge=0,
|
||||||
|
le=10,
|
||||||
|
description="User rating"
|
||||||
|
)
|
||||||
|
watched: bool = Field(False, description="Watched status")
|
||||||
|
playcount: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
ge=0,
|
||||||
|
description="Play count"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Media
|
||||||
|
thumb: List[ImageInfo] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Thumbnail images"
|
||||||
|
)
|
||||||
|
fanart: List[ImageInfo] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Fanart images"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cast and crew
|
||||||
|
actors: List[ActorInfo] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Cast members"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Seasons
|
||||||
|
namedseason: List[NamedSeason] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Named seasons"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Additional
|
||||||
|
trailer: Optional[HttpUrl] = Field(None, description="Trailer URL")
|
||||||
|
|
||||||
|
@field_validator('premiered')
|
||||||
|
@classmethod
|
||||||
|
def validate_premiered_date(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
"""Validate premiered date format (YYYY-MM-DD)."""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
|
# Check format strictly: YYYY-MM-DD
|
||||||
|
if len(v) != 10 or v[4] != '-' or v[7] != '-':
|
||||||
|
raise ValueError(
|
||||||
|
"Premiered date must be in YYYY-MM-DD format"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
datetime.strptime(v, '%Y-%m-%d')
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
"Premiered date must be in YYYY-MM-DD format"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator('dateadded')
|
||||||
|
@classmethod
|
||||||
|
def validate_dateadded(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
"""Validate dateadded format (YYYY-MM-DD HH:MM:SS)."""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
|
# Check format strictly: YYYY-MM-DD HH:MM:SS
|
||||||
|
if len(v) != 19 or v[4] != '-' or v[7] != '-' or v[10] != ' ' or v[13] != ':' or v[16] != ':':
|
||||||
|
raise ValueError(
|
||||||
|
"Dateadded must be in YYYY-MM-DD HH:MM:SS format"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
datetime.strptime(v, '%Y-%m-%d %H:%M:%S')
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
"Dateadded must be in YYYY-MM-DD HH:MM:SS format"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator('imdbid')
|
||||||
|
@classmethod
|
||||||
|
def validate_imdbid(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
"""Validate IMDB ID format (should start with 'tt')."""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
|
if not v.startswith('tt'):
|
||||||
|
raise ValueError("IMDB ID must start with 'tt'")
|
||||||
|
|
||||||
|
if not v[2:].isdigit():
|
||||||
|
raise ValueError("IMDB ID must be 'tt' followed by digits")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
def model_post_init(self, __context) -> None:
|
||||||
|
"""Set default values after initialization."""
|
||||||
|
# Set showtitle to title if not provided
|
||||||
|
if self.showtitle is None:
|
||||||
|
self.showtitle = self.title
|
||||||
|
|
||||||
|
# Set originaltitle to title if not provided
|
||||||
|
if self.originaltitle is None:
|
||||||
|
self.originaltitle = self.title
|
||||||
@@ -422,3 +422,32 @@ class TMDBClient:
|
|||||||
if expired_keys:
|
if expired_keys:
|
||||||
logger.debug("Removed %d expired negative cache entries", len(expired_keys))
|
logger.debug("Removed %d expired negative cache entries", len(expired_keys))
|
||||||
return len(expired_keys)
|
return len(expired_keys)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tmdb_client() -> TMDBClient:
|
||||||
|
"""Factory function to create a TMDBClient with settings configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TMDBClient instance configured with settings.tmdb_api_key
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If TMDB API key is not configured
|
||||||
|
"""
|
||||||
|
from src.config.settings import settings
|
||||||
|
|
||||||
|
if not settings.tmdb_api_key:
|
||||||
|
raise ValueError("TMDB API key is not configured")
|
||||||
|
|
||||||
|
return TMDBClient(api_key=settings.tmdb_api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tmdb_image_base_url(tmdb_id: int) -> str:
|
||||||
|
"""Get the base URL for TMDB images.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tmdb_id: TMDB show ID (used for account-specific URLs)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base URL string for TMDB images
|
||||||
|
"""
|
||||||
|
return "https://image.tmdb.org/t/p/"
|
||||||
0
src/server/providers/__init__.py
Normal file
0
src/server/providers/__init__.py
Normal file
@@ -158,6 +158,7 @@ class AniworldLoader(Loader):
|
|||||||
|
|
||||||
self._KeyHTMLDict = {}
|
self._KeyHTMLDict = {}
|
||||||
self._EpisodeHTMLDict = {}
|
self._EpisodeHTMLDict = {}
|
||||||
|
self._YearDict = {}
|
||||||
self.Providers = Providers()
|
self.Providers = Providers()
|
||||||
|
|
||||||
# Events: download_progress is triggered with progress dict
|
# Events: download_progress is triggered with progress dict
|
||||||
@@ -542,6 +543,7 @@ class AniworldLoader(Loader):
|
|||||||
self.events.download_progress(d)
|
self.events.download_progress(d)
|
||||||
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
|
'downloader': 'ffmpeg', # Use ffmpeg for proper progress reporting
|
||||||
'fragment_retries': float('inf'),
|
'fragment_retries': float('inf'),
|
||||||
'outtmpl': temp_path,
|
'outtmpl': temp_path,
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
@@ -774,55 +776,81 @@ class AniworldLoader(Loader):
|
|||||||
if span_tag:
|
if span_tag:
|
||||||
title = span_tag.text
|
title = span_tag.text
|
||||||
logger.debug("Found title: %s", title)
|
logger.debug("Found title: %s", title)
|
||||||
|
|
||||||
|
# Also try to extract year from sibling p tag "Jahr: {year}"
|
||||||
|
# Year is typically right after title in the HTML structure
|
||||||
|
year = self._extract_year_from_soup(soup)
|
||||||
|
if year is not None:
|
||||||
|
self._YearDict[key] = year
|
||||||
|
logger.debug("Cached year %d for key: %s", year, key)
|
||||||
|
|
||||||
return title
|
return title
|
||||||
|
|
||||||
logger.warning("No title found for key: %s", key)
|
logger.warning("No title found for key: %s", key)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def _extract_year_from_soup(self, soup: BeautifulSoup) -> int | None:
|
||||||
|
"""Extract year from BeautifulSoup object.
|
||||||
|
|
||||||
|
Looks for 'Jahr: {year}' pattern in p tags adjacent to series-title.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
soup: Parsed BeautifulSoup object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Year as int or None if not found
|
||||||
|
"""
|
||||||
|
# Try to find year in metadata
|
||||||
|
for p_tag in soup.find_all('p'):
|
||||||
|
text = p_tag.get_text()
|
||||||
|
if 'Jahr:' in text or 'Year:' in text:
|
||||||
|
match = re.search(r'(\d{4})', text)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
|
||||||
|
# Fallback: look in series-info div
|
||||||
|
info_div = soup.find('div', class_='series-info')
|
||||||
|
if info_div:
|
||||||
|
text = info_div.get_text()
|
||||||
|
match = re.search(r'\b(19\d{2}|20\d{2})\b', text)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def get_year(self, key: str) -> int | None:
|
def get_year(self, key: str) -> int | None:
|
||||||
"""Get anime release year from series key.
|
"""Get anime release year from series key.
|
||||||
|
|
||||||
Attempts to extract the year from the series page metadata.
|
Uses cached year from get_title if available,
|
||||||
Returns None if year cannot be determined.
|
otherwise extracts and caches it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: Series identifier
|
key: Series identifier
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int or None: Release year if found, None otherwise
|
Release year or None if not found
|
||||||
"""
|
"""
|
||||||
logger.debug("Getting year for key: %s", key)
|
logger.debug("Getting year for key: %s", key)
|
||||||
|
|
||||||
|
# Check cache first
|
||||||
|
if key in self._YearDict:
|
||||||
|
logger.debug("Using cached year %d for key: %s", self._YearDict[key], key)
|
||||||
|
return self._YearDict[key]
|
||||||
|
|
||||||
|
# Not cached - extract from HTML
|
||||||
try:
|
try:
|
||||||
soup = BeautifulSoup(
|
soup = BeautifulSoup(
|
||||||
_decode_html_content(self._get_key_html(key).content),
|
_decode_html_content(self._get_key_html(key).content),
|
||||||
'html.parser'
|
'html.parser'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try to find year in metadata
|
year = self._extract_year_from_soup(soup)
|
||||||
# Check for "Jahr:" or similar metadata fields
|
if year is not None:
|
||||||
for p_tag in soup.find_all('p'):
|
self._YearDict[key] = year
|
||||||
text = p_tag.get_text()
|
logger.debug("Found and cached year %d for key: %s", year, key)
|
||||||
if 'Jahr:' in text or 'Year:' in text:
|
|
||||||
# Extract year from text like "Jahr: 2025"
|
return year
|
||||||
match = re.search(r'(\d{4})', text)
|
|
||||||
if match:
|
|
||||||
year = int(match.group(1))
|
|
||||||
logger.debug("Found year in metadata: %s", year)
|
|
||||||
return year
|
|
||||||
|
|
||||||
# Try alternative: look for year in genre/info section
|
|
||||||
info_div = soup.find('div', class_='series-info')
|
|
||||||
if info_div:
|
|
||||||
text = info_div.get_text()
|
|
||||||
match = re.search(r'\b(19\d{2}|20\d{2})\b', text)
|
|
||||||
if match:
|
|
||||||
year = int(match.group(1))
|
|
||||||
logger.debug("Found year in info section: %s", year)
|
|
||||||
return year
|
|
||||||
|
|
||||||
logger.debug("No year found for key: %s", key)
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error extracting year for key %s: %s", key, e)
|
logger.warning("Error extracting year for key %s: %s", key, e)
|
||||||
return None
|
return None
|
||||||
@@ -91,6 +91,17 @@ class Loader(ABC):
|
|||||||
Series title string
|
Series title string
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_year(self, key: str) -> int | None:
|
||||||
|
"""Get the release year of a series.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Unique series identifier/key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Release year as integer, or None if year cannot be determined
|
||||||
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_season_episode_count(self, slug: str) -> Dict[int, int]:
|
def get_season_episode_count(self, slug: str) -> Dict[int, int]:
|
||||||
"""Get season and episode counts for a series.
|
"""Get season and episode counts for a series.
|
||||||
@@ -110,6 +110,7 @@ class EnhancedAniWorldLoader(Loader):
|
|||||||
# Cache dictionaries
|
# Cache dictionaries
|
||||||
self._KeyHTMLDict = {}
|
self._KeyHTMLDict = {}
|
||||||
self._EpisodeHTMLDict = {}
|
self._EpisodeHTMLDict = {}
|
||||||
|
self._YearDict = {}
|
||||||
|
|
||||||
# Provider manager
|
# Provider manager
|
||||||
self.Providers = Providers()
|
self.Providers = Providers()
|
||||||
@@ -666,6 +667,10 @@ class EnhancedAniWorldLoader(Loader):
|
|||||||
if title_span:
|
if title_span:
|
||||||
span = title_span.find('span')
|
span = title_span.find('span')
|
||||||
if span:
|
if span:
|
||||||
|
# Extract and cache year from soup if available
|
||||||
|
year = self._ExtractYearFromSoup(soup)
|
||||||
|
if year is not None:
|
||||||
|
self._YearDict[key] = year
|
||||||
return span.text.strip()
|
return span.text.strip()
|
||||||
|
|
||||||
self.logger.warning("Could not extract title for key: %s", key)
|
self.logger.warning("Could not extract title for key: %s", key)
|
||||||
@@ -674,7 +679,62 @@ class EnhancedAniWorldLoader(Loader):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error("Failed to get title for key %s: %s", key, e)
|
self.logger.error("Failed to get title for key %s: %s", key, e)
|
||||||
raise RetryableError(f"Title extraction failed: {e}") from e
|
raise RetryableError(f"Title extraction failed: {e}") from e
|
||||||
|
|
||||||
|
def _ExtractYearFromSoup(self, soup: BeautifulSoup) -> int | None:
|
||||||
|
"""Extract year from parsed BeautifulSoup.
|
||||||
|
|
||||||
|
Looks for 'Jahr: {year}' pattern in p tags.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
soup: Parsed BeautifulSoup object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Year as int or None if not found
|
||||||
|
"""
|
||||||
|
for p_tag in soup.find_all('p'):
|
||||||
|
text = p_tag.get_text()
|
||||||
|
if 'Jahr:' in text or 'Year:' in text:
|
||||||
|
match = re.search(r'(\d{4})', text)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
|
||||||
|
info_div = soup.find('div', class_='series-info')
|
||||||
|
if info_div:
|
||||||
|
text = info_div.get_text()
|
||||||
|
match = re.search(r'\b(19\d{2}|20\d{2})\b', text)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def GetYear(self, key: str) -> int | None:
|
||||||
|
"""Get anime release year from series key.
|
||||||
|
|
||||||
|
Uses cached year from GetTitle if available,
|
||||||
|
otherwise extracts and caches it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Series identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Release year or None if not found
|
||||||
|
"""
|
||||||
|
# Check cache first
|
||||||
|
if key in self._YearDict:
|
||||||
|
return self._YearDict[key]
|
||||||
|
|
||||||
|
# Not cached - extract from HTML
|
||||||
|
try:
|
||||||
|
soup = BeautifulSoup(self._GetKeyHTML(key).content, 'html.parser')
|
||||||
|
year = self._ExtractYearFromSoup(soup)
|
||||||
|
if year is not None:
|
||||||
|
self._YearDict[key] = year
|
||||||
|
return year
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning("Error extracting year for key %s: %s", key, e)
|
||||||
|
return None
|
||||||
|
|
||||||
def GetSiteKey(self) -> str:
|
def GetSiteKey(self) -> str:
|
||||||
"""Get site identifier."""
|
"""Get site identifier."""
|
||||||
return "aniworld.to"
|
return "aniworld.to"
|
||||||
@@ -8,8 +8,8 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
||||||
|
|
||||||
from src.core.providers.health_monitor import get_health_monitor
|
from src.server.providers.health_monitor import get_health_monitor
|
||||||
from src.core.providers.provider_config import DEFAULT_PROVIDERS
|
from src.server.providers.provider_config import DEFAULT_PROVIDERS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -7,8 +7,8 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
from src.core.providers.base_provider import Loader
|
from src.server.providers.base_provider import Loader
|
||||||
from src.core.providers.health_monitor import get_health_monitor
|
from src.server.providers.health_monitor import get_health_monitor
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import structlog
|
from src.server.SeriesApp import SeriesApp
|
||||||
|
|
||||||
from src.core.SeriesApp import SeriesApp
|
|
||||||
from src.server.services.progress_service import (
|
from src.server.services.progress_service import (
|
||||||
ProgressService,
|
ProgressService,
|
||||||
ProgressType,
|
ProgressType,
|
||||||
@@ -19,7 +18,7 @@ from src.server.services.websocket_service import (
|
|||||||
get_websocket_service,
|
get_websocket_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AnimeServiceError(Exception):
|
class AnimeServiceError(Exception):
|
||||||
@@ -61,16 +60,28 @@ class AnimeService:
|
|||||||
self._scan_lock = asyncio.Lock()
|
self._scan_lock = asyncio.Lock()
|
||||||
# Subscribe to SeriesApp events
|
# Subscribe to SeriesApp events
|
||||||
# Note: Events library uses assignment (=), not += operator
|
# Note: Events library uses assignment (=), not += operator
|
||||||
|
import logging
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
try:
|
try:
|
||||||
self._app.download_status = self._on_download_status
|
self._app.download_status = self._on_download_status
|
||||||
self._app.scan_status = self._on_scan_status
|
self._app.scan_status = self._on_scan_status
|
||||||
logger.info(
|
_logger.info(
|
||||||
"Subscribed to SeriesApp events",
|
"Subscribed to SeriesApp events: scan_status=%s series_app_id=%s",
|
||||||
scan_status_handler=str(self._app.scan_status),
|
str(self._app.scan_status),
|
||||||
series_app_id=id(self._app),
|
id(self._app),
|
||||||
|
)
|
||||||
|
except (BrokenPipeError, OSError) as e:
|
||||||
|
# Handle "broken pipe" when structlog tries to write to closed stdout
|
||||||
|
# This can happen when server runs in background with stdout redirected
|
||||||
|
import sys
|
||||||
|
print(
|
||||||
|
f"WARNING: Failed to subscribe to SeriesApp events: {e}. "
|
||||||
|
f"Download/scan status callbacks may not work.",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to subscribe to SeriesApp events")
|
_logger.error("Failed to subscribe to SeriesApp events: %s", e)
|
||||||
raise AnimeServiceError("Initialization failed") from e
|
raise AnimeServiceError("Initialization failed") from e
|
||||||
|
|
||||||
|
|
||||||
@@ -95,8 +106,8 @@ class AnimeService:
|
|||||||
|
|
||||||
if not loop:
|
if not loop:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"No event loop available for download status event",
|
"No event loop available for download status event status=%s",
|
||||||
status=args.status
|
args.status
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -166,8 +177,8 @@ class AnimeService:
|
|||||||
)
|
)
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
logger.error(
|
logger.error(
|
||||||
"Error handling download status event",
|
"Error handling download status event error=%s",
|
||||||
error=str(exc)
|
str(exc)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_scan_status(self, args) -> None:
|
def _on_scan_status(self, args) -> None:
|
||||||
@@ -181,41 +192,40 @@ class AnimeService:
|
|||||||
args: ScanStatusEventArgs from SeriesApp containing key,
|
args: ScanStatusEventArgs from SeriesApp containing key,
|
||||||
folder, current, total, status, and progress info
|
folder, current, total, status, and progress info
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
_event_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
scan_id = "library_scan"
|
scan_id = "library_scan"
|
||||||
|
|
||||||
logger.info(
|
_event_logger.info(
|
||||||
"Scan status event received",
|
"Scan status event received status=%s current=%s total=%s folder=%s",
|
||||||
status=args.status,
|
args.status, args.current, args.total, args.folder,
|
||||||
current=args.current,
|
|
||||||
total=args.total,
|
|
||||||
folder=args.folder,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get event loop - try running loop first, then stored loop
|
# Get event loop - try running loop first, then stored loop
|
||||||
loop = None
|
loop = None
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
logger.debug("Using running event loop for scan status")
|
_event_logger.debug("Using running event loop for scan status")
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# No running loop in this thread - use stored loop
|
# No running loop in this thread - use stored loop
|
||||||
loop = self._event_loop
|
loop = self._event_loop
|
||||||
logger.debug(
|
_event_logger.debug(
|
||||||
"Using stored event loop for scan status",
|
"Using stored event loop for scan status has_loop=%s",
|
||||||
has_loop=loop is not None
|
loop is not None
|
||||||
)
|
)
|
||||||
|
|
||||||
if not loop:
|
if not loop:
|
||||||
logger.warning(
|
_event_logger.warning(
|
||||||
"No event loop available for scan status event",
|
"No event loop available for scan status event status=%s",
|
||||||
status=args.status
|
args.status
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(
|
_event_logger.info(
|
||||||
"Processing scan status event",
|
"Processing scan status event status=%s loop_id=%s",
|
||||||
status=args.status,
|
args.status, id(loop),
|
||||||
loop_id=id(loop),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Map SeriesApp scan events to progress service
|
# Map SeriesApp scan events to progress service
|
||||||
@@ -439,8 +449,8 @@ class AnimeService:
|
|||||||
else:
|
else:
|
||||||
result.append(s) # type: ignore
|
result.append(s) # type: ignore
|
||||||
return result
|
return result
|
||||||
except Exception:
|
except Exception as e:
|
||||||
logger.exception("Failed to get missing episodes list")
|
_logger.error("Failed to get missing episodes list: %s", str(e))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def list_missing(self) -> list[dict]:
|
async def list_missing(self) -> list[dict]:
|
||||||
@@ -459,7 +469,7 @@ class AnimeService:
|
|||||||
except AnimeServiceError:
|
except AnimeServiceError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("list_missing failed")
|
_logger.error("list_missing failed: %s", str(exc))
|
||||||
raise AnimeServiceError("Failed to list missing series") from exc
|
raise AnimeServiceError("Failed to list missing series") from exc
|
||||||
|
|
||||||
async def list_series_with_filters(
|
async def list_series_with_filters(
|
||||||
@@ -604,16 +614,15 @@ class AnimeService:
|
|||||||
result_list.append(series_dict)
|
result_list.append(series_dict)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Listed series with filters",
|
"Listed series with filters total=%d filter_type=%s",
|
||||||
total_count=len(result_list),
|
len(result_list), filter_type
|
||||||
filter_type=filter_type
|
|
||||||
)
|
)
|
||||||
return result_list
|
return result_list
|
||||||
|
|
||||||
except AnimeServiceError:
|
except AnimeServiceError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("list_series_with_filters failed")
|
logger.error("list_series_with_filters failed: %s", str(exc))
|
||||||
raise AnimeServiceError(
|
raise AnimeServiceError(
|
||||||
"Failed to list series with metadata"
|
"Failed to list series with metadata"
|
||||||
) from exc
|
) from exc
|
||||||
@@ -635,7 +644,7 @@ class AnimeService:
|
|||||||
result = await self._app.search(query)
|
result = await self._app.search(query)
|
||||||
return result
|
return result
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("search failed")
|
logger.error("search failed: %s", str(exc))
|
||||||
raise AnimeServiceError("Search failed") from exc
|
raise AnimeServiceError("Search failed") from exc
|
||||||
|
|
||||||
async def rescan(self) -> None:
|
async def rescan(self) -> None:
|
||||||
@@ -655,30 +664,36 @@ class AnimeService:
|
|||||||
progress, this method returns immediately without starting
|
progress, this method returns immediately without starting
|
||||||
a new scan.
|
a new scan.
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
_rescan_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Check if a scan is already running (non-blocking)
|
# Check if a scan is already running (non-blocking)
|
||||||
if self._scan_lock.locked():
|
if self._scan_lock.locked():
|
||||||
logger.info("Rescan already in progress, ignoring request")
|
_rescan_logger.info("Rescan already in progress, ignoring request")
|
||||||
return
|
return
|
||||||
|
|
||||||
async with self._scan_lock:
|
async with self._scan_lock:
|
||||||
try:
|
try:
|
||||||
# Store event loop for event handlers
|
# Store event loop for event handlers
|
||||||
self._event_loop = asyncio.get_running_loop()
|
self._event_loop = asyncio.get_running_loop()
|
||||||
logger.info(
|
_rescan_logger.info(
|
||||||
"Rescan started, event loop stored",
|
"Rescan started, event loop stored. loop_id=%d series_app_id=%d",
|
||||||
loop_id=id(self._event_loop),
|
id(self._event_loop),
|
||||||
series_app_id=id(self._app),
|
id(self._app),
|
||||||
scan_handler=str(self._app.scan_status),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# SeriesApp.rescan returns scanned series list
|
# SeriesApp.rescan returns scanned series list
|
||||||
|
_rescan_logger.info("Calling _app.rescan()")
|
||||||
scanned_series = await self._app.rescan()
|
scanned_series = await self._app.rescan()
|
||||||
|
_rescan_logger.info("Rescan completed, found %d series", len(scanned_series) if scanned_series else 0)
|
||||||
|
|
||||||
# Persist scan results to database
|
# Persist scan results to database
|
||||||
if scanned_series:
|
if scanned_series:
|
||||||
|
_rescan_logger.info("Saving %d series to database", len(scanned_series))
|
||||||
await self._save_scan_results_to_db(scanned_series)
|
await self._save_scan_results_to_db(scanned_series)
|
||||||
|
|
||||||
# Reload series from database to ensure consistency
|
# Reload series from database to ensure consistency
|
||||||
|
_rescan_logger.info("Loading series from database")
|
||||||
await self._load_series_from_db()
|
await self._load_series_from_db()
|
||||||
|
|
||||||
# invalidate cache
|
# invalidate cache
|
||||||
@@ -687,8 +702,11 @@ class AnimeService:
|
|||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
except AnimeServiceError:
|
||||||
|
# Re-raise AnimeServiceError without wrapping
|
||||||
|
raise
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
logger.exception("rescan failed")
|
_rescan_logger.error("Rescan failed: %s", str(exc))
|
||||||
raise AnimeServiceError("Rescan failed") from exc
|
raise AnimeServiceError("Rescan failed") from exc
|
||||||
|
|
||||||
async def sync_single_series_after_scan(self, series_key: str) -> None:
|
async def sync_single_series_after_scan(self, series_key: str) -> None:
|
||||||
@@ -942,47 +960,16 @@ class AnimeService:
|
|||||||
in-memory episodeDict, so downloaded episodes are not shown
|
in-memory episodeDict, so downloaded episodes are not shown
|
||||||
as missing.
|
as missing.
|
||||||
"""
|
"""
|
||||||
from src.core.entities.series import Serie
|
|
||||||
from src.server.database.connection import get_db_session
|
from src.server.database.connection import get_db_session
|
||||||
from src.server.database.service import AnimeSeriesService
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
async with get_db_session() as db:
|
async with get_db_session() as db:
|
||||||
anime_series_list = await AnimeSeriesService.get_all(
|
anime_series_list = await AnimeSeriesService.get_all(
|
||||||
db, with_episodes=True
|
db, with_episodes=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convert to Serie objects
|
# Load AnimeSeries objects directly into SeriesApp
|
||||||
series_list = []
|
self._app.load_series_from_list(anime_series_list)
|
||||||
for anime_series in anime_series_list:
|
|
||||||
# Build episode_dict from episodes relationship
|
|
||||||
# Only include episodes that are NOT downloaded (is_downloaded=False)
|
|
||||||
# so the missing-episode list stays accurate
|
|
||||||
episode_dict: dict[int, list[int]] = {}
|
|
||||||
if anime_series.episodes:
|
|
||||||
for episode in anime_series.episodes:
|
|
||||||
# Skip downloaded episodes — they are not missing
|
|
||||||
if episode.is_downloaded:
|
|
||||||
continue
|
|
||||||
season = episode.season
|
|
||||||
if season not in episode_dict:
|
|
||||||
episode_dict[season] = []
|
|
||||||
episode_dict[season].append(episode.episode_number)
|
|
||||||
# Sort episode numbers
|
|
||||||
for season in episode_dict:
|
|
||||||
episode_dict[season].sort()
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key=anime_series.key,
|
|
||||||
name=anime_series.name,
|
|
||||||
site=anime_series.site,
|
|
||||||
folder=anime_series.folder,
|
|
||||||
episodeDict=episode_dict,
|
|
||||||
year=anime_series.year
|
|
||||||
)
|
|
||||||
series_list.append(serie)
|
|
||||||
|
|
||||||
# Load into SeriesApp
|
|
||||||
self._app.load_series_from_list(series_list)
|
|
||||||
|
|
||||||
async def sync_episodes_to_db(self, series_key: str) -> int:
|
async def sync_episodes_to_db(self, series_key: str) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -1178,17 +1165,17 @@ class AnimeService:
|
|||||||
|
|
||||||
async def add_series_to_db(
|
async def add_series_to_db(
|
||||||
self,
|
self,
|
||||||
serie,
|
anime,
|
||||||
db
|
db
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Add a series to the database if it doesn't already exist.
|
Add a series to the database if it doesn't already exist.
|
||||||
|
|
||||||
Uses serie.key for identification. Creates a new AnimeSeries
|
Uses anime.key for identification. Creates a new AnimeSeries
|
||||||
record in the database if it doesn't already exist.
|
record in the database if it doesn't already exist.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
serie: The Serie instance to add
|
anime: The AnimeSeries instance to add
|
||||||
db: Database session for async operations
|
db: Database session for async operations
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -1197,45 +1184,139 @@ class AnimeService:
|
|||||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||||
|
|
||||||
# Check if series already exists in DB
|
# Check if series already exists in DB
|
||||||
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
existing = await AnimeSeriesService.get_by_key(db, anime.key)
|
||||||
if existing:
|
if existing:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Series already exists in database: %s (key=%s)",
|
"Series already exists in database: %s (key=%s)",
|
||||||
serie.name,
|
anime.name,
|
||||||
serie.key
|
anime.key
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Create new series in database
|
# Create new series in database
|
||||||
anime_series = await AnimeSeriesService.create(
|
anime_series = await AnimeSeriesService.create(
|
||||||
db=db,
|
db=db,
|
||||||
key=serie.key,
|
key=anime.key,
|
||||||
name=serie.name,
|
name=anime.name,
|
||||||
site=serie.site,
|
site=anime.site,
|
||||||
folder=serie.folder,
|
folder=anime.folder,
|
||||||
year=serie.year if hasattr(serie, 'year') else None,
|
year=anime.year if hasattr(anime, 'year') else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create Episode records for each episode in episodeDict
|
# Create Episode records for each episode in episodes relationship
|
||||||
if serie.episodeDict:
|
if anime.episodes:
|
||||||
for season, episode_numbers in serie.episodeDict.items():
|
for episode in anime.episodes:
|
||||||
for episode_number in episode_numbers:
|
await EpisodeService.create(
|
||||||
await EpisodeService.create(
|
db=db,
|
||||||
db=db,
|
series_id=anime_series.id,
|
||||||
series_id=anime_series.id,
|
season=episode.season,
|
||||||
season=season,
|
episode_number=episode.episode_number,
|
||||||
episode_number=episode_number,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Added series to database: %s (key=%s, year=%s)",
|
"Added series to database: %s (key=%s, year=%s)",
|
||||||
serie.name,
|
anime.name,
|
||||||
serie.key,
|
anime.key,
|
||||||
serie.year if hasattr(serie, 'year') else None
|
anime.year if hasattr(anime, 'year') else None
|
||||||
)
|
)
|
||||||
|
|
||||||
return anime_series
|
return anime_series
|
||||||
|
|
||||||
|
async def rename_folder_if_needed(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
current_folder: str,
|
||||||
|
target_folder: str,
|
||||||
|
db: Optional[AsyncSession] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Rename anime folder if current and target folders differ.
|
||||||
|
|
||||||
|
Compares current_folder with target_folder, and if different,
|
||||||
|
renames the folder on disk using shutil.move. Updates the DB
|
||||||
|
record and in-memory cache if rename succeeds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Series unique identifier
|
||||||
|
current_folder: Current folder name (metadata from DB)
|
||||||
|
target_folder: Desired folder name (computed with year)
|
||||||
|
db: Optional database session for updating DB record
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if rename was performed, False if no rename needed or failed
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
if current_folder == target_folder:
|
||||||
|
logger.debug(
|
||||||
|
"Folder rename not needed for %s: same folder name",
|
||||||
|
key
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_path = self._directory / current_folder
|
||||||
|
target_path = self._directory / target_folder
|
||||||
|
|
||||||
|
if not current_path.exists():
|
||||||
|
logger.debug(
|
||||||
|
"Folder rename not needed for %s: current folder does not exist on disk",
|
||||||
|
key
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if target_path.exists():
|
||||||
|
logger.warning(
|
||||||
|
"Cannot rename folder for %s: target path already exists: %s",
|
||||||
|
key,
|
||||||
|
target_path
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Rename folder on disk
|
||||||
|
shutil.move(str(current_path), str(target_path))
|
||||||
|
logger.info(
|
||||||
|
"Renamed folder for %s: %s -> %s",
|
||||||
|
key,
|
||||||
|
current_folder,
|
||||||
|
target_folder
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update in-memory cache
|
||||||
|
if key in self._app.list.keyDict:
|
||||||
|
self._app.list.keyDict[key].folder = target_folder
|
||||||
|
logger.debug(
|
||||||
|
"Updated in-memory cache folder for %s: %s",
|
||||||
|
key,
|
||||||
|
target_folder
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update database if session provided
|
||||||
|
if db is not None:
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
# Look up series by key to get database ID
|
||||||
|
series = await AnimeSeriesService.get_by_key(db, key)
|
||||||
|
if series:
|
||||||
|
await AnimeSeriesService.update(db, series_id=series.id, folder=target_folder)
|
||||||
|
logger.debug(
|
||||||
|
"Updated DB folder for %s: %s",
|
||||||
|
key,
|
||||||
|
target_folder
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to rename folder for %s: %s -> %s: %s",
|
||||||
|
key,
|
||||||
|
current_folder,
|
||||||
|
target_folder,
|
||||||
|
str(e)
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
async def contains_in_db(self, key: str, db) -> bool:
|
async def contains_in_db(self, key: str, db) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a series with the given key exists in the database.
|
Check if a series with the given key exists in the database.
|
||||||
@@ -1303,7 +1384,7 @@ class AnimeService:
|
|||||||
logger.info("Download cancelled, propagating cancellation")
|
logger.info("Download cancelled, propagating cancellation")
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("download failed")
|
logger.error("download failed: %s", str(exc))
|
||||||
raise AnimeServiceError("Download failed") from exc
|
raise AnimeServiceError("Download failed") from exc
|
||||||
|
|
||||||
async def update_nfo_status(
|
async def update_nfo_status(
|
||||||
@@ -1404,10 +1485,9 @@ class AnimeService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(
|
logger.error(
|
||||||
"Failed to update NFO status",
|
"Failed to update NFO status key=%s has_nfo=%s: %s",
|
||||||
key=key,
|
key, has_nfo, str(exc)
|
||||||
has_nfo=has_nfo
|
|
||||||
)
|
)
|
||||||
raise AnimeServiceError("NFO status update failed") from exc
|
raise AnimeServiceError("NFO status update failed") from exc
|
||||||
|
|
||||||
@@ -1483,7 +1563,7 @@ class AnimeService:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Failed to query series without NFO")
|
logger.error("Failed to query series without NFO: %s", str(exc))
|
||||||
raise AnimeServiceError(
|
raise AnimeServiceError(
|
||||||
"Query for series without NFO failed"
|
"Query for series without NFO failed"
|
||||||
) from exc
|
) from exc
|
||||||
@@ -1528,7 +1608,8 @@ class AnimeService:
|
|||||||
"with_tvdb_id": with_tvdb
|
"with_tvdb_id": with_tvdb
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Retrieved NFO statistics", **stats)
|
logger.info("Retrieved NFO statistics total=%d with_nfo=%d without_nfo=%d with_tmdb_id=%d with_tvdb_id=%d",
|
||||||
|
total, with_nfo, total - with_nfo, with_tmdb, with_tvdb)
|
||||||
return stats
|
return stats
|
||||||
else:
|
else:
|
||||||
# Use provided session and service layer count methods
|
# Use provided session and service layer count methods
|
||||||
@@ -1545,150 +1626,15 @@ class AnimeService:
|
|||||||
"with_tvdb_id": with_tvdb
|
"with_tvdb_id": with_tvdb
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Retrieved NFO statistics", **stats)
|
logger.info("Retrieved NFO statistics total=%d with_nfo=%d without_nfo=%d with_tmdb_id=%d with_tvdb_id=%d",
|
||||||
|
total, with_nfo, total - with_nfo, with_tmdb, with_tvdb)
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Failed to get NFO statistics")
|
logger.error("Failed to get NFO statistics: %s", str(exc))
|
||||||
raise AnimeServiceError("NFO statistics query failed") from exc
|
raise AnimeServiceError("NFO statistics query failed") from exc
|
||||||
|
|
||||||
|
|
||||||
def get_anime_service(series_app: SeriesApp) -> AnimeService:
|
def get_anime_service(series_app: SeriesApp) -> AnimeService:
|
||||||
"""Factory used for creating AnimeService with a SeriesApp instance."""
|
"""Factory used for creating AnimeService with a SeriesApp instance."""
|
||||||
return AnimeService(series_app)
|
return AnimeService(series_app)
|
||||||
|
|
||||||
|
|
||||||
async def sync_legacy_series_to_db(
|
|
||||||
anime_directory: str,
|
|
||||||
log_instance=None # pylint: disable=unused-argument
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
One-time legacy sync: import any series from 'data' files
|
|
||||||
not already in the database.
|
|
||||||
|
|
||||||
Deprecated: Series are now loaded directly from the database.
|
|
||||||
This function remains for backwards compatibility with legacy
|
|
||||||
file-based data during migration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
anime_directory: Path to the anime directory with data files
|
|
||||||
log_instance: Optional logger instance (unused, kept for API
|
|
||||||
compatibility). This function always uses structlog internally.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of new series added to the database
|
|
||||||
"""
|
|
||||||
# Always use structlog for structured logging with keyword arguments
|
|
||||||
log = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
log.warning(
|
|
||||||
"sync_legacy_series_to_db is deprecated. "
|
|
||||||
"Series are now loaded directly from database."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from src.server.database.connection import get_db_session
|
|
||||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"Starting data file to database sync",
|
|
||||||
directory=anime_directory
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get all series from data files using SeriesApp
|
|
||||||
series_app = SeriesApp(anime_directory)
|
|
||||||
all_series = await asyncio.to_thread(
|
|
||||||
series_app.get_all_series_from_data_files
|
|
||||||
)
|
|
||||||
|
|
||||||
if not all_series:
|
|
||||||
log.info("No series found in data files to sync")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"Found series in data files, syncing to database",
|
|
||||||
count=len(all_series)
|
|
||||||
)
|
|
||||||
|
|
||||||
async with get_db_session() as db:
|
|
||||||
added_count = 0
|
|
||||||
skipped_count = 0
|
|
||||||
for serie in all_series:
|
|
||||||
# Handle series with empty name - use folder as fallback
|
|
||||||
if not serie.name or not serie.name.strip():
|
|
||||||
if serie.folder and serie.folder.strip():
|
|
||||||
serie.name = serie.folder.strip()
|
|
||||||
log.debug(
|
|
||||||
"Using folder as name fallback",
|
|
||||||
key=serie.key,
|
|
||||||
folder=serie.folder
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
log.warning(
|
|
||||||
"Skipping series with empty name and folder",
|
|
||||||
key=serie.key
|
|
||||||
)
|
|
||||||
skipped_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check if series already exists in DB
|
|
||||||
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
|
||||||
if existing:
|
|
||||||
log.debug(
|
|
||||||
"Series already exists in database",
|
|
||||||
name=serie.name,
|
|
||||||
key=serie.key
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Create new series in database
|
|
||||||
anime_series = await AnimeSeriesService.create(
|
|
||||||
db=db,
|
|
||||||
key=serie.key,
|
|
||||||
name=serie.name,
|
|
||||||
site=serie.site,
|
|
||||||
folder=serie.folder,
|
|
||||||
year=serie.year if hasattr(serie, 'year') else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create Episode records for each episode in episodeDict
|
|
||||||
if serie.episodeDict:
|
|
||||||
for season, episode_numbers in serie.episodeDict.items():
|
|
||||||
for episode_number in episode_numbers:
|
|
||||||
await EpisodeService.create(
|
|
||||||
db=db,
|
|
||||||
series_id=anime_series.id,
|
|
||||||
season=season,
|
|
||||||
episode_number=episode_number,
|
|
||||||
)
|
|
||||||
|
|
||||||
added_count += 1
|
|
||||||
log.debug(
|
|
||||||
"Added series to database",
|
|
||||||
name=serie.name,
|
|
||||||
key=serie.key
|
|
||||||
)
|
|
||||||
except Exception as e: # pylint: disable=broad-except
|
|
||||||
log.warning(
|
|
||||||
"Failed to add series to database",
|
|
||||||
key=serie.key,
|
|
||||||
name=serie.name,
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
skipped_count += 1
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"Data file sync complete",
|
|
||||||
added=added_count,
|
|
||||||
skipped=len(all_series) - added_count
|
|
||||||
)
|
|
||||||
return added_count
|
|
||||||
|
|
||||||
except Exception as e: # pylint: disable=broad-except
|
|
||||||
log.warning(
|
|
||||||
"Failed to sync series to database",
|
|
||||||
error=str(e),
|
|
||||||
exc_info=True
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ can call it from async routes via threadpool if needed.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import os
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
@@ -88,6 +89,8 @@ class AuthService:
|
|||||||
self.lockout_seconds = 300 # 5 minutes
|
self.lockout_seconds = 300 # 5 minutes
|
||||||
self.token_expiry_hours = settings.token_expiry_hours or 24
|
self.token_expiry_hours = settings.token_expiry_hours or 24
|
||||||
self.secret = settings.jwt_secret_key
|
self.secret = settings.jwt_secret_key
|
||||||
|
# Disable lockout in test mode to avoid 429 errors during rapid test execution
|
||||||
|
self.disable_lockout = os.getenv("ANIWORLD_TESTING") == "1"
|
||||||
|
|
||||||
# --- password helpers ---
|
# --- password helpers ---
|
||||||
def _hash_password(self, password: str) -> str:
|
def _hash_password(self, password: str) -> str:
|
||||||
@@ -109,7 +112,22 @@ class AuthService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
return bool(self._hash)
|
# Always re-read from config to detect if reset happened
|
||||||
|
hash_val = None
|
||||||
|
try:
|
||||||
|
from src.server.services.config_service import get_config_service
|
||||||
|
config_service = get_config_service()
|
||||||
|
config = config_service.load_config()
|
||||||
|
hash_val = config.other.get('master_password_hash')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if isinstance(hash_val, str):
|
||||||
|
self._hash = hash_val
|
||||||
|
return True
|
||||||
|
# No hash in config - clear any stale in-memory hash
|
||||||
|
self._hash = None
|
||||||
|
return False
|
||||||
|
|
||||||
def setup_master_password(self, password: str) -> str:
|
def setup_master_password(self, password: str) -> str:
|
||||||
"""Set the master password (hash and store in memory/settings).
|
"""Set the master password (hash and store in memory/settings).
|
||||||
@@ -173,6 +191,8 @@ class AuthService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _record_failure(self, identifier: str) -> None:
|
def _record_failure(self, identifier: str) -> None:
|
||||||
|
if self.disable_lockout:
|
||||||
|
return
|
||||||
rec = self._get_fail_record(identifier)
|
rec = self._get_fail_record(identifier)
|
||||||
rec["count"] += 1
|
rec["count"] += 1
|
||||||
rec["last"] = datetime.now(timezone.utc)
|
rec["last"] = datetime.now(timezone.utc)
|
||||||
@@ -186,19 +206,14 @@ class AuthService:
|
|||||||
self._failed.pop(identifier, None)
|
self._failed.pop(identifier, None)
|
||||||
|
|
||||||
def _check_locked(self, identifier: str) -> None:
|
def _check_locked(self, identifier: str) -> None:
|
||||||
|
if self.disable_lockout:
|
||||||
|
return
|
||||||
rec = self._get_fail_record(identifier)
|
rec = self._get_fail_record(identifier)
|
||||||
lu = rec.get("locked_until")
|
lu = rec.get("locked_until")
|
||||||
if lu and datetime.now(timezone.utc) < lu:
|
if lu and datetime.now(timezone.utc) < lu:
|
||||||
raise LockedOutError(
|
raise LockedOutError(
|
||||||
"Too many failed attempts - temporarily locked out"
|
"Too many failed attempts - temporarily locked out"
|
||||||
)
|
)
|
||||||
if lu and datetime.now(timezone.utc) >= lu:
|
|
||||||
# lock expired, reset
|
|
||||||
self._failed[identifier] = {
|
|
||||||
"count": 0,
|
|
||||||
"last": None,
|
|
||||||
"locked_until": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- authentication ---
|
# --- authentication ---
|
||||||
def validate_master_password(
|
def validate_master_password(
|
||||||
@@ -266,6 +281,15 @@ class AuthService:
|
|||||||
# to a revocation list.
|
# to a revocation list.
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset authentication state to unconfigured.
|
||||||
|
|
||||||
|
Clears the in-memory hash. Does NOT persist - caller should also
|
||||||
|
clear the config file if persistent reset is needed.
|
||||||
|
"""
|
||||||
|
self._hash = None
|
||||||
|
self._failed.clear()
|
||||||
|
|
||||||
|
|
||||||
# Singleton service instance for import convenience
|
# Singleton service instance for import convenience
|
||||||
auth_service = AuthService()
|
auth_service = AuthService()
|
||||||
|
|||||||
@@ -14,18 +14,16 @@ Key Features:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import structlog
|
|
||||||
|
|
||||||
from src.core.services.nfo_factory import get_nfo_factory
|
|
||||||
from src.server.services.websocket_service import WebSocketService
|
from src.server.services.websocket_service import WebSocketService
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class LoadingStatus(str, Enum):
|
class LoadingStatus(str, Enum):
|
||||||
@@ -122,8 +120,8 @@ class BackgroundLoaderService:
|
|||||||
self._shutdown = False
|
self._shutdown = False
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"BackgroundLoaderService initialized",
|
"BackgroundLoaderService initialized max_concurrent_loads=%s",
|
||||||
extra={"max_concurrent_loads": max_concurrent_loads}
|
max_concurrent_loads
|
||||||
)
|
)
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
@@ -141,8 +139,8 @@ class BackgroundLoaderService:
|
|||||||
self.worker_tasks.append(worker)
|
self.worker_tasks.append(worker)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Background workers started",
|
"Background workers started num_workers=%s",
|
||||||
extra={"num_workers": len(self.worker_tasks)}
|
len(self.worker_tasks)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
@@ -165,8 +163,8 @@ class BackgroundLoaderService:
|
|||||||
for i, result in enumerate(results):
|
for i, result in enumerate(results):
|
||||||
if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError):
|
if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError):
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Worker {i} stopped with exception",
|
"Worker %s stopped with exception exception=%s",
|
||||||
extra={"exception": str(result)}
|
i, str(result)
|
||||||
)
|
)
|
||||||
|
|
||||||
self.worker_tasks = []
|
self.worker_tasks = []
|
||||||
@@ -203,10 +201,15 @@ class BackgroundLoaderService:
|
|||||||
self.active_tasks[key] = task
|
self.active_tasks[key] = task
|
||||||
await self.task_queue.put(task)
|
await self.task_queue.put(task)
|
||||||
|
|
||||||
logger.info("Added loading task for series: %s", key)
|
import logging
|
||||||
|
_task_logger = logging.getLogger(__name__)
|
||||||
|
_task_logger.info("Added loading task for series: %s", key)
|
||||||
|
|
||||||
# Broadcast initial status
|
# Broadcast initial status
|
||||||
await self._broadcast_status(task)
|
try:
|
||||||
|
await self._broadcast_status(task)
|
||||||
|
except Exception as e:
|
||||||
|
_task_logger.warning("Failed to broadcast initial status: %s", e)
|
||||||
|
|
||||||
async def check_missing_data(
|
async def check_missing_data(
|
||||||
self,
|
self,
|
||||||
@@ -289,7 +292,8 @@ class BackgroundLoaderService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Worker {worker_id} processing loading task for series: {task.key}"
|
"Worker %s processing loading task for series: %s",
|
||||||
|
worker_id, task.key
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process the task
|
# Process the task
|
||||||
@@ -305,7 +309,10 @@ class BackgroundLoaderService:
|
|||||||
logger.info("Worker %s task cancelled", worker_id)
|
logger.info("Worker %s task cancelled", worker_id)
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error in background worker %s: %s", worker_id, e)
|
logger.error(
|
||||||
|
"Error in background worker %s: %s",
|
||||||
|
worker_id, str(e)
|
||||||
|
)
|
||||||
# Continue processing other tasks
|
# Continue processing other tasks
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -497,109 +504,53 @@ class BackgroundLoaderService:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
async def _load_nfo_and_images(self, task: SeriesLoadingTask, db: Any) -> bool:
|
async def _load_nfo_and_images(self, task: SeriesLoadingTask, db: Any) -> bool:
|
||||||
"""Load NFO file and images for a series by reusing NFOService.
|
"""Load NFO file and images for a series.
|
||||||
|
|
||||||
|
Downloads poster.jpg, fanart.jpg, and logo.png from TMDB
|
||||||
|
using the ImageLoadingService.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task: The loading task
|
task: The loading task
|
||||||
db: Database session
|
db: Database session
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if NFO was created, False if it already existed or failed
|
bool: True if any images were loaded, False otherwise
|
||||||
"""
|
"""
|
||||||
task.status = LoadingStatus.LOADING_NFO
|
task.status = LoadingStatus.LOADING_NFO
|
||||||
await self._broadcast_status(task, "Checking NFO file...")
|
await self._broadcast_status(task, "Loading images...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if NFOService is available
|
from src.server.nfo.tmdb_client import get_tmdb_client
|
||||||
if not self.series_app.nfo_service:
|
from src.server.services.image_loading_service import (
|
||||||
logger.warning(
|
init_image_loading_service,
|
||||||
f"NFOService not available, skipping NFO/images for {task.key}"
|
)
|
||||||
)
|
|
||||||
task.progress["nfo"] = False
|
|
||||||
task.progress["logo"] = False
|
|
||||||
task.progress["images"] = False
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check if NFO already exists
|
|
||||||
if self.series_app.nfo_service.has_nfo(task.folder):
|
|
||||||
logger.info("NFO already exists for %s, skipping creation", task.key)
|
|
||||||
|
|
||||||
# Update task progress
|
|
||||||
task.progress["nfo"] = True
|
|
||||||
task.progress["logo"] = True # Assume logo exists if NFO exists
|
|
||||||
task.progress["images"] = True # Assume images exist if NFO exists
|
|
||||||
|
|
||||||
# Update database with existing NFO info
|
|
||||||
from src.server.database.service import AnimeSeriesService
|
|
||||||
series_db = await AnimeSeriesService.get_by_key(db, task.key)
|
|
||||||
if series_db:
|
|
||||||
# Only update if not already marked
|
|
||||||
if not series_db.has_nfo:
|
|
||||||
series_db.has_nfo = True
|
|
||||||
series_db.nfo_created_at = datetime.now(timezone.utc)
|
|
||||||
logger.info("Updated database with existing NFO for %s", task.key)
|
|
||||||
if not series_db.logo_loaded:
|
|
||||||
series_db.logo_loaded = True
|
|
||||||
if not series_db.images_loaded:
|
|
||||||
series_db.images_loaded = True
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
logger.info("Existing NFO found and database updated for series: %s", task.key)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# NFO doesn't exist, create it
|
|
||||||
await self._broadcast_status(task, "Generating NFO file...")
|
|
||||||
logger.info("Creating new NFO for %s", task.key)
|
|
||||||
|
|
||||||
# Create a fresh NFOService for this task to avoid shared TMDB session closure
|
|
||||||
try:
|
|
||||||
factory = get_nfo_factory()
|
|
||||||
nfo_service = factory.create()
|
|
||||||
except ValueError:
|
|
||||||
logger.warning(
|
|
||||||
"NFOService unavailable for %s, skipping NFO/images",
|
|
||||||
task.key
|
|
||||||
)
|
|
||||||
task.progress["nfo"] = False
|
|
||||||
task.progress["logo"] = False
|
|
||||||
task.progress["images"] = False
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
tmdb_client = get_tmdb_client()
|
||||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
image_service = init_image_loading_service(tmdb_client)
|
||||||
serie_name=task.name,
|
|
||||||
serie_folder=task.folder,
|
|
||||||
year=task.year,
|
|
||||||
download_poster=True,
|
|
||||||
download_logo=True,
|
|
||||||
download_fanart=True
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await nfo_service.close()
|
|
||||||
|
|
||||||
# Update task progress
|
result = await image_service.load_series_images(
|
||||||
task.progress["nfo"] = True
|
key=task.key,
|
||||||
task.progress["logo"] = True
|
folder=task.folder,
|
||||||
task.progress["images"] = True
|
anime_directory=self.series_app.directory_to_search,
|
||||||
|
db=db,
|
||||||
# Update database
|
)
|
||||||
from src.server.database.service import AnimeSeriesService
|
|
||||||
series_db = await AnimeSeriesService.get_by_key(db, task.key)
|
task.progress["nfo"] = True # NFO was already created earlier in the flow
|
||||||
if series_db:
|
task.progress["logo"] = result.get("logo", False)
|
||||||
series_db.has_nfo = True
|
task.progress["images"] = result.get("poster", False) and result.get("fanart", False)
|
||||||
series_db.nfo_created_at = datetime.now(timezone.utc)
|
|
||||||
series_db.logo_loaded = True
|
logger.info(
|
||||||
series_db.images_loaded = True
|
"Images loaded for series %s: poster=%s fanart=%s logo=%s",
|
||||||
series_db.loading_status = "loading_nfo"
|
task.key,
|
||||||
await db.commit()
|
result.get("poster", False),
|
||||||
|
result.get("fanart", False),
|
||||||
logger.info("NFO and images created and loaded for series: %s", task.key)
|
result.get("logo", False),
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to load NFO/images for %s: %s", task.key, e)
|
logger.warning("Failed to load images for series %s: %s", task.key, e)
|
||||||
# Don't fail the entire task if NFO fails
|
task.progress["nfo"] = True
|
||||||
task.progress["nfo"] = False
|
|
||||||
task.progress["logo"] = False
|
task.progress["logo"] = False
|
||||||
task.progress["images"] = False
|
task.progress["images"] = False
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -532,6 +532,15 @@ class DownloadService:
|
|||||||
"Queue progress already initialized by concurrent task"
|
"Queue progress already initialized by concurrent task"
|
||||||
)
|
)
|
||||||
self._queue_progress_initialized = True
|
self._queue_progress_initialized = True
|
||||||
|
# Handle broken pipe / connection errors from WebSocket broadcast
|
||||||
|
# These are non-fatal and should not fail the queue operation
|
||||||
|
elif isinstance(e, OSError) and e.errno == 32:
|
||||||
|
logger.warning(
|
||||||
|
"Queue progress broadcast failed (broken pipe) - "
|
||||||
|
"continuing without progress tracking: %s",
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
self._queue_progress_initialized = True
|
||||||
else:
|
else:
|
||||||
logger.error("Failed to initialize queue progress: %s", e)
|
logger.error("Failed to initialize queue progress: %s", e)
|
||||||
|
|
||||||
@@ -674,17 +683,10 @@ class DownloadService:
|
|||||||
episode=episode.episode,
|
episode=episode.episode,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Notify via progress service
|
logger.info(
|
||||||
queue_status = await self.get_queue_status()
|
"Added items to queue",
|
||||||
await self._progress_service.update_progress(
|
count=len(created_ids),
|
||||||
progress_id="download_queue",
|
serie_key=serie_id,
|
||||||
message=f"Added {len(created_ids)} items to queue",
|
|
||||||
metadata={
|
|
||||||
"action": "items_added",
|
|
||||||
"added_ids": created_ids,
|
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
force_broadcast=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return created_ids
|
return created_ids
|
||||||
@@ -731,9 +733,7 @@ class DownloadService:
|
|||||||
# Delete from database
|
# Delete from database
|
||||||
await self._delete_from_database(item_id)
|
await self._delete_from_database(item_id)
|
||||||
removed_ids.append(item_id)
|
removed_ids.append(item_id)
|
||||||
logger.info(
|
logger.info("Removed from pending queue item_id=%s", item_id)
|
||||||
"Removed from pending queue", item_id=item_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if removed_ids:
|
if removed_ids:
|
||||||
# Notify via progress service
|
# Notify via progress service
|
||||||
@@ -803,7 +803,7 @@ class DownloadService:
|
|||||||
force_broadcast=True,
|
force_broadcast=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Queue reordered", reordered_count=len(item_ids))
|
logger.info("Queue reordered reordered_count=%s", len(item_ids))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to reorder queue: %s", e)
|
logger.error("Failed to reorder queue: %s", e)
|
||||||
@@ -828,8 +828,8 @@ class DownloadService:
|
|||||||
# Initialize queue progress tracking if not already done
|
# Initialize queue progress tracking if not already done
|
||||||
await self._init_queue_progress()
|
await self._init_queue_progress()
|
||||||
|
|
||||||
# Check if download already active
|
# Check if queue is already running
|
||||||
if self._active_download:
|
if not self._is_stopped:
|
||||||
raise DownloadServiceError(
|
raise DownloadServiceError(
|
||||||
"Queue processing is already active"
|
"Queue processing is already active"
|
||||||
)
|
)
|
||||||
@@ -1036,7 +1036,7 @@ class DownloadService:
|
|||||||
"""
|
"""
|
||||||
count = len(self._completed_items)
|
count = len(self._completed_items)
|
||||||
self._completed_items.clear()
|
self._completed_items.clear()
|
||||||
logger.info("Cleared completed items", count=count)
|
logger.info("Cleared completed items count=%s", count)
|
||||||
|
|
||||||
# Notify via progress service
|
# Notify via progress service
|
||||||
if count > 0:
|
if count > 0:
|
||||||
@@ -1062,7 +1062,7 @@ class DownloadService:
|
|||||||
"""
|
"""
|
||||||
count = len(self._failed_items)
|
count = len(self._failed_items)
|
||||||
self._failed_items.clear()
|
self._failed_items.clear()
|
||||||
logger.info("Cleared failed items", count=count)
|
logger.info("Cleared failed items count=%s", count)
|
||||||
|
|
||||||
# Notify via progress service
|
# Notify via progress service
|
||||||
if count > 0:
|
if count > 0:
|
||||||
@@ -1088,27 +1088,17 @@ class DownloadService:
|
|||||||
"""
|
"""
|
||||||
count = len(self._pending_queue)
|
count = len(self._pending_queue)
|
||||||
|
|
||||||
# Delete all pending items from database
|
# Only try to delete from DB if there are items
|
||||||
for item_id in list(self._pending_items_by_id.keys()):
|
if count > 0:
|
||||||
await self._delete_from_database(item_id)
|
for item_id in list(self._pending_items_by_id.keys()):
|
||||||
|
try:
|
||||||
|
await self._delete_from_database(item_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to delete item %s: %s", item_id, e)
|
||||||
|
|
||||||
self._pending_queue.clear()
|
self._pending_queue.clear()
|
||||||
self._pending_items_by_id.clear()
|
self._pending_items_by_id.clear()
|
||||||
logger.info("Cleared pending items", count=count)
|
self._pending_by_episode.clear()
|
||||||
|
|
||||||
# Notify via progress service
|
|
||||||
if count > 0:
|
|
||||||
queue_status = await self.get_queue_status()
|
|
||||||
await self._progress_service.update_progress(
|
|
||||||
progress_id="download_queue",
|
|
||||||
message=f"Cleared {count} pending items",
|
|
||||||
metadata={
|
|
||||||
"action": "pending_cleared",
|
|
||||||
"cleared_count": count,
|
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
force_broadcast=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|||||||
205
src/server/services/folder_naming_service.py
Normal file
205
src/server/services/folder_naming_service.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
"""Folder naming service for fixing missing years in anime folder names."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from src.config.settings import settings
|
||||||
|
from src.server.database.connection import get_db_session as _get_db_session
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FolderRenameResult:
|
||||||
|
key: str
|
||||||
|
old_folder: str
|
||||||
|
new_folder: Optional[str]
|
||||||
|
success: bool
|
||||||
|
skipped: bool = False
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FolderNamingReport:
|
||||||
|
total: int
|
||||||
|
renamed: int
|
||||||
|
skipped: int
|
||||||
|
errors: int
|
||||||
|
results: List[FolderRenameResult]
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"total": self.total,
|
||||||
|
"renamed": self.renamed,
|
||||||
|
"skipped": self.skipped,
|
||||||
|
"errors": self.errors,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"key": r.key,
|
||||||
|
"old_folder": r.old_folder,
|
||||||
|
"new_folder": r.new_folder,
|
||||||
|
"success": r.success,
|
||||||
|
"skipped": r.skipped,
|
||||||
|
"reason": r.reason,
|
||||||
|
}
|
||||||
|
for r in self.results
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FolderNamingService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._is_running = False
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def run(self) -> FolderNamingReport:
|
||||||
|
async with self._lock:
|
||||||
|
if self._is_running:
|
||||||
|
logger.warning("FolderNamingService.run() called while already running")
|
||||||
|
return FolderNamingReport(total=0, renamed=0, skipped=0, errors=0, results=[])
|
||||||
|
self._is_running = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("FolderNamingService: starting folder naming scan")
|
||||||
|
results: List[FolderRenameResult] = []
|
||||||
|
|
||||||
|
async with _get_db_session() as db:
|
||||||
|
all_series = await AnimeSeriesService.get_all(db)
|
||||||
|
|
||||||
|
for series in all_series:
|
||||||
|
result = await self._process_series(series)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
renamed = sum(1 for r in results if r.success and not r.skipped)
|
||||||
|
skipped = sum(1 for r in results if r.skipped)
|
||||||
|
errors = sum(1 for r in results if not r.skipped and not r.success)
|
||||||
|
|
||||||
|
report = FolderNamingReport(
|
||||||
|
total=len(results),
|
||||||
|
renamed=renamed,
|
||||||
|
skipped=skipped,
|
||||||
|
errors=errors,
|
||||||
|
results=results,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"FolderNamingService: scan complete — total=%d renamed=%d skipped=%d errors=%d",
|
||||||
|
report.total, report.renamed, report.skipped, report.errors,
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
finally:
|
||||||
|
self._is_running = False
|
||||||
|
|
||||||
|
async def _process_series(self, series) -> FolderRenameResult:
|
||||||
|
key = series.key
|
||||||
|
folder = series.folder or ""
|
||||||
|
year = getattr(series, "year", None)
|
||||||
|
|
||||||
|
if year is None:
|
||||||
|
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=True, skipped=True, reason="no year in DB record")
|
||||||
|
|
||||||
|
if self._folder_has_year(folder):
|
||||||
|
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=True, skipped=True, reason="folder already has year")
|
||||||
|
|
||||||
|
target_folder = self._build_target_folder(folder, year)
|
||||||
|
|
||||||
|
# Safety: re-extract year from target to prevent double-year
|
||||||
|
if target_folder != folder:
|
||||||
|
extracted = self._extract_year_from_folder_name(target_folder)
|
||||||
|
if extracted != year:
|
||||||
|
logger.error("Safety guard for %s: target '%s' year=%s != DB year=%s — skipping", key, target_folder, extracted, year)
|
||||||
|
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=False, skipped=True, reason="safety guard: target year mismatch")
|
||||||
|
|
||||||
|
return await self._execute_rename(series, folder, target_folder)
|
||||||
|
|
||||||
|
async def _execute_rename(self, series, old_folder: str, target_folder: str) -> FolderRenameResult:
|
||||||
|
key = series.key
|
||||||
|
|
||||||
|
if old_folder == target_folder:
|
||||||
|
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=True, skipped=True, reason="same folder name")
|
||||||
|
|
||||||
|
anime_dir = settings.anime_directory
|
||||||
|
old_path = os.path.join(anime_dir, old_folder)
|
||||||
|
target_path = os.path.join(anime_dir, target_folder)
|
||||||
|
|
||||||
|
if not os.path.isdir(old_path):
|
||||||
|
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="source folder does not exist on disk")
|
||||||
|
|
||||||
|
if os.path.isdir(target_path):
|
||||||
|
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="target folder already exists on disk")
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.move(old_path, target_path)
|
||||||
|
logger.info("Renamed folder %s -> %s for series %s", old_folder, target_folder, key)
|
||||||
|
|
||||||
|
# Update in-memory cache
|
||||||
|
try:
|
||||||
|
from src.server.SeriesApp import get_series_app
|
||||||
|
series_app = get_series_app()
|
||||||
|
if key in series_app.list.keyDict:
|
||||||
|
series_app.list.keyDict[key].folder = target_folder
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to update in-memory cache for %s: %s", key, exc)
|
||||||
|
|
||||||
|
# Update database
|
||||||
|
async with _get_db_session() as db:
|
||||||
|
db_series = await AnimeSeriesService.get_by_key(db, key)
|
||||||
|
if db_series:
|
||||||
|
await AnimeSeriesService.update(db, series_id=db_series.id, folder=target_folder)
|
||||||
|
logger.debug("Updated DB folder for %s to %s", key, target_folder)
|
||||||
|
|
||||||
|
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=target_folder, success=True, skipped=False)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to rename folder for %s (%s -> %s): %s", key, old_folder, target_folder, exc)
|
||||||
|
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason=str(exc))
|
||||||
|
|
||||||
|
# Static helpers — public for direct testing
|
||||||
|
@staticmethod
|
||||||
|
def _folder_has_year(folder_name: str) -> bool:
|
||||||
|
if not folder_name:
|
||||||
|
return False
|
||||||
|
return bool(re.search(r"\(\d{4}\)", folder_name))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_year_from_folder_name(folder_name: str) -> Optional[int]:
|
||||||
|
if not folder_name:
|
||||||
|
return None
|
||||||
|
match = re.search(r"\((\d{4})\)", folder_name)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
year = int(match.group(1))
|
||||||
|
if 1900 <= year <= 2100:
|
||||||
|
return year
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_title_from_folder_name(folder_name: str) -> str:
|
||||||
|
return re.sub(r"\s*\(\d{4}\)\s*$", "", folder_name).strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_target_folder(folder_name: str, year: int) -> str:
|
||||||
|
title = FolderNamingService._extract_title_from_folder_name(folder_name)
|
||||||
|
return f"{title} ({year})"
|
||||||
|
|
||||||
|
|
||||||
|
_folder_naming_service: Optional[FolderNamingService] = None
|
||||||
|
|
||||||
|
def get_folder_naming_service() -> FolderNamingService:
|
||||||
|
global _folder_naming_service
|
||||||
|
if _folder_naming_service is None:
|
||||||
|
_folder_naming_service = FolderNamingService()
|
||||||
|
return _folder_naming_service
|
||||||
|
|
||||||
|
def reset_folder_naming_service() -> None:
|
||||||
|
global _folder_naming_service
|
||||||
|
_folder_naming_service = None
|
||||||
@@ -1,710 +0,0 @@
|
|||||||
"""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 collections import defaultdict
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List, Optional, Set, 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'
|
|
||||||
|
|
||||||
|
|
||||||
class DuplicateGroup:
|
|
||||||
"""Represents a group of duplicate folders for the same series.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
key: The series key (folder name before rename).
|
|
||||||
folders: List of folder paths that map to this series.
|
|
||||||
nfo_paths: List of corresponding NFO file paths.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, key: str, folders: List[str], nfo_paths: List[Path]):
|
|
||||||
self.key = key
|
|
||||||
self.folders = folders
|
|
||||||
self.nfo_paths = nfo_paths
|
|
||||||
|
|
||||||
@property
|
|
||||||
def count(self) -> int:
|
|
||||||
return len(self.folders)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"DuplicateGroup(key={self.key!r}, folders={self.folders})"
|
|
||||||
|
|
||||||
|
|
||||||
def _scan_for_pre_existing_duplicates(anime_dir: Path) -> List[DuplicateGroup]:
|
|
||||||
"""Scan anime directory for pre-existing duplicate folders.
|
|
||||||
|
|
||||||
Groups folders by the series key extracted from their NFO files.
|
|
||||||
Folders with the same title+year (same expected name) are flagged as duplicates.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
anime_dir: Path to the anime directory to scan.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of DuplicateGroup objects, one per series with duplicate folders.
|
|
||||||
"""
|
|
||||||
# Group folders by their expected name (title+year from NFO)
|
|
||||||
groups: Dict[str, List[Tuple[str, Path]]] = defaultdict(list)
|
|
||||||
|
|
||||||
for series_dir in anime_dir.iterdir():
|
|
||||||
if not series_dir.is_dir():
|
|
||||||
continue
|
|
||||||
nfo_path = series_dir / "tvshow.nfo"
|
|
||||||
if not nfo_path.exists():
|
|
||||||
continue
|
|
||||||
title, year = _parse_nfo_title_and_year(nfo_path)
|
|
||||||
if not title or not year:
|
|
||||||
continue
|
|
||||||
expected_name = _compute_expected_folder_name(title, year)
|
|
||||||
groups[expected_name].append((series_dir.name, nfo_path))
|
|
||||||
|
|
||||||
# Filter to only groups with more than one folder
|
|
||||||
duplicates = []
|
|
||||||
for key, items in groups.items():
|
|
||||||
if len(items) > 1:
|
|
||||||
folders = [item[0] for item in items]
|
|
||||||
nfo_paths = [item[1] for item in items]
|
|
||||||
duplicates.append(DuplicateGroup(key=key, folders=folders, nfo_paths=nfo_paths))
|
|
||||||
|
|
||||||
return duplicates
|
|
||||||
|
|
||||||
|
|
||||||
def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) -> bool:
|
|
||||||
"""Attempt to merge a duplicate group automatically.
|
|
||||||
|
|
||||||
Uses the first folder as the canonical one and removes others if they are
|
|
||||||
empty or contain only symlinks.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
group: The DuplicateGroup to merge.
|
|
||||||
dry_run: If True, only log actions without executing them.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if merge was successful, False otherwise.
|
|
||||||
"""
|
|
||||||
if len(group.folders) < 2:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Keep first folder as canonical, mark others for removal
|
|
||||||
canonical = group.folders[0]
|
|
||||||
to_remove = group.folders[1:]
|
|
||||||
|
|
||||||
for folder in to_remove:
|
|
||||||
folder_path = group.nfo_paths[0].parent.parent / folder # same parent dir
|
|
||||||
if not folder_path.exists():
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if folder is empty or only has symlinks
|
|
||||||
try:
|
|
||||||
contents = list(folder_path.iterdir())
|
|
||||||
except PermissionError:
|
|
||||||
logger.warning("Permission denied accessing %s, skip merge", folder_path)
|
|
||||||
return False
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not contents:
|
|
||||||
# Empty folder - safe to remove
|
|
||||||
if dry_run:
|
|
||||||
logger.info("[DRY-RUN] Would delete empty duplicate folder: %s", folder_path)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
folder_path.rmdir()
|
|
||||||
logger.info("Deleted empty duplicate folder: %s", folder_path)
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if all contents are symlinks pointing to canonical
|
|
||||||
all_symlinks = all(
|
|
||||||
item.is_symlink() and item.resolve() == (folder_path.parent / canonical).resolve()
|
|
||||||
for item in contents
|
|
||||||
)
|
|
||||||
if all_symlinks:
|
|
||||||
if dry_run:
|
|
||||||
logger.info("[DRY-RUN] Would remove symlinks in duplicate folder: %s", folder_path)
|
|
||||||
else:
|
|
||||||
for item in contents:
|
|
||||||
item.unlink()
|
|
||||||
try:
|
|
||||||
folder_path.rmdir()
|
|
||||||
logger.info("Removed symlink-only duplicate folder: %s", folder_path)
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Cannot auto-merge - requires manual intervention
|
|
||||||
logger.warning(
|
|
||||||
"Cannot auto-merge duplicate folders for '%s': %s (manual merge required)",
|
|
||||||
group.key,
|
|
||||||
[canonical] + to_remove,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Removes any existing year suffixes (e.g., "(2021)") before adding the
|
|
||||||
canonical one to prevent duplication across multiple folder rename runs.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title: Series title from NFO.
|
|
||||||
year: Release year from NFO.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Sanitised folder name in the format ``"{title} ({year})"``.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
|
|
||||||
# Remove all trailing year suffixes to prevent duplication.
|
|
||||||
# This handles cases where the title already contains one or more years.
|
|
||||||
# Regex pattern: matches one or more " (YYYY)" at the end of the string
|
|
||||||
clean_title = re.sub(r'(\s*\(\d{4}\))+\s*$', '', title).strip()
|
|
||||||
|
|
||||||
year_suffix = f" ({year})"
|
|
||||||
raw_name = f"{clean_title}{year_suffix}"
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_stale_files_after_rename(new_path: Path, new_name: str) -> None:
|
|
||||||
"""Remove legacy 'key' file after successful folder rename.
|
|
||||||
|
|
||||||
Also checks for orphaned folders with the same key that may have been
|
|
||||||
left behind from previous rename operations.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
new_path: The new folder path after rename.
|
|
||||||
new_name: The new folder name.
|
|
||||||
"""
|
|
||||||
key_file = new_path / "key"
|
|
||||||
if key_file.exists():
|
|
||||||
try:
|
|
||||||
key_file.unlink()
|
|
||||||
logger.info(
|
|
||||||
"Removed legacy 'key' file after rename: %s", key_file
|
|
||||||
)
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Could not remove legacy 'key' file %s: %s", key_file, exc
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_orphaned_folder(old_path: Path, new_path: Path, dry_run: bool = False) -> bool:
|
|
||||||
"""Clean up orphaned folder after successful rename.
|
|
||||||
|
|
||||||
After a folder is successfully renamed to new_path, this function checks
|
|
||||||
if the old_path still exists (orphaned folder) and removes it. If the
|
|
||||||
old folder contains files, they are moved to new_path before deletion.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
old_path: The original folder path before rename.
|
|
||||||
new_path: The new folder path after rename.
|
|
||||||
dry_run: If True, only log actions without executing them.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if old folder was cleaned up (or would be in dry-run mode),
|
|
||||||
False if old folder does not exist or cleanup failed.
|
|
||||||
"""
|
|
||||||
if not old_path.exists():
|
|
||||||
logger.debug(
|
|
||||||
"Old folder does not exist, no cleanup needed: %s", old_path
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check if folder is empty
|
|
||||||
try:
|
|
||||||
contents = list(old_path.iterdir())
|
|
||||||
except PermissionError as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Permission denied accessing old folder %s: %s", old_path, exc
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning(
|
|
||||||
"OS error accessing old folder %s: %s", old_path, exc
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not contents:
|
|
||||||
# Empty folder — delete it
|
|
||||||
if dry_run:
|
|
||||||
logger.info(
|
|
||||||
"[DRY-RUN] Would delete empty orphaned folder: %s", old_path
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
old_path.rmdir()
|
|
||||||
logger.info("Deleted empty orphaned folder: %s", old_path)
|
|
||||||
return True
|
|
||||||
except PermissionError as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Permission denied deleting folder %s: %s", old_path, exc
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning(
|
|
||||||
"OS error deleting folder %s: %s", old_path, exc
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Folder has contents — move files to new_path then delete
|
|
||||||
if dry_run:
|
|
||||||
logger.info(
|
|
||||||
"[DRY-RUN] Would move %d files from orphaned folder %s to %s",
|
|
||||||
len(contents), old_path, new_path
|
|
||||||
)
|
|
||||||
for item in contents:
|
|
||||||
logger.info("[DRY-RUN] Would move: %s → %s", item, new_path / item.name)
|
|
||||||
logger.info("[DRY-RUN] Would then delete orphaned folder: %s", old_path)
|
|
||||||
return True
|
|
||||||
|
|
||||||
files_moved = 0
|
|
||||||
errors = 0
|
|
||||||
for item in contents:
|
|
||||||
try:
|
|
||||||
dest = new_path / item.name
|
|
||||||
item.rename(dest)
|
|
||||||
logger.debug("Moved %s → %s", item, dest)
|
|
||||||
files_moved += 1
|
|
||||||
except PermissionError as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Permission denied moving %s: %s", item, exc
|
|
||||||
)
|
|
||||||
errors += 1
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning(
|
|
||||||
"OS error moving %s: %s", item, exc
|
|
||||||
)
|
|
||||||
errors += 1
|
|
||||||
|
|
||||||
if files_moved > 0:
|
|
||||||
logger.info(
|
|
||||||
"Moved %d files from orphaned folder to %s",
|
|
||||||
files_moved, new_path
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete the now-empty old folder
|
|
||||||
try:
|
|
||||||
old_path.rmdir()
|
|
||||||
logger.info("Deleted orphaned folder after moving contents: %s", old_path)
|
|
||||||
return errors == 0
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Could not delete orphaned folder %s (may not be empty): %s",
|
|
||||||
old_path, exc
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
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_folder(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(dry_run: bool = False) -> 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.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
dry_run: If True, simulate rename operations without actually
|
|
||||||
moving folders or updating the database.
|
|
||||||
|
|
||||||
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}
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
logger.info("Running in DRY-RUN mode — no changes will be made")
|
|
||||||
|
|
||||||
stats = {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
|
|
||||||
|
|
||||||
# Detect pre-existing duplicates before rename loop
|
|
||||||
pre_existing_duplicates: Set[str] = set()
|
|
||||||
duplicates = _scan_for_pre_existing_duplicates(anime_dir)
|
|
||||||
for dup_group in duplicates:
|
|
||||||
# Try automatic merge first
|
|
||||||
if _try_merge_duplicate_group(dup_group, dry_run=dry_run):
|
|
||||||
logger.info(
|
|
||||||
"Auto-merged duplicate group for '%s' (%d folders)",
|
|
||||||
dup_group.key,
|
|
||||||
dup_group.count,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Flag all folders in this group as pre-existing duplicates
|
|
||||||
for folder in dup_group.folders:
|
|
||||||
pre_existing_duplicates.add(folder)
|
|
||||||
logger.warning(
|
|
||||||
"Duplicate folders detected for series '%s': %s — "
|
|
||||||
"manual cleanup required (different releases or non-empty duplicates)",
|
|
||||||
dup_group.key,
|
|
||||||
dup_group.folders,
|
|
||||||
)
|
|
||||||
|
|
||||||
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 pre-existing duplicate
|
|
||||||
if current_name in pre_existing_duplicates:
|
|
||||||
logger.warning(
|
|
||||||
"Skipping rename for '%s' — pre-existing duplicate folder detected",
|
|
||||||
current_name,
|
|
||||||
)
|
|
||||||
stats["errors"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check for duplicate target
|
|
||||||
if expected_path.exists():
|
|
||||||
logger.warning(
|
|
||||||
"Cannot rename '%s' → '%s' — target already exists",
|
|
||||||
current_name,
|
|
||||||
expected_name,
|
|
||||||
)
|
|
||||||
# Target folder exists — remove source folder and delete its DB record
|
|
||||||
# (target folder's DB record survives, source folder's record must be removed
|
|
||||||
# to avoid orphaning episodes/downloads)
|
|
||||||
try:
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
logger.warning(
|
|
||||||
"Removing source duplicate folder '%s' — target '%s' already exists",
|
|
||||||
current_name,
|
|
||||||
expected_name,
|
|
||||||
)
|
|
||||||
shutil.rmtree(series_dir)
|
|
||||||
logger.info(
|
|
||||||
"Removed source folder '%s' — series already exists at target",
|
|
||||||
current_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete source DB record (cascades to episodes and download items)
|
|
||||||
async with get_db_session() as db:
|
|
||||||
source_series = await AnimeSeriesService.get_by_folder(db, current_name)
|
|
||||||
if source_series is None:
|
|
||||||
# Fallback: find by folder name
|
|
||||||
all_series = await AnimeSeriesService.get_all(db)
|
|
||||||
for s in all_series:
|
|
||||||
if s.folder == current_name:
|
|
||||||
source_series = s
|
|
||||||
break
|
|
||||||
if source_series is not None:
|
|
||||||
await AnimeSeriesService.delete(db, source_series.id)
|
|
||||||
logger.info(
|
|
||||||
"Deleted source DB record for '%s' (id=%s) — target folder '%s' retains DB record",
|
|
||||||
current_name,
|
|
||||||
source_series.id,
|
|
||||||
expected_name,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
"No DB record found for source folder '%s' — folder removed only",
|
|
||||||
current_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
stats["renamed"] += 1
|
|
||||||
except OSError as exc:
|
|
||||||
logger.error(
|
|
||||||
"Failed to remove source folder '%s': %s",
|
|
||||||
current_name,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
logger.info(
|
|
||||||
"[DRY-RUN] Would rename folder: '%s' → '%s'",
|
|
||||||
current_name,
|
|
||||||
expected_name,
|
|
||||||
)
|
|
||||||
stats["renamed"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
old_path = series_dir
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Clean up stale/legacy files after successful rename
|
|
||||||
_cleanup_stale_files_after_rename(expected_path, expected_name)
|
|
||||||
|
|
||||||
# Clean up orphaned folder if old path still exists
|
|
||||||
_cleanup_orphaned_folder(old_path, expected_path, dry_run=False)
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
"""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.config.settings import settings as _settings
|
|
||||||
from src.core.utils.image_downloader import ImageDownloader
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Semaphore to limit concurrent NFO repair TMDB operations to 3.
|
|
||||||
_NFO_REPAIR_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_missing_nfo(series_dir: Path, series_name: str) -> None:
|
|
||||||
"""Create minimal NFO for series without one.
|
|
||||||
|
|
||||||
Creates a fresh :class:`NFOService` per invocation so concurrent
|
|
||||||
tasks cannot interfere with each other.
|
|
||||||
|
|
||||||
A module-level semaphore limits concurrent TMDB operations to 3.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
series_dir: Absolute path to the series folder.
|
|
||||||
series_name: Human-readable series name for log messages.
|
|
||||||
"""
|
|
||||||
from src.core.services.nfo_factory import NFOServiceFactory
|
|
||||||
|
|
||||||
async with _NFO_REPAIR_SEMAPHORE:
|
|
||||||
try:
|
|
||||||
factory = NFOServiceFactory()
|
|
||||||
nfo_service = factory.create()
|
|
||||||
await nfo_service.create_minimal_nfo(
|
|
||||||
serie_name=series_name,
|
|
||||||
serie_folder=series_dir.name,
|
|
||||||
)
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
|
||||||
logger.error(
|
|
||||||
"NFO creation failed for %s: %s",
|
|
||||||
series_name,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _repair_one_series(series_dir: Path, series_name: str) -> None:
|
|
||||||
"""Repair a single series NFO in isolation.
|
|
||||||
|
|
||||||
Creates a fresh :class:`NFOService` and :class:`NfoRepairService` per
|
|
||||||
invocation so that each repair owns its own ``aiohttp`` session/connector
|
|
||||||
and concurrent tasks cannot interfere with each other.
|
|
||||||
|
|
||||||
A module-level semaphore (``_NFO_REPAIR_SEMAPHORE``) limits the number of
|
|
||||||
simultaneous TMDB requests to avoid rate-limiting.
|
|
||||||
|
|
||||||
Any exception is caught and logged so the asyncio task never silently
|
|
||||||
drops an unhandled error.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
series_dir: Absolute path to the series folder.
|
|
||||||
series_name: Human-readable series name for log messages.
|
|
||||||
"""
|
|
||||||
from src.core.services.nfo_factory import NFOServiceFactory
|
|
||||||
from src.core.services.nfo_repair_service import NfoRepairService
|
|
||||||
|
|
||||||
async with _NFO_REPAIR_SEMAPHORE:
|
|
||||||
try:
|
|
||||||
factory = NFOServiceFactory()
|
|
||||||
nfo_service = factory.create()
|
|
||||||
repair_service = NfoRepairService(nfo_service)
|
|
||||||
await repair_service.repair_series(series_dir, series_name)
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
|
||||||
logger.error(
|
|
||||||
"NFO repair failed for %s: %s",
|
|
||||||
series_name,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def perform_nfo_repair_scan(background_loader=None) -> None:
|
|
||||||
"""Scan all series folders, repair incomplete and create missing NFO files.
|
|
||||||
|
|
||||||
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``:
|
|
||||||
- Missing NFOs: creates minimal NFO via ``_create_missing_nfo``
|
|
||||||
- Incomplete NFOs: repairs via ``_repair_one_series``
|
|
||||||
|
|
||||||
Each repair 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.
|
|
||||||
|
|
||||||
The ``background_loader`` parameter is accepted for backwards-compatibility
|
|
||||||
but is no longer used.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
background_loader: Unused. Kept to avoid breaking call-sites.
|
|
||||||
"""
|
|
||||||
from src.core.services.nfo_repair_service import nfo_needs_repair
|
|
||||||
|
|
||||||
if not _settings.tmdb_api_key:
|
|
||||||
logger.warning("NFO repair scan skipped — TMDB API key not configured")
|
|
||||||
return
|
|
||||||
if not _settings.anime_directory:
|
|
||||||
logger.warning("NFO repair scan skipped — anime directory not configured")
|
|
||||||
return
|
|
||||||
anime_dir = Path(_settings.anime_directory)
|
|
||||||
if not anime_dir.is_dir():
|
|
||||||
logger.warning("NFO repair scan skipped — anime directory not found: %s", anime_dir)
|
|
||||||
return
|
|
||||||
|
|
||||||
queued = 0
|
|
||||||
total = 0
|
|
||||||
missing_nfo_count = 0
|
|
||||||
repair_tasks: list[asyncio.Task] = []
|
|
||||||
for series_dir in sorted(anime_dir.iterdir()):
|
|
||||||
if not series_dir.is_dir():
|
|
||||||
continue
|
|
||||||
nfo_path = series_dir / "tvshow.nfo"
|
|
||||||
series_name = series_dir.name
|
|
||||||
if not nfo_path.exists():
|
|
||||||
# Create minimal NFO for series without one
|
|
||||||
missing_nfo_count += 1
|
|
||||||
repair_tasks.append(
|
|
||||||
asyncio.create_task(
|
|
||||||
_create_missing_nfo(series_dir, series_name),
|
|
||||||
name=f"nfo_create:{series_name}",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
total += 1
|
|
||||||
if nfo_needs_repair(nfo_path):
|
|
||||||
queued += 1
|
|
||||||
repair_tasks.append(
|
|
||||||
asyncio.create_task(
|
|
||||||
_repair_one_series(series_dir, series_name),
|
|
||||||
name=f"nfo_repair:{series_name}",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if repair_tasks:
|
|
||||||
logger.info(
|
|
||||||
"NFO repair scan: waiting for %d repair/create tasks to complete",
|
|
||||||
len(repair_tasks),
|
|
||||||
)
|
|
||||||
await asyncio.gather(*repair_tasks, return_exceptions=True)
|
|
||||||
logger.info("NFO repair scan tasks completed")
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"NFO repair scan complete: %d of %d series queued for repair, %d missing NFOs queued for creation",
|
|
||||||
queued,
|
|
||||||
total,
|
|
||||||
missing_nfo_count,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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 (synchronous, waits for completion).
|
|
||||||
logger.info("Starting NFO repair scan as part of folder scan")
|
|
||||||
await perform_nfo_repair_scan(background_loader=None)
|
|
||||||
logger.info("NFO repair scan complete")
|
|
||||||
|
|
||||||
# 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
|
|
||||||
386
src/server/services/image_loading_service.py
Normal file
386
src/server/services/image_loading_service.py
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
"""Image loading service for downloading series artwork from TMDB.
|
||||||
|
|
||||||
|
This service downloads poster.jpg, fanart.jpg, and logo.png images
|
||||||
|
for anime series using TMDB as the image source.
|
||||||
|
|
||||||
|
Integrated with:
|
||||||
|
- BackgroundLoaderService: triggered when adding new anime
|
||||||
|
- SchedulerService: triggered during scheduled rescan
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
from src.server.nfo.tmdb_client import TMDBClient
|
||||||
|
from src.server.utils.image_downloader import ImageDownloader
|
||||||
|
from src.server.utils.media import FANART_FILENAME, LOGO_FILENAME, POSTER_FILENAME
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageLoadingServiceError(Exception):
|
||||||
|
"""Exception raised for image loading failures."""
|
||||||
|
|
||||||
|
|
||||||
|
class ImageLoadingService:
|
||||||
|
"""Service for loading series images from TMDB.
|
||||||
|
|
||||||
|
Downloads poster.jpg, fanart.jpg, and logo.png for anime series
|
||||||
|
using TMDB as the image source. Images are saved to the series
|
||||||
|
folder alongside tvshow.nfo.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
tmdb_client: TMDB API client for fetching image URLs
|
||||||
|
image_downloader: Downloader for saving images to disk
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Batch size for scheduler bulk processing
|
||||||
|
BATCH_SIZE = 10
|
||||||
|
|
||||||
|
def __init__(self, tmdb_client: TMDBClient):
|
||||||
|
"""Initialize the image loading service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tmdb_client: TMDB API client for fetching image metadata
|
||||||
|
"""
|
||||||
|
self._tmdb_client = tmdb_client
|
||||||
|
|
||||||
|
async def load_series_images(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
folder: str,
|
||||||
|
anime_directory: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> Dict[str, bool]:
|
||||||
|
"""Load all images for a single series.
|
||||||
|
|
||||||
|
Downloads poster.jpg, fanart.jpg, and logo.png from TMDB
|
||||||
|
if they don't already exist in the series folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Series unique identifier (provider key)
|
||||||
|
folder: Series folder name (metadata, for path construction)
|
||||||
|
anime_directory: Base anime directory path
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with download status for each image type:
|
||||||
|
{
|
||||||
|
"poster": bool, # True if poster.jpg exists/downloads succeeded
|
||||||
|
"fanart": bool, # True if fanart.jpg exists/downloads succeeded
|
||||||
|
"logo": bool # True if logo.png exists/downloads succeeded
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
series_dir = Path(anime_directory) / folder
|
||||||
|
|
||||||
|
if not series_dir.exists():
|
||||||
|
logger.warning(
|
||||||
|
"Series directory not found, cannot load images",
|
||||||
|
key=key,
|
||||||
|
folder=folder,
|
||||||
|
path=str(series_dir),
|
||||||
|
)
|
||||||
|
return {"poster": False, "fanart": False, "logo": False}
|
||||||
|
|
||||||
|
# Get series from database to retrieve TMDB ID
|
||||||
|
series = await AnimeSeriesService.get_by_key(db, key)
|
||||||
|
if not series:
|
||||||
|
logger.warning("Series not found in database key=%s", key)
|
||||||
|
return {"poster": False, "fanart": False, "logo": False}
|
||||||
|
|
||||||
|
if not series.tmdb_id:
|
||||||
|
logger.warning(
|
||||||
|
"Series has no TMDB ID, cannot load images key=%s name=%s",
|
||||||
|
key, series.name,
|
||||||
|
)
|
||||||
|
return {"poster": False, "fanart": False, "logo": False}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Fetch image metadata from TMDB
|
||||||
|
images_data = await self._tmdb_client.get_tv_show_images(series.tmdb_id)
|
||||||
|
|
||||||
|
poster_url, logo_url, fanart_url = self._select_best_images(images_data)
|
||||||
|
|
||||||
|
# Download images
|
||||||
|
results = await self._download_images(
|
||||||
|
series_dir, poster_url, logo_url, fanart_url
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update database flags
|
||||||
|
await self._update_series_flags(db, series, results)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to load images for series: %s", key, exc_info=e)
|
||||||
|
return {"poster": False, "fanart": False, "logo": False}
|
||||||
|
|
||||||
|
async def load_series_images_batch(
|
||||||
|
self,
|
||||||
|
series_list: List[Dict[str, Any]],
|
||||||
|
anime_directory: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""Load images for multiple series in batches.
|
||||||
|
|
||||||
|
Used by scheduler to process many series efficiently.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
series_list: List of dicts with 'key' and 'folder' for each series
|
||||||
|
anime_directory: Base anime directory path
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with counts:
|
||||||
|
{
|
||||||
|
"total": int, # Total series processed
|
||||||
|
"success": int, # Series with all images loaded
|
||||||
|
"partial": int, # Series with some images loaded
|
||||||
|
"failed": int, # Series with no images loaded
|
||||||
|
"poster": int, # Count of poster.jpg downloads
|
||||||
|
"fanart": int, # Count of fanart.jpg downloads
|
||||||
|
"logo": int, # Count of logo.png downloads
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
stats = {
|
||||||
|
"total": len(series_list),
|
||||||
|
"success": 0,
|
||||||
|
"partial": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"poster": 0,
|
||||||
|
"fanart": 0,
|
||||||
|
"logo": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process in batches to respect TMDB rate limits
|
||||||
|
for i in range(0, len(series_list), self.BATCH_SIZE):
|
||||||
|
batch = series_list[i : i + self.BATCH_SIZE]
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
self.load_series_images(
|
||||||
|
key=series["key"],
|
||||||
|
folder=series["folder"],
|
||||||
|
anime_directory=anime_directory,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
for series in batch
|
||||||
|
]
|
||||||
|
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
for series, result in zip(batch, results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
logger.warning(
|
||||||
|
"Image loading failed for series: %s",
|
||||||
|
series["key"],
|
||||||
|
exc_info=result,
|
||||||
|
)
|
||||||
|
stats["failed"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if result["poster"] and result["fanart"] and result["logo"]:
|
||||||
|
stats["success"] += 1
|
||||||
|
elif result["poster"] or result["fanart"] or result["logo"]:
|
||||||
|
stats["partial"] += 1
|
||||||
|
else:
|
||||||
|
stats["failed"] += 1
|
||||||
|
|
||||||
|
if result["poster"]:
|
||||||
|
stats["poster"] += 1
|
||||||
|
if result["fanart"]:
|
||||||
|
stats["fanart"] += 1
|
||||||
|
if result["logo"]:
|
||||||
|
stats["logo"] += 1
|
||||||
|
|
||||||
|
# Small delay between batches to avoid overwhelming TMDB
|
||||||
|
if i + self.BATCH_SIZE < len(series_list):
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Batch image loading completed",
|
||||||
|
total=stats["total"],
|
||||||
|
success=stats["success"],
|
||||||
|
partial=stats["partial"],
|
||||||
|
failed=stats["failed"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def _select_best_images(
|
||||||
|
self, images_data: Dict[str, Any]
|
||||||
|
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||||
|
"""Select the best available images from TMDB data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
images_data: TMDB images response with 'posters', 'backdrops', 'logos'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (poster_url, logo_url, fanart_url) - URLs or None if not available
|
||||||
|
"""
|
||||||
|
poster_url = None
|
||||||
|
logo_url = None
|
||||||
|
fanart_url = None
|
||||||
|
|
||||||
|
# Select poster: prefer English, otherwise take first available
|
||||||
|
posters = images_data.get("posters", [])
|
||||||
|
for poster in posters:
|
||||||
|
if poster.get("iso_639_1") == "en" or poster.get("iso_639_1") is None:
|
||||||
|
poster_url = self._tmdb_client.get_image_url(poster["file_path"])
|
||||||
|
break
|
||||||
|
if not poster_url and posters:
|
||||||
|
poster_url = self._tmdb_client.get_image_url(posters[0]["file_path"])
|
||||||
|
|
||||||
|
# Select logo/clearlogo: prefer English with transparent background
|
||||||
|
logos = images_data.get("logos", [])
|
||||||
|
for logo in logos:
|
||||||
|
if logo.get("iso_639_1") == "en":
|
||||||
|
logo_url = self._tmdb_client.get_image_url(logo["file_path"])
|
||||||
|
break
|
||||||
|
if not logo_url and logos:
|
||||||
|
logo_url = self._tmdb_client.get_image_url(logos[0]["file_path"])
|
||||||
|
|
||||||
|
# Select fanart/backdrop: prefer English
|
||||||
|
backdrops = images_data.get("backdrops", [])
|
||||||
|
for backdrop in backdrops:
|
||||||
|
if backdrop.get("iso_639_1") == "en":
|
||||||
|
fanart_url = self._tmdb_client.get_image_url(backdrop["file_path"])
|
||||||
|
break
|
||||||
|
if not fanart_url and backdrops:
|
||||||
|
fanart_url = self._tmdb_client.get_image_url(backdrops[0]["file_path"])
|
||||||
|
|
||||||
|
return poster_url, logo_url, fanart_url
|
||||||
|
|
||||||
|
async def _download_images(
|
||||||
|
self,
|
||||||
|
series_dir: Path,
|
||||||
|
poster_url: Optional[str],
|
||||||
|
logo_url: Optional[str],
|
||||||
|
fanart_url: Optional[str],
|
||||||
|
) -> Dict[str, bool]:
|
||||||
|
"""Download images to series directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
series_dir: Path to series folder
|
||||||
|
poster_url: URL for poster.jpg
|
||||||
|
logo_url: URL for logo.png
|
||||||
|
fanart_url: URL for fanart.jpg
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with download status for each image
|
||||||
|
"""
|
||||||
|
results = {"poster": False, "fanart": False, "logo": False}
|
||||||
|
|
||||||
|
async with ImageDownloader() as downloader:
|
||||||
|
tasks = []
|
||||||
|
|
||||||
|
if poster_url:
|
||||||
|
tasks.append(
|
||||||
|
self._download_and_track(
|
||||||
|
downloader, poster_url, series_dir / POSTER_FILENAME, "poster", results
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if logo_url:
|
||||||
|
tasks.append(
|
||||||
|
self._download_and_track(
|
||||||
|
downloader, logo_url, series_dir / LOGO_FILENAME, "logo", results
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if fanart_url:
|
||||||
|
tasks.append(
|
||||||
|
self._download_and_track(
|
||||||
|
downloader, fanart_url, series_dir / FANART_FILENAME, "fanart", results
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def _download_and_track(
|
||||||
|
self,
|
||||||
|
downloader: ImageDownloader,
|
||||||
|
url: str,
|
||||||
|
local_path: Path,
|
||||||
|
key: str,
|
||||||
|
results: Dict[str, bool],
|
||||||
|
) -> None:
|
||||||
|
"""Download single image and track result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
downloader: ImageDownloader instance
|
||||||
|
url: Image URL
|
||||||
|
local_path: Local destination path
|
||||||
|
key: Result dict key ('poster', 'logo', 'fanart')
|
||||||
|
results: Dict to update with result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
success = await downloader.download_image(
|
||||||
|
url, local_path, skip_existing=True, validate=True
|
||||||
|
)
|
||||||
|
results[key] = success
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to download %s: %s", key, e)
|
||||||
|
results[key] = False
|
||||||
|
|
||||||
|
async def _update_series_flags(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
series: Any,
|
||||||
|
results: Dict[str, bool],
|
||||||
|
) -> None:
|
||||||
|
"""Update database flags after image loading.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
series: AnimeSeries instance
|
||||||
|
results: Dict with download status for each image
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
series.images_loaded = results["poster"] and results["fanart"]
|
||||||
|
series.logo_loaded = results["logo"]
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to update series image flags: %s", e)
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_image_loading_service: Optional[ImageLoadingService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_image_loading_service() -> ImageLoadingService:
|
||||||
|
"""Get the image loading service singleton.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ImageLoadingService instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If service not initialized
|
||||||
|
"""
|
||||||
|
if _image_loading_service is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"ImageLoadingService not initialized. "
|
||||||
|
"Call init_image_loading_service() first."
|
||||||
|
)
|
||||||
|
return _image_loading_service
|
||||||
|
|
||||||
|
|
||||||
|
def init_image_loading_service(tmdb_client: TMDBClient) -> ImageLoadingService:
|
||||||
|
"""Initialize the image loading service singleton.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tmdb_client: TMDB API client
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ImageLoadingService instance
|
||||||
|
"""
|
||||||
|
global _image_loading_service
|
||||||
|
_image_loading_service = ImageLoadingService(tmdb_client=tmdb_client)
|
||||||
|
return _image_loading_service
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user