Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 49cd84f3e5 | |||
| e46759347e | |||
| 75f743e6cc | |||
| 4dc5ffa19e | |||
| 1649a22418 | |||
| 246752e2fc | |||
| 84b24ed79e | |||
| bf3954587a |
@@ -17,6 +17,9 @@ __pycache__/
|
|||||||
# Docker files (not needed inside the image)
|
# Docker files (not needed inside the image)
|
||||||
Docker/
|
Docker/
|
||||||
|
|
||||||
|
# Exception: VERSION is needed by Dockerfile.app
|
||||||
|
!Docker/VERSION
|
||||||
|
|
||||||
# Test and dev files
|
# Test and dev files
|
||||||
tests/
|
tests/
|
||||||
Temp/
|
Temp/
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ COPY src/ ./src/
|
|||||||
COPY run_server.py .
|
COPY run_server.py .
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
COPY data/config.json ./data/config.json
|
COPY data/config.json ./data/config.json
|
||||||
|
COPY Docker/VERSION ./Docker/VERSION
|
||||||
|
|
||||||
# Create runtime directories
|
# Create runtime directories
|
||||||
RUN mkdir -p /app/data/config_backups /app/logs
|
RUN mkdir -p /app/data/config_backups /app/logs
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v1.3.1
|
v1.4.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
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ src/server/
|
|||||||
| +-- 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
|
| +-- nfo_service.py # NFO metadata management
|
||||||
|
| +-- setup_service.py # Series key resolution from folder names
|
||||||
| +-- folder_scan_service.py # Daily folder maintenance scan
|
| +-- folder_scan_service.py # Daily folder maintenance scan
|
||||||
+-- models/ # Pydantic models
|
+-- models/ # Pydantic models
|
||||||
| +-- auth.py # Auth request/response models
|
| +-- auth.py # Auth request/response models
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|||||||
@@ -73,40 +73,31 @@ from src.server.models.download import DownloadItem, EpisodeIdentifier
|
|||||||
class MockQueueRepository:
|
class MockQueueRepository:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._items: Dict[str, DownloadItem] = {}
|
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:**
|
### Testing SetupService
|
||||||
- The mock uses in-memory storage, no database required
|
|
||||||
- All async methods are implemented (even if just pass-through)
|
SetupService handles series key resolution from folder names during library setup. Test file: `tests/unit/test_setup_service.py`.
|
||||||
- `save_item` uses `item.id` as key (must be set before calling)
|
|
||||||
- Suitable for unit tests only (no persistence)
|
Key methods tested:
|
||||||
|
- `_extract_year_from_folder_name()` — parses `(YYYY)` suffix
|
||||||
|
- `_extract_title_from_folder_name()` — strips year suffix
|
||||||
|
- `_resolve_key_via_search()` — resolves provider key via fuzzy title matching
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_key_when_single_exact_match(self):
|
||||||
|
"""Search returns 1 result with same name → returns key."""
|
||||||
|
mock_series_app = AsyncMock()
|
||||||
|
mock_series_app.search.return_value = [
|
||||||
|
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'}
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch('src.server.services.setup_service.get_series_app', return_value=mock_series_app):
|
||||||
|
result = await SetupService._resolve_key_via_search("Attack on Titan")
|
||||||
|
|
||||||
|
assert result == 'attack-on-titan'
|
||||||
|
```
|
||||||
|
|
||||||
### Mocking aiohttp Sessions
|
### Mocking aiohttp Sessions
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aniworld-web",
|
"name": "aniworld-web",
|
||||||
"version": "1.3.1",
|
"version": "1.4.2",
|
||||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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,41 +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)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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
|
||||||
@@ -518,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 = (
|
||||||
@@ -621,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:
|
||||||
@@ -673,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]:
|
||||||
@@ -939,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,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
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 +8,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,
|
||||||
@@ -20,7 +19,6 @@ from src.server.exceptions import (
|
|||||||
from src.server.models.anime import AnimeMetadataUpdate
|
from src.server.models.anime import AnimeMetadataUpdate
|
||||||
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,
|
||||||
@@ -30,6 +28,7 @@ from src.server.utils.dependencies import (
|
|||||||
require_auth,
|
require_auth,
|
||||||
)
|
)
|
||||||
from src.server.utils.filesystem import sanitize_folder_name
|
from src.server.utils.filesystem import 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 +36,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 +101,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 +122,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):
|
||||||
@@ -828,18 +789,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 +800,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 +876,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 +896,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,7 +942,7 @@ async def add_series(
|
|||||||
e
|
e
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step F: Scan missing episodes immediately if background loader is not running
|
# Step G: Scan missing episodes immediately if background loader is not running
|
||||||
# Uses existing SerieScanner and AnimeService sync to avoid duplicates
|
# Uses existing SerieScanner and AnimeService sync to avoid duplicates
|
||||||
try:
|
try:
|
||||||
loader_running = bool(
|
loader_running = bool(
|
||||||
|
|||||||
@@ -76,8 +76,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:
|
||||||
@@ -165,7 +163,7 @@ async def setup_auth(req: SetupRequest):
|
|||||||
|
|
||||||
# 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/health", tags=["health"])
|
router = APIRouter(prefix="/health", tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
from src.server.utils.version import APP_VERSION
|
||||||
|
|
||||||
|
|
||||||
class HealthStatus(BaseModel):
|
class HealthStatus(BaseModel):
|
||||||
"""Basic health status response."""
|
"""Basic health status response."""
|
||||||
|
|
||||||
status: str
|
status: str
|
||||||
timestamp: str
|
timestamp: str
|
||||||
version: str = "1.0.1"
|
version: str = APP_VERSION
|
||||||
service: str = "aniworld-api"
|
service: str = "aniworld-api"
|
||||||
series_app_initialized: bool = False
|
series_app_initialized: bool = False
|
||||||
anime_directory_configured: bool = False
|
anime_directory_configured: bool = False
|
||||||
@@ -63,7 +66,7 @@ class DetailedHealthStatus(BaseModel):
|
|||||||
|
|
||||||
status: str
|
status: str
|
||||||
timestamp: str
|
timestamp: str
|
||||||
version: str = "1.0.1"
|
version: str = APP_VERSION
|
||||||
dependencies: DependencyHealth
|
dependencies: DependencyHealth
|
||||||
startup_time: datetime
|
startup_time: datetime
|
||||||
|
|
||||||
@@ -192,7 +195,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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ 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 require_auth
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -31,7 +31,6 @@ 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,
|
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"is_running": runtime.get("is_running", False),
|
"is_running": runtime.get("is_running", False),
|
||||||
|
|||||||
313
src/server/api/setup_endpoints.py
Normal file
313
src/server/api/setup_endpoints.py
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
"""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.utils.dependencies import (
|
||||||
|
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),
|
||||||
|
) -> 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
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unresolved/{folder_name}/search", response_model=UnresolvedFolderResponse)
|
||||||
|
async def search_unresolved_folder(
|
||||||
|
folder_name: str,
|
||||||
|
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 and caches the results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_name: URL-encoded folder name to search for
|
||||||
|
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Perform search
|
||||||
|
series_app = get_series_app()
|
||||||
|
try:
|
||||||
|
results = await series_app.search(folder.title)
|
||||||
|
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,
|
||||||
|
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}"}
|
||||||
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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -119,6 +119,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 +310,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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -190,6 +190,54 @@ 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]] = {}
|
||||||
|
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)
|
||||||
|
return episode_dict
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
|
||||||
class Episode(Base, TimestampMixin):
|
class Episode(Base, TimestampMixin):
|
||||||
"""SQLAlchemy model for anime episodes.
|
"""SQLAlchemy model for anime episodes.
|
||||||
@@ -578,6 +626,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()
|
||||||
@@ -1352,3 +1365,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,
|
||||||
@@ -214,6 +215,7 @@ async def lifespan(_application: FastAPI):
|
|||||||
"""
|
"""
|
||||||
# Setup logging first with INFO level
|
# Setup logging first with INFO level
|
||||||
logger = setup_logging(log_level="INFO")
|
logger = setup_logging(log_level="INFO")
|
||||||
|
logger.info("Starting FastAPI application v%s", APP_VERSION)
|
||||||
|
|
||||||
# Track successful initialization steps
|
# Track successful initialization steps
|
||||||
initialized = {
|
initialized = {
|
||||||
@@ -410,7 +412,7 @@ 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 (
|
from src.server.services.scheduler.scheduler_service import (
|
||||||
get_scheduler_service,
|
get_scheduler_service,
|
||||||
)
|
)
|
||||||
scheduler_service = get_scheduler_service()
|
scheduler_service = get_scheduler_service()
|
||||||
@@ -497,7 +499,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(
|
||||||
@@ -542,8 +546,8 @@ async def lifespan(_application: FastAPI):
|
|||||||
|
|
||||||
# 4. Shutdown download service and persist active downloads
|
# 4. Shutdown download service and persist active downloads
|
||||||
try:
|
try:
|
||||||
from src.server.services.download_service import ( # noqa: E501
|
from src.server.services.download_service import (
|
||||||
_download_service_instance,
|
_download_service_instance, # noqa: E501
|
||||||
)
|
)
|
||||||
if _download_service_instance is not None:
|
if _download_service_instance is not None:
|
||||||
logger.info("Stopping download service...")
|
logger.info("Stopping download service...")
|
||||||
@@ -600,11 +604,13 @@ async def lifespan(_application: FastAPI):
|
|||||||
raise startup_error
|
raise startup_error
|
||||||
|
|
||||||
|
|
||||||
|
from src.server.utils.version import APP_VERSION
|
||||||
|
|
||||||
# Initialize FastAPI app with lifespan
|
# Initialize FastAPI app with lifespan
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Aniworld Download Manager",
|
title="Aniworld Download Manager",
|
||||||
description="Modern web interface for Aniworld anime download management",
|
description="Modern web interface for Aniworld anime download management",
|
||||||
version="1.0.1",
|
version=APP_VERSION,
|
||||||
docs_url="/api/docs",
|
docs_url="/api/docs",
|
||||||
redoc_url="/api/redoc",
|
redoc_url="/api/redoc",
|
||||||
lifespan=lifespan
|
lifespan=lifespan
|
||||||
@@ -643,6 +649,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)
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -39,14 +39,14 @@ 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.",
|
||||||
)
|
)
|
||||||
# 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 +54,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
|
||||||
|
|||||||
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
|
||||||
@@ -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
|
||||||
@@ -774,55 +775,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__)
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ from typing import Optional
|
|||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from src.core.SeriesApp import SeriesApp
|
from src.server.SeriesApp import SeriesApp
|
||||||
from src.server.services.progress_service import (
|
from src.server.services.progress_service import (
|
||||||
ProgressService,
|
ProgressService,
|
||||||
ProgressType,
|
ProgressType,
|
||||||
@@ -942,47 +942,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 +1147,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 +1166,138 @@ 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.exception(
|
||||||
|
"Failed to rename folder for %s: %s -> %s",
|
||||||
|
key,
|
||||||
|
current_folder,
|
||||||
|
target_folder
|
||||||
|
)
|
||||||
|
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.
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
import structlog
|
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 = structlog.get_logger(__name__)
|
||||||
@@ -497,112 +496,25 @@ 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.
|
||||||
|
|
||||||
|
Note: NFO service has been removed. This method now just marks
|
||||||
|
progress as False since NFO handling moved to server layer.
|
||||||
|
|
||||||
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: Always False since NFO service removed
|
||||||
"""
|
"""
|
||||||
task.status = LoadingStatus.LOADING_NFO
|
task.status = LoadingStatus.LOADING_NFO
|
||||||
await self._broadcast_status(task, "Checking NFO file...")
|
await self._broadcast_status(task, "NFO loading disabled...")
|
||||||
|
|
||||||
try:
|
task.progress["nfo"] = False
|
||||||
# Check if NFOService is available
|
task.progress["logo"] = False
|
||||||
if not self.series_app.nfo_service:
|
task.progress["images"] = False
|
||||||
logger.warning(
|
return False
|
||||||
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:
|
|
||||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
|
||||||
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
|
|
||||||
task.progress["nfo"] = True
|
|
||||||
task.progress["logo"] = True
|
|
||||||
task.progress["images"] = True
|
|
||||||
|
|
||||||
# Update database
|
|
||||||
from src.server.database.service import AnimeSeriesService
|
|
||||||
series_db = await AnimeSeriesService.get_by_key(db, task.key)
|
|
||||||
if series_db:
|
|
||||||
series_db.has_nfo = True
|
|
||||||
series_db.nfo_created_at = datetime.now(timezone.utc)
|
|
||||||
series_db.logo_loaded = True
|
|
||||||
series_db.images_loaded = True
|
|
||||||
series_db.loading_status = "loading_nfo"
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
logger.info("NFO and images created and loaded for series: %s", task.key)
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Failed to load NFO/images for %s: %s", task.key, e)
|
|
||||||
# Don't fail the entire task if NFO fails
|
|
||||||
task.progress["nfo"] = False
|
|
||||||
task.progress["logo"] = False
|
|
||||||
task.progress["images"] = False
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _scan_missing_episodes(self, task: SeriesLoadingTask, db: Any) -> None:
|
async def _scan_missing_episodes(self, task: SeriesLoadingTask, db: Any) -> None:
|
||||||
"""Scan for missing episodes after NFO creation.
|
"""Scan for missing episodes after NFO creation.
|
||||||
|
|||||||
@@ -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_key(db, old_folder)
|
|
||||||
if series is None:
|
|
||||||
# Fallback: try to find by folder name
|
|
||||||
all_series = await AnimeSeriesService.get_all(db)
|
|
||||||
for s in all_series:
|
|
||||||
if s.folder == old_folder:
|
|
||||||
series = s
|
|
||||||
break
|
|
||||||
|
|
||||||
if series is None:
|
|
||||||
logger.warning(
|
|
||||||
"No database record found for folder '%s', skipping DB update",
|
|
||||||
old_folder,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
await AnimeSeriesService.update(db, series.id, folder=new_folder)
|
|
||||||
logger.info(
|
|
||||||
"Updated AnimeSeries.folder: %s → %s (id=%s)",
|
|
||||||
old_folder,
|
|
||||||
new_folder,
|
|
||||||
series.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. Update Episode.file_path for all episodes of this series
|
|
||||||
episodes = await EpisodeService.get_by_series(db, series.id)
|
|
||||||
for episode in episodes:
|
|
||||||
if episode.file_path:
|
|
||||||
old_file_path = Path(episode.file_path)
|
|
||||||
# Only update if the path is under the old series folder
|
|
||||||
try:
|
|
||||||
old_file_path.relative_to(old_series_path)
|
|
||||||
new_file_path = new_series_path / old_file_path.relative_to(
|
|
||||||
old_series_path
|
|
||||||
)
|
|
||||||
episode.file_path = str(new_file_path)
|
|
||||||
logger.debug(
|
|
||||||
"Updated Episode.file_path: %s → %s",
|
|
||||||
old_file_path,
|
|
||||||
new_file_path,
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
# Path is not under old_series_path, skip
|
|
||||||
pass
|
|
||||||
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
# 3. Update DownloadQueueItem.file_destination for pending items
|
|
||||||
queue_items = await DownloadQueueService.get_all(db, with_series=True)
|
|
||||||
for item in queue_items:
|
|
||||||
if item.series_id == series.id and item.file_destination:
|
|
||||||
old_dest = Path(item.file_destination)
|
|
||||||
try:
|
|
||||||
old_dest.relative_to(old_series_path)
|
|
||||||
new_dest = new_series_path / old_dest.relative_to(
|
|
||||||
old_series_path
|
|
||||||
)
|
|
||||||
item.file_destination = str(new_dest)
|
|
||||||
logger.debug(
|
|
||||||
"Updated DownloadQueueItem.file_destination: %s → %s",
|
|
||||||
old_dest,
|
|
||||||
new_dest,
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
await db.flush()
|
|
||||||
logger.info(
|
|
||||||
"Database paths updated for series '%s' → '%s'",
|
|
||||||
old_folder,
|
|
||||||
new_folder,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_and_rename_series_folders(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_key(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
|
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
"""Centralized initialization service for application startup and setup."""
|
"""Centralized initialization service for application startup and setup."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||||
from src.server.services.legacy_file_migration import migrate_series_from_files_to_db
|
from src.server.services.setup_service import SetupService
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
# Provider site URL constant
|
||||||
|
ANIMEWORLD_URL = "https://aniworld.to"
|
||||||
|
|
||||||
|
|
||||||
async def _check_scan_status(
|
async def _check_scan_status(
|
||||||
check_method: Callable,
|
check_method: Callable,
|
||||||
@@ -101,26 +106,6 @@ async def _mark_initial_scan_completed() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _check_legacy_migration_status() -> bool:
|
|
||||||
"""Check if legacy key/data file migration has been completed.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if migration was completed, False otherwise
|
|
||||||
"""
|
|
||||||
return await _check_scan_status(
|
|
||||||
check_method=lambda svc, db: svc.is_migration_legacy_files_completed(db),
|
|
||||||
scan_type="legacy_migration",
|
|
||||||
log_completed_msg="Legacy file migration already completed, skipping",
|
|
||||||
log_not_completed_msg="Legacy file migration not yet run, will check for files"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _mark_legacy_migration_completed() -> None:
|
|
||||||
"""Mark the legacy file migration as completed in system settings."""
|
|
||||||
await _mark_scan_completed(
|
|
||||||
mark_method=lambda svc, db: svc.mark_migration_legacy_files_completed(db),
|
|
||||||
scan_type="legacy_migration"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _check_legacy_key_cleanup_status() -> bool:
|
async def _check_legacy_key_cleanup_status() -> bool:
|
||||||
@@ -145,33 +130,6 @@ async def _mark_legacy_key_cleanup_completed() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _migrate_legacy_files() -> int:
|
|
||||||
"""Migrate series from legacy key/data files to database.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: Number of series migrated
|
|
||||||
"""
|
|
||||||
from src.server.database.connection import get_db_session
|
|
||||||
|
|
||||||
logger.info("Checking for legacy key/data files to migrate...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with get_db_session() as db:
|
|
||||||
migrated_count = await migrate_series_from_files_to_db(
|
|
||||||
settings.anime_directory,
|
|
||||||
db
|
|
||||||
)
|
|
||||||
|
|
||||||
if migrated_count > 0:
|
|
||||||
logger.info("Migrated %d series from legacy files", migrated_count)
|
|
||||||
else:
|
|
||||||
logger.info("No series found in legacy files to migrate")
|
|
||||||
|
|
||||||
return migrated_count
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to migrate legacy files: %s", e)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
async def _cleanup_legacy_key_files() -> int:
|
async def _cleanup_legacy_key_files() -> int:
|
||||||
@@ -299,6 +257,40 @@ async def _load_series_into_memory(progress_service=None) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _scan_folders_to_database(progress_service=None) -> int:
|
||||||
|
"""Scan anime folders and create AnimeSeries DB records.
|
||||||
|
|
||||||
|
This function runs during initial setup only. It delegates to
|
||||||
|
SetupService.run() which handles:
|
||||||
|
1. Iterates subdirectories of anime_directory
|
||||||
|
2. Extracts title/year from folder names (year via (YYYY) pattern)
|
||||||
|
3. Uses provider search to resolve key field when single match found
|
||||||
|
4. Creates AnimeSeries records for new folders
|
||||||
|
|
||||||
|
Args:
|
||||||
|
progress_service: Optional ProgressService for progress updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Number of new series created
|
||||||
|
"""
|
||||||
|
logger.info("Scanning anime folders for new series...")
|
||||||
|
|
||||||
|
if not settings.anime_directory or not os.path.isdir(settings.anime_directory):
|
||||||
|
logger.info(
|
||||||
|
"Anime directory not configured or does not exist, skipping folder scan"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Use SetupService to handle the scanning and creation
|
||||||
|
created_count = await SetupService.run()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Folder scan complete",
|
||||||
|
created=created_count
|
||||||
|
)
|
||||||
|
return created_count
|
||||||
|
|
||||||
|
|
||||||
async def _validate_anime_directory(progress_service=None) -> bool:
|
async def _validate_anime_directory(progress_service=None) -> bool:
|
||||||
"""Validate that anime directory is configured.
|
"""Validate that anime directory is configured.
|
||||||
|
|
||||||
@@ -373,11 +365,10 @@ async def perform_initial_setup(progress_service=None):
|
|||||||
|
|
||||||
# Perform the actual initialization
|
# Perform the actual initialization
|
||||||
try:
|
try:
|
||||||
# First, run legacy file migration if needed (independent of initial scan)
|
# Scan folders and create AnimeSeries records first
|
||||||
is_legacy_migration_done = await _check_legacy_migration_status()
|
folder_scan_count = await _scan_folders_to_database(progress_service)
|
||||||
if not is_legacy_migration_done:
|
if folder_scan_count > 0:
|
||||||
await _migrate_legacy_files()
|
logger.info("Created %d series from anime folders", folder_scan_count)
|
||||||
await _mark_legacy_migration_completed()
|
|
||||||
|
|
||||||
# Sync series from anime folders to database
|
# Sync series from anime folders to database
|
||||||
await _sync_anime_folders(progress_service)
|
await _sync_anime_folders(progress_service)
|
||||||
@@ -436,44 +427,13 @@ async def _is_nfo_scan_configured() -> bool:
|
|||||||
async def _execute_nfo_scan(progress_service=None) -> None:
|
async def _execute_nfo_scan(progress_service=None) -> None:
|
||||||
"""Execute the actual NFO scan with TMDB data.
|
"""Execute the actual NFO scan with TMDB data.
|
||||||
|
|
||||||
|
Note: NFO service removed. This function is now a no-op stub.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
progress_service: Optional ProgressService for progress updates
|
progress_service: Unused. Kept to avoid breaking call-sites.
|
||||||
|
|
||||||
Raises:
|
|
||||||
Exception: If NFO scan fails
|
|
||||||
"""
|
"""
|
||||||
from src.core.services.series_manager_service import SeriesManagerService
|
logger.info("NFO scan skipped — NFO service removed")
|
||||||
|
return
|
||||||
logger.info("Performing initial NFO scan...")
|
|
||||||
|
|
||||||
if progress_service:
|
|
||||||
await progress_service.update_progress(
|
|
||||||
progress_id="nfo_scan",
|
|
||||||
current=25,
|
|
||||||
message="Scanning series for NFO files...",
|
|
||||||
metadata={"step_id": "nfo_scan"}
|
|
||||||
)
|
|
||||||
|
|
||||||
manager = SeriesManagerService.from_settings()
|
|
||||||
|
|
||||||
if progress_service:
|
|
||||||
await progress_service.update_progress(
|
|
||||||
progress_id="nfo_scan",
|
|
||||||
current=50,
|
|
||||||
message="Processing NFO files with TMDB data...",
|
|
||||||
metadata={"step_id": "nfo_scan"}
|
|
||||||
)
|
|
||||||
|
|
||||||
await manager.scan_and_process_nfo()
|
|
||||||
await manager.close()
|
|
||||||
logger.info("Initial NFO scan completed")
|
|
||||||
|
|
||||||
if progress_service:
|
|
||||||
await progress_service.complete_progress(
|
|
||||||
progress_id="nfo_scan",
|
|
||||||
message="NFO scan completed successfully",
|
|
||||||
metadata={"step_id": "nfo_scan"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def perform_nfo_scan_if_needed(progress_service=None):
|
async def perform_nfo_scan_if_needed(progress_service=None):
|
||||||
|
|||||||
@@ -1,317 +0,0 @@
|
|||||||
"""Key resolution service for orphaned anime folders.
|
|
||||||
|
|
||||||
Attempts to resolve provider keys for anime folders that have no key/data
|
|
||||||
file and no database entry, by searching the anime provider and matching
|
|
||||||
folder names to search results.
|
|
||||||
|
|
||||||
This service runs after nfo_repair_service during the daily folder scan.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import structlog
|
|
||||||
|
|
||||||
from src.config.settings import settings as _settings
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
# Limit concurrent provider searches to avoid rate-limiting.
|
|
||||||
_SEARCH_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(2)
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_year_from_folder(folder_name: str) -> str:
|
|
||||||
"""Remove trailing year suffix like ' (2020)' from folder name.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
folder_name: Folder name, e.g. 'Rent-A-Girlfriend (2020)'
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Name without year, e.g. 'Rent-A-Girlfriend'
|
|
||||||
"""
|
|
||||||
return re.sub(r"\s*\(\d{4}\)\s*$", "", folder_name).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_year_from_folder(folder_name: str) -> Optional[int]:
|
|
||||||
"""Extract year from folder name like 'Anime Name (2020)'.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Year as int or None if not present.
|
|
||||||
"""
|
|
||||||
match = re.search(r"\((\d{4})\)$", folder_name.strip())
|
|
||||||
if match:
|
|
||||||
return int(match.group(1))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_key_from_link(link: str) -> Optional[str]:
|
|
||||||
"""Extract provider key from search result link.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
link: Link like '/anime/stream/rent-a-girlfriend' or full URL.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Key slug like 'rent-a-girlfriend' or None.
|
|
||||||
"""
|
|
||||||
if not link:
|
|
||||||
return None
|
|
||||||
if "/anime/stream/" in link:
|
|
||||||
parts = link.split("/anime/stream/")[-1].split("/")
|
|
||||||
key = parts[0].strip()
|
|
||||||
return key if key else None
|
|
||||||
# If link is just a slug
|
|
||||||
if "/" not in link and link.strip():
|
|
||||||
return link.strip()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_for_comparison(text: str) -> str:
|
|
||||||
"""Normalize text for case-insensitive comparison.
|
|
||||||
|
|
||||||
Strips whitespace, lowercases, and removes common punctuation
|
|
||||||
differences that shouldn't affect matching.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Raw text string.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Normalized lowercase string.
|
|
||||||
"""
|
|
||||||
normalized = text.strip().lower()
|
|
||||||
# Remove common punctuation that varies between sources
|
|
||||||
normalized = re.sub(r"[:\-–—]", " ", normalized)
|
|
||||||
# Collapse multiple spaces
|
|
||||||
normalized = re.sub(r"\s+", " ", normalized)
|
|
||||||
return normalized.strip()
|
|
||||||
|
|
||||||
|
|
||||||
async def resolve_key_for_folder(folder_name: str) -> Optional[str]:
|
|
||||||
"""Attempt to resolve the provider key for a single folder.
|
|
||||||
|
|
||||||
Strategy:
|
|
||||||
1. Strip year suffix from folder name to get search query.
|
|
||||||
2. Search the anime provider with that query.
|
|
||||||
3. If exactly ONE result matches the folder name (case-insensitive),
|
|
||||||
return the key extracted from the result link.
|
|
||||||
4. If zero or multiple matches, return None (not confident enough).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
folder_name: The anime folder name, e.g. 'Rent-A-Girlfriend (2020)'.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The provider key string, or None if resolution is not confident.
|
|
||||||
"""
|
|
||||||
search_query = _strip_year_from_folder(folder_name)
|
|
||||||
if not search_query:
|
|
||||||
logger.debug("Empty search query after stripping year from '%s'", folder_name)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async with _SEARCH_SEMAPHORE:
|
|
||||||
try:
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
results = await loop.run_in_executor(None, _search_provider, search_query)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Provider search failed for '%s': %s", search_query, exc
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
logger.debug("No search results for folder '%s'", folder_name)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Filter results: find exact name matches (case-insensitive)
|
|
||||||
normalized_query = _normalize_for_comparison(search_query)
|
|
||||||
exact_matches = []
|
|
||||||
|
|
||||||
for result in results:
|
|
||||||
title = result.get("title") or result.get("name") or ""
|
|
||||||
normalized_title = _normalize_for_comparison(title)
|
|
||||||
|
|
||||||
if normalized_title == normalized_query:
|
|
||||||
key = _extract_key_from_link(result.get("link", ""))
|
|
||||||
if key:
|
|
||||||
exact_matches.append((key, title))
|
|
||||||
|
|
||||||
if len(exact_matches) == 1:
|
|
||||||
resolved_key, matched_title = exact_matches[0]
|
|
||||||
logger.info(
|
|
||||||
"Resolved key for folder '%s': key='%s' (matched title: '%s')",
|
|
||||||
folder_name,
|
|
||||||
resolved_key,
|
|
||||||
matched_title,
|
|
||||||
)
|
|
||||||
return resolved_key
|
|
||||||
|
|
||||||
if len(exact_matches) > 1:
|
|
||||||
logger.info(
|
|
||||||
"Multiple exact matches for folder '%s' (%d matches), skipping",
|
|
||||||
folder_name,
|
|
||||||
len(exact_matches),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.debug(
|
|
||||||
"No exact title match for folder '%s' in %d results",
|
|
||||||
folder_name,
|
|
||||||
len(results),
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _search_provider(query: str) -> list:
|
|
||||||
"""Call the anime provider search synchronously.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search term.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of search result dicts with 'link' and 'title'/'name' fields.
|
|
||||||
"""
|
|
||||||
from src.core.providers.provider_factory import Loaders
|
|
||||||
|
|
||||||
loader = Loaders().GetLoader("aniworld.to")
|
|
||||||
return loader.search(query)
|
|
||||||
|
|
||||||
|
|
||||||
async def perform_key_resolution_scan() -> dict[str, int]:
|
|
||||||
"""Scan all anime folders and resolve missing keys.
|
|
||||||
|
|
||||||
Iterates over all subfolders of the anime directory. For each folder
|
|
||||||
that has no corresponding database entry, attempts to resolve the
|
|
||||||
provider key via provider search and saves it to the database.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with counts:
|
|
||||||
- 'scanned': total folders checked
|
|
||||||
- 'resolved': keys successfully resolved and saved
|
|
||||||
- 'skipped': folders already in DB or resolution uncertain
|
|
||||||
- 'errors': folders that caused errors during resolution
|
|
||||||
"""
|
|
||||||
from src.server.database.connection import get_db_session
|
|
||||||
from src.server.database.service import AnimeSeriesService
|
|
||||||
|
|
||||||
stats = {"scanned": 0, "resolved": 0, "skipped": 0, "errors": 0}
|
|
||||||
|
|
||||||
if not _settings.anime_directory:
|
|
||||||
logger.warning("Key resolution scan skipped — anime directory not configured")
|
|
||||||
return stats
|
|
||||||
|
|
||||||
anime_dir = Path(_settings.anime_directory)
|
|
||||||
if not anime_dir.is_dir():
|
|
||||||
logger.warning(
|
|
||||||
"Key resolution scan skipped — anime directory not found: %s",
|
|
||||||
anime_dir,
|
|
||||||
)
|
|
||||||
return stats
|
|
||||||
|
|
||||||
# Collect folders that need resolution
|
|
||||||
folders_to_resolve: list[str] = []
|
|
||||||
|
|
||||||
async with get_db_session() as db:
|
|
||||||
for series_dir in sorted(anime_dir.iterdir()):
|
|
||||||
if not series_dir.is_dir():
|
|
||||||
continue
|
|
||||||
folder_name = series_dir.name
|
|
||||||
stats["scanned"] += 1
|
|
||||||
|
|
||||||
# Check if already in database
|
|
||||||
existing = await AnimeSeriesService.get_by_folder(db, folder_name)
|
|
||||||
if existing:
|
|
||||||
stats["skipped"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
folders_to_resolve.append(folder_name)
|
|
||||||
|
|
||||||
if not folders_to_resolve:
|
|
||||||
logger.info("Key resolution scan: all folders already have DB entries")
|
|
||||||
return stats
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Key resolution scan: %d folders need resolution", len(folders_to_resolve)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Resolve keys one by one (provider search is rate-limited)
|
|
||||||
for folder_name in folders_to_resolve:
|
|
||||||
try:
|
|
||||||
key = await resolve_key_for_folder(folder_name)
|
|
||||||
if key:
|
|
||||||
# Save to database
|
|
||||||
await _save_resolved_key(folder_name, key)
|
|
||||||
stats["resolved"] += 1
|
|
||||||
else:
|
|
||||||
stats["skipped"] += 1
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error(
|
|
||||||
"Error resolving key for folder '%s': %s",
|
|
||||||
folder_name,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
stats["errors"] += 1
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Key resolution scan complete: scanned=%d, resolved=%d, skipped=%d, errors=%d",
|
|
||||||
stats["scanned"],
|
|
||||||
stats["resolved"],
|
|
||||||
stats["skipped"],
|
|
||||||
stats["errors"],
|
|
||||||
)
|
|
||||||
return stats
|
|
||||||
|
|
||||||
|
|
||||||
async def _save_resolved_key(folder_name: str, key: str) -> None:
|
|
||||||
"""Save a resolved key to the database.
|
|
||||||
|
|
||||||
Creates a new AnimeSeries entry with the resolved key and folder name.
|
|
||||||
Does NOT write any key/data file to disk.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
folder_name: The anime folder name (e.g. 'Rent-A-Girlfriend (2020)').
|
|
||||||
key: The resolved provider key (e.g. 'rent-a-girlfriend').
|
|
||||||
"""
|
|
||||||
from src.server.database.connection import get_db_session
|
|
||||||
from src.server.database.service import AnimeSeriesService
|
|
||||||
|
|
||||||
name = _strip_year_from_folder(folder_name)
|
|
||||||
year = _extract_year_from_folder(folder_name)
|
|
||||||
|
|
||||||
async with get_db_session() as db:
|
|
||||||
# Double-check: another task might have resolved it concurrently
|
|
||||||
existing = await AnimeSeriesService.get_by_folder(db, folder_name)
|
|
||||||
if existing:
|
|
||||||
logger.debug(
|
|
||||||
"Folder '%s' already in DB (resolved concurrently), skipping",
|
|
||||||
folder_name,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Also check if a series with this key already exists
|
|
||||||
existing_key = await AnimeSeriesService.get_by_key(db, key)
|
|
||||||
if existing_key:
|
|
||||||
logger.warning(
|
|
||||||
"Key '%s' already exists in DB for folder '%s', "
|
|
||||||
"cannot assign to folder '%s'",
|
|
||||||
key,
|
|
||||||
existing_key.folder,
|
|
||||||
folder_name,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
await AnimeSeriesService.create(
|
|
||||||
db,
|
|
||||||
key=key,
|
|
||||||
name=name,
|
|
||||||
site="aniworld.to",
|
|
||||||
folder=folder_name,
|
|
||||||
year=year,
|
|
||||||
loading_status="pending",
|
|
||||||
episodes_loaded=False,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Saved resolved key '%s' for folder '%s' to database",
|
|
||||||
key,
|
|
||||||
folder_name,
|
|
||||||
)
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
"""One-time migration service for legacy key and data files.
|
|
||||||
|
|
||||||
This module provides functionality to migrate series data from legacy
|
|
||||||
file-based storage (key/data files) to the database. The migration is
|
|
||||||
designed to be idempotent and run only once per environment.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import structlog
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def migrate_series_from_files_to_db(
|
|
||||||
anime_dir: str,
|
|
||||||
db: AsyncSession,
|
|
||||||
) -> int:
|
|
||||||
"""Migrate series from legacy key/data files to database.
|
|
||||||
|
|
||||||
Scans for folders containing legacy 'key' or 'data' files and imports
|
|
||||||
any series not already in the database. The DB version wins if a series
|
|
||||||
exists in both places.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
anime_dir: Path to the anime directory
|
|
||||||
db: Database session
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of series imported
|
|
||||||
"""
|
|
||||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
|
||||||
|
|
||||||
if not anime_dir or not os.path.isdir(anime_dir):
|
|
||||||
logger.warning(
|
|
||||||
"Anime directory does not exist, skipping legacy migration",
|
|
||||||
anime_dir=anime_dir
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
migrated_count = 0
|
|
||||||
scanned_count = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
for folder_name in os.listdir(anime_dir):
|
|
||||||
folder_path = os.path.join(anime_dir, folder_name)
|
|
||||||
|
|
||||||
if not os.path.isdir(folder_path):
|
|
||||||
continue
|
|
||||||
|
|
||||||
scanned_count += 1
|
|
||||||
|
|
||||||
# Check for 'key' file (single line with series key)
|
|
||||||
key_file = os.path.join(folder_path, "key")
|
|
||||||
# Check for 'data' file (JSON with series metadata)
|
|
||||||
data_file = os.path.join(folder_path, "data")
|
|
||||||
|
|
||||||
series_data: Optional[dict] = None
|
|
||||||
|
|
||||||
# Try to load from 'data' file first (more complete)
|
|
||||||
if os.path.isfile(data_file):
|
|
||||||
series_data = _load_data_file(data_file)
|
|
||||||
elif os.path.isfile(key_file):
|
|
||||||
# Fall back to 'key' file - just the key, need to infer other data
|
|
||||||
series_data = _load_key_file(key_file, folder_name)
|
|
||||||
|
|
||||||
if series_data is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
key = series_data.get("key")
|
|
||||||
if not key:
|
|
||||||
logger.warning(
|
|
||||||
"Skipping folder with no valid key",
|
|
||||||
folder=folder_name
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if already in DB
|
|
||||||
existing = await AnimeSeriesService.get_by_key(db, key)
|
|
||||||
if existing:
|
|
||||||
logger.debug(
|
|
||||||
"Series already in database, skipping",
|
|
||||||
key=key,
|
|
||||||
folder=folder_name
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Create the series in DB
|
|
||||||
try:
|
|
||||||
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")
|
|
||||||
|
|
||||||
anime_series = await AnimeSeriesService.create(
|
|
||||||
db=db,
|
|
||||||
key=key,
|
|
||||||
name=name,
|
|
||||||
site=site,
|
|
||||||
folder=folder,
|
|
||||||
year=year,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create episodes if present
|
|
||||||
episode_dict = series_data.get("episodeDict", {})
|
|
||||||
if episode_dict:
|
|
||||||
for season, episode_numbers in episode_dict.items():
|
|
||||||
for episode_number in episode_numbers:
|
|
||||||
await EpisodeService.create(
|
|
||||||
db=db,
|
|
||||||
series_id=anime_series.id,
|
|
||||||
season=season,
|
|
||||||
episode_number=episode_number,
|
|
||||||
)
|
|
||||||
|
|
||||||
migrated_count += 1
|
|
||||||
logger.info(
|
|
||||||
"Migrated series from legacy file",
|
|
||||||
key=key,
|
|
||||||
name=name,
|
|
||||||
folder=folder_name
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to migrate series from legacy file",
|
|
||||||
key=key,
|
|
||||||
folder=folder_name,
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Legacy migration failed",
|
|
||||||
anime_dir=anime_dir,
|
|
||||||
error=str(e),
|
|
||||||
exc_info=True
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Legacy file migration complete",
|
|
||||||
scanned_folders=scanned_count,
|
|
||||||
migrated=migrated_count
|
|
||||||
)
|
|
||||||
return migrated_count
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
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",
|
|
||||||
file=data_file_path
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Ensure episodeDict has int keys
|
|
||||||
if "episodeDict" in data and isinstance(data["episodeDict"], dict):
|
|
||||||
data["episodeDict"] = {
|
|
||||||
int(k): v for k, v in data["episodeDict"].items()
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to parse legacy data file (JSON error)",
|
|
||||||
file=data_file_path,
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to read legacy data file",
|
|
||||||
file=data_file_path,
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _load_key_file(key_file_path: str, folder_name: str) -> Optional[dict]:
|
|
||||||
"""Load a legacy 'key' file (single line with series key).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key_file_path: Path to the key file
|
|
||||||
folder_name: Folder name to use as fallback name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Data dict with key and inferred fields, or None if loading fails
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with open(key_file_path, "r", encoding="utf-8") as f:
|
|
||||||
key = f.read().strip()
|
|
||||||
|
|
||||||
if not key:
|
|
||||||
logger.warning(
|
|
||||||
"Key file is empty",
|
|
||||||
file=key_file_path
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Infer basic data from key file
|
|
||||||
return {
|
|
||||||
"key": key,
|
|
||||||
"name": folder_name,
|
|
||||||
"site": "https://aniworld.to",
|
|
||||||
"folder": folder_name,
|
|
||||||
"episodeDict": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to read legacy key file",
|
|
||||||
file=key_file_path,
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
651
src/server/services/nfo_scan_service.py
Normal file
651
src/server/services/nfo_scan_service.py
Normal file
@@ -0,0 +1,651 @@
|
|||||||
|
"""NFO scan service for validating and creating tvshow.nfo files.
|
||||||
|
|
||||||
|
This module provides a service layer for scanning the anime library,
|
||||||
|
checking whether each series has a valid tvshow.nfo file, creating
|
||||||
|
missing files, and filling in missing properties from TMDB metadata.
|
||||||
|
|
||||||
|
All series are identified by 'key' (provider-assigned, URL-safe
|
||||||
|
identifier). 'folder' is used as metadata only for filesystem paths.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from src.config.settings import settings
|
||||||
|
from src.server.nfo.nfo_generator import generate_tvshow_nfo
|
||||||
|
from src.server.nfo.nfo_mapper import tmdb_to_nfo_model
|
||||||
|
from src.server.nfo.nfo_models import TVShowNFO
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NfoScanServiceError(Exception):
|
||||||
|
"""Service-level exception for NFO scan operations."""
|
||||||
|
|
||||||
|
|
||||||
|
class NfoScanProgress:
|
||||||
|
"""Tracks the current state of an NFO scan operation.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
scan_id: Unique identifier for this scan
|
||||||
|
status: Current status (started, in_progress, completed, failed, cancelled)
|
||||||
|
total: Total number of series to scan
|
||||||
|
current: Number of series processed
|
||||||
|
percentage: Completion percentage
|
||||||
|
message: Human-readable progress message
|
||||||
|
key: Current series key being processed (metadata only)
|
||||||
|
folder: Current series folder being processed (metadata only)
|
||||||
|
created: Number of NFO files created
|
||||||
|
updated: Number of NFO files updated
|
||||||
|
errors: List of error messages encountered
|
||||||
|
started_at: When the scan started
|
||||||
|
updated_at: When progress was last updated
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, scan_id: str):
|
||||||
|
self.scan_id = scan_id
|
||||||
|
self.status = "started"
|
||||||
|
self.total = 0
|
||||||
|
self.current = 0
|
||||||
|
self.percentage = 0.0
|
||||||
|
self.message = "Initializing NFO scan..."
|
||||||
|
self.key: Optional[str] = None
|
||||||
|
self.folder: Optional[str] = None
|
||||||
|
self.started_at = datetime.now(timezone.utc)
|
||||||
|
self.updated_at = datetime.now(timezone.utc)
|
||||||
|
self.created = 0
|
||||||
|
self.updated = 0
|
||||||
|
self.errors: List[str] = []
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
result = {
|
||||||
|
"scan_id": self.scan_id,
|
||||||
|
"status": self.status,
|
||||||
|
"total": self.total,
|
||||||
|
"current": self.current,
|
||||||
|
"percentage": round(self.percentage, 2),
|
||||||
|
"message": self.message,
|
||||||
|
"started_at": self.started_at.isoformat(),
|
||||||
|
"updated_at": self.updated_at.isoformat(),
|
||||||
|
"created": self.created,
|
||||||
|
"updated": self.updated,
|
||||||
|
"errors": self.errors,
|
||||||
|
}
|
||||||
|
if self.key is not None:
|
||||||
|
result["key"] = self.key
|
||||||
|
if self.folder is not None:
|
||||||
|
result["folder"] = self.folder
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class NfoScanService:
|
||||||
|
"""Manages NFO validation and creation for anime series.
|
||||||
|
|
||||||
|
Scans the anime library directory, checks each series folder for
|
||||||
|
a tvshow.nfo file, creates missing files, and fills in missing
|
||||||
|
or empty properties from TMDB metadata.
|
||||||
|
|
||||||
|
Uses 'key' as the primary series identifier and 'folder' as
|
||||||
|
metadata only for filesystem operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._current_scan: Optional[NfoScanProgress] = None
|
||||||
|
self._is_scanning = False
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# Event handlers for scan events
|
||||||
|
self._scan_event_handlers: List[Callable[[Dict[str, Any]], None]] = []
|
||||||
|
|
||||||
|
logger.info("NfoScanService initialized")
|
||||||
|
|
||||||
|
def subscribe_to_scan_events(
|
||||||
|
self,
|
||||||
|
handler: Callable[[Dict[str, Any]], None],
|
||||||
|
) -> None:
|
||||||
|
"""Subscribe to NFO scan events."""
|
||||||
|
self._scan_event_handlers.append(handler)
|
||||||
|
|
||||||
|
def unsubscribe_from_scan_events(
|
||||||
|
self,
|
||||||
|
handler: Callable[[Dict[str, Any]], None],
|
||||||
|
) -> None:
|
||||||
|
"""Unsubscribe from NFO scan events."""
|
||||||
|
try:
|
||||||
|
self._scan_event_handlers.remove(handler)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Handler not found for unsubscribe")
|
||||||
|
|
||||||
|
async def _emit_scan_event(self, event_data: Dict[str, Any]) -> None:
|
||||||
|
"""Emit scan event to all subscribers."""
|
||||||
|
for handler in self._scan_event_handlers:
|
||||||
|
try:
|
||||||
|
if asyncio.iscoroutinefunction(handler):
|
||||||
|
await handler(event_data)
|
||||||
|
else:
|
||||||
|
handler(event_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("NFO scan event handler error", error=str(e))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_scanning(self) -> bool:
|
||||||
|
return self._is_scanning
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_scan(self) -> Optional[NfoScanProgress]:
|
||||||
|
return self._current_scan
|
||||||
|
|
||||||
|
async def scan_all(
|
||||||
|
self,
|
||||||
|
anime_service: Any, # AnimeService instance
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Run NFO validation and creation across all series.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_service: AnimeService instance for accessing series data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Summary dict with keys: total, created, updated, errors_count,
|
||||||
|
scan_id, and duration_seconds.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NfoScanServiceError: If a scan is already in progress.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
if self._is_scanning:
|
||||||
|
raise NfoScanServiceError("An NFO scan is already in progress")
|
||||||
|
self._is_scanning = True
|
||||||
|
|
||||||
|
scan_id = f"nfo_scan_{id(self)}"
|
||||||
|
scan_progress = NfoScanProgress(scan_id)
|
||||||
|
self._current_scan = scan_progress
|
||||||
|
|
||||||
|
logger.info("Starting NFO scan")
|
||||||
|
|
||||||
|
# Emit scan started
|
||||||
|
await self._emit_scan_event({
|
||||||
|
"type": "nfo_scan_started",
|
||||||
|
"scan_id": scan_id,
|
||||||
|
"message": "NFO scan started",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get all series from AnimeService
|
||||||
|
try:
|
||||||
|
series_list = await anime_service.list_series_with_filters()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to get series list: %s", exc)
|
||||||
|
async with self._lock:
|
||||||
|
self._is_scanning = False
|
||||||
|
raise NfoScanServiceError(f"Failed to get series list: {exc}") from exc
|
||||||
|
|
||||||
|
if not series_list:
|
||||||
|
logger.info("No series found — NFO scan complete")
|
||||||
|
scan_progress.status = "completed"
|
||||||
|
scan_progress.message = "No series found"
|
||||||
|
scan_progress.percentage = 100.0
|
||||||
|
scan_progress.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
self._is_scanning = False
|
||||||
|
|
||||||
|
await self._emit_scan_event({
|
||||||
|
"type": "nfo_scan_completed",
|
||||||
|
"scan_id": scan_id,
|
||||||
|
"success": True,
|
||||||
|
"message": "No series found",
|
||||||
|
"data": scan_progress.to_dict(),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"total": 0,
|
||||||
|
"created": 0,
|
||||||
|
"updated": 0,
|
||||||
|
"errors_count": 0,
|
||||||
|
"scan_id": scan_id,
|
||||||
|
"duration_seconds": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
scan_progress.total = len(series_list)
|
||||||
|
scan_progress.status = "in_progress"
|
||||||
|
scan_progress.message = f"Scanning {scan_progress.total} series..."
|
||||||
|
scan_progress.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
start_time = datetime.now(timezone.utc)
|
||||||
|
errors: List[str] = []
|
||||||
|
|
||||||
|
for idx, series in enumerate(series_list):
|
||||||
|
key = series.get("key", "")
|
||||||
|
folder = series.get("folder", "")
|
||||||
|
name = series.get("name", "")
|
||||||
|
|
||||||
|
scan_progress.key = key
|
||||||
|
scan_progress.folder = folder
|
||||||
|
scan_progress.message = f"Scanning: {name}"
|
||||||
|
scan_progress.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
await self._emit_scan_event({
|
||||||
|
"type": "nfo_scan_progress",
|
||||||
|
"data": scan_progress.to_dict(),
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self._scan_series(key, folder, series)
|
||||||
|
if result == "created":
|
||||||
|
scan_progress.created += 1
|
||||||
|
elif result == "updated":
|
||||||
|
scan_progress.updated += 1
|
||||||
|
except Exception as exc:
|
||||||
|
error_msg = f"NFO scan failed for {key}: {exc}"
|
||||||
|
logger.warning(error_msg)
|
||||||
|
errors.append(error_msg)
|
||||||
|
scan_progress.errors.append(error_msg)
|
||||||
|
|
||||||
|
scan_progress.current = idx + 1
|
||||||
|
scan_progress.percentage = round(
|
||||||
|
(scan_progress.current / scan_progress.total) * 100, 2
|
||||||
|
)
|
||||||
|
scan_progress.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
end_time = datetime.now(timezone.utc)
|
||||||
|
duration = (end_time - start_time).total_seconds()
|
||||||
|
scan_progress.status = "completed"
|
||||||
|
scan_progress.message = (
|
||||||
|
f"NFO scan completed: {scan_progress.created} created, "
|
||||||
|
f"{scan_progress.updated} updated, {len(errors)} errors"
|
||||||
|
)
|
||||||
|
scan_progress.percentage = 100.0
|
||||||
|
scan_progress.updated_at = end_time
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
self._is_scanning = False
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"NFO scan completed: total=%d created=%d updated=%d errors=%d duration=%.2fs",
|
||||||
|
scan_progress.total,
|
||||||
|
scan_progress.created,
|
||||||
|
scan_progress.updated,
|
||||||
|
len(errors),
|
||||||
|
duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._emit_scan_event({
|
||||||
|
"type": "nfo_scan_completed",
|
||||||
|
"scan_id": scan_id,
|
||||||
|
"success": True,
|
||||||
|
"message": scan_progress.message,
|
||||||
|
"data": scan_progress.to_dict(),
|
||||||
|
"statistics": {
|
||||||
|
"total": scan_progress.total,
|
||||||
|
"created": scan_progress.created,
|
||||||
|
"updated": scan_progress.updated,
|
||||||
|
"errors_count": len(errors),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": scan_progress.total,
|
||||||
|
"created": scan_progress.created,
|
||||||
|
"updated": scan_progress.updated,
|
||||||
|
"errors_count": len(errors),
|
||||||
|
"scan_id": scan_id,
|
||||||
|
"duration_seconds": round(duration, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _scan_series(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
folder: str,
|
||||||
|
series_data: Dict[str, Any],
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Scan and update NFO for a single series.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Series key (primary identifier)
|
||||||
|
folder: Series folder name (metadata for filesystem path)
|
||||||
|
series_data: Series data dict from anime_service
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
"created" if new NFO was created, "updated" if existing was
|
||||||
|
modified, None if no change needed or error occurred.
|
||||||
|
"""
|
||||||
|
if not folder:
|
||||||
|
logger.debug("Skipping series with no folder: key=%s", key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
anime_dir = getattr(settings, "anime_directory", None)
|
||||||
|
if not anime_dir:
|
||||||
|
logger.warning("anime_directory not configured — skipping NFO scan")
|
||||||
|
return None
|
||||||
|
|
||||||
|
series_path = os.path.join(anime_dir, folder)
|
||||||
|
nfo_path = os.path.join(series_path, "tvshow.nfo")
|
||||||
|
|
||||||
|
nfo_exists = os.path.isfile(nfo_path)
|
||||||
|
|
||||||
|
if not nfo_exists:
|
||||||
|
# Create new NFO
|
||||||
|
logger.info("Creating NFO for series: %s (%s)", key, folder)
|
||||||
|
await self._create_nfo(key, folder, series_data, nfo_path)
|
||||||
|
await self._update_series_nfo_flag(key, has_nfo=True, nfo_path=nfo_path)
|
||||||
|
return "created"
|
||||||
|
|
||||||
|
# NFO exists — check if it needs updating
|
||||||
|
updated = await self._update_nfo_if_needed(key, folder, series_data, nfo_path)
|
||||||
|
if updated:
|
||||||
|
await self._update_series_nfo_flag(key, has_nfo=True, nfo_path=nfo_path)
|
||||||
|
return "updated"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _create_nfo(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
folder: str,
|
||||||
|
series_data: Dict[str, Any],
|
||||||
|
nfo_path: str,
|
||||||
|
) -> None:
|
||||||
|
"""Create a new tvshow.nfo file from TMDB metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Series key
|
||||||
|
folder: Series folder name
|
||||||
|
series_data: Series data from anime_service
|
||||||
|
nfo_path: Full path to the NFO file to create
|
||||||
|
"""
|
||||||
|
tmdb_id = series_data.get("tmdb_id")
|
||||||
|
|
||||||
|
if not tmdb_id:
|
||||||
|
logger.warning(
|
||||||
|
"Cannot create NFO for %s: no tmdb_id available",
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
tmdb_data = await self._fetch_tmdb_data(tmdb_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to fetch TMDB data for %s: %s", key, exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not tmdb_data:
|
||||||
|
logger.warning("No TMDB data for %s", key)
|
||||||
|
return
|
||||||
|
|
||||||
|
nfo_model = tmdb_to_nfo_model(
|
||||||
|
tmdb_data,
|
||||||
|
content_ratings=None,
|
||||||
|
get_image_url=self._make_tmdb_image_url(tmdb_id),
|
||||||
|
image_size="original",
|
||||||
|
)
|
||||||
|
|
||||||
|
xml_content = generate_tvshow_nfo(nfo_model)
|
||||||
|
|
||||||
|
# Ensure directory exists
|
||||||
|
os.makedirs(os.path.dirname(nfo_path), exist_ok=True)
|
||||||
|
|
||||||
|
with open(nfo_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(xml_content)
|
||||||
|
|
||||||
|
logger.info("Created tvshow.nfo for %s at %s", key, nfo_path)
|
||||||
|
|
||||||
|
await self._emit_scan_event({
|
||||||
|
"type": "nfo_created",
|
||||||
|
"key": key,
|
||||||
|
"folder": folder,
|
||||||
|
"path": nfo_path,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _update_nfo_if_needed(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
folder: str,
|
||||||
|
series_data: Dict[str, Any],
|
||||||
|
nfo_path: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Load existing NFO, check for missing fields, fill and rewrite.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Series key
|
||||||
|
folder: Series folder name
|
||||||
|
series_data: Series data from anime_service
|
||||||
|
nfo_path: Full path to the existing NFO file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if NFO was updated, False if no changes were needed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from lxml import etree
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("lxml not available — cannot update existing NFO files")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = etree.parse(nfo_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to parse existing NFO for %s: %s — will regenerate", key, exc)
|
||||||
|
# Corrupt or unreadable NFO — regenerate from TMDB
|
||||||
|
return await self._regenerate_nfo(key, folder, series_data, nfo_path)
|
||||||
|
|
||||||
|
# Check for missing or empty critical fields
|
||||||
|
critical_fields = ["title", "plot", "premiered", "tmdbid"]
|
||||||
|
missing_fields: List[str] = []
|
||||||
|
|
||||||
|
for field in critical_fields:
|
||||||
|
elem = root.find(field)
|
||||||
|
if elem is None or not elem.text or elem.text.strip() == "":
|
||||||
|
missing_fields.append(field)
|
||||||
|
|
||||||
|
if not missing_fields:
|
||||||
|
logger.debug("NFO for %s is complete — no update needed", key)
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"NFO for %s is missing fields %s — attempting to fill from TMDB",
|
||||||
|
key,
|
||||||
|
missing_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to fill missing fields from TMDB
|
||||||
|
tmdb_id = series_data.get("tmdb_id")
|
||||||
|
if not tmdb_id:
|
||||||
|
logger.warning("Cannot update NFO for %s: no tmdb_id", key)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
tmdb_data = await self._fetch_tmdb_data(tmdb_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to fetch TMDB data for %s: %s", key, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not tmdb_data:
|
||||||
|
return False
|
||||||
|
|
||||||
|
nfo_model = tmdb_to_nfo_model(
|
||||||
|
tmdb_data,
|
||||||
|
content_ratings=None,
|
||||||
|
get_image_url=self._make_tmdb_image_url(tmdb_id),
|
||||||
|
image_size="original",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Serialize updated model to XML and write
|
||||||
|
xml_content = generate_tvshow_nfo(nfo_model)
|
||||||
|
|
||||||
|
with open(nfo_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(xml_content)
|
||||||
|
|
||||||
|
logger.info("Updated NFO for %s (filled %d fields)", key, len(missing_fields))
|
||||||
|
|
||||||
|
await self._emit_scan_event({
|
||||||
|
"type": "nfo_updated",
|
||||||
|
"key": key,
|
||||||
|
"folder": folder,
|
||||||
|
"path": nfo_path,
|
||||||
|
"missing_fields": missing_fields,
|
||||||
|
})
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _regenerate_nfo(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
folder: str,
|
||||||
|
series_data: Dict[str, Any],
|
||||||
|
nfo_path: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Regenerate NFO from scratch when existing file is corrupt."""
|
||||||
|
tmdb_id = series_data.get("tmdb_id")
|
||||||
|
if not tmdb_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
tmdb_data = await self._fetch_tmdb_data(tmdb_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to fetch TMDB data for %s during regeneration: %s", key, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not tmdb_data:
|
||||||
|
return False
|
||||||
|
|
||||||
|
nfo_model = tmdb_to_nfo_model(
|
||||||
|
tmdb_data,
|
||||||
|
content_ratings=None,
|
||||||
|
get_image_url=self._make_tmdb_image_url(tmdb_id),
|
||||||
|
image_size="original",
|
||||||
|
)
|
||||||
|
|
||||||
|
xml_content = generate_tvshow_nfo(nfo_model)
|
||||||
|
|
||||||
|
with open(nfo_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(xml_content)
|
||||||
|
|
||||||
|
logger.info("Regenerated NFO for %s", key)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _fetch_tmdb_data(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Fetch series metadata from TMDB API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tmdb_id: TMDB series ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TMDB response dict or None on failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.server.nfo.tmdb_client import get_tmdb_client
|
||||||
|
|
||||||
|
client = get_tmdb_client()
|
||||||
|
data = await client.get_series_details(tmdb_id)
|
||||||
|
return data
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("TMDB fetch failed for TMDB ID %s: %s", tmdb_id, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _make_tmdb_image_url(self, tmdb_id: int) -> Callable[[str, str], str]:
|
||||||
|
"""Create a get_image_url closure bound to a TMDB account."""
|
||||||
|
from src.server.nfo.tmdb_client import get_tmdb_image_base_url
|
||||||
|
|
||||||
|
base = get_tmdb_image_base_url(tmdb_id)
|
||||||
|
|
||||||
|
def get_image_url(path: str, size: str = "original") -> str:
|
||||||
|
if not path:
|
||||||
|
return ""
|
||||||
|
return f"{base}{size}{path}"
|
||||||
|
|
||||||
|
return get_image_url
|
||||||
|
|
||||||
|
async def _update_series_nfo_flag(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
has_nfo: bool,
|
||||||
|
nfo_path: str,
|
||||||
|
) -> None:
|
||||||
|
"""Update the has_nfo flag and nfo_path in the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Series key (primary identifier)
|
||||||
|
has_nfo: Whether the series now has an NFO file
|
||||||
|
nfo_path: Path to the NFO file
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
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, key)
|
||||||
|
if series:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
series.has_nfo = has_nfo
|
||||||
|
series.nfo_path = nfo_path
|
||||||
|
if series.nfo_created_at is None:
|
||||||
|
series.nfo_created_at = now
|
||||||
|
series.nfo_updated_at = now
|
||||||
|
await db.flush()
|
||||||
|
logger.debug("Updated NFO flag for series: %s", key)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to update NFO flag for %s: %s", key, exc)
|
||||||
|
|
||||||
|
async def cancel_scan(self) -> bool:
|
||||||
|
"""Cancel the current NFO scan if one is in progress.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if scan was cancelled, False if no scan in progress.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
if not self._is_scanning:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._is_scanning = False
|
||||||
|
|
||||||
|
if self._current_scan:
|
||||||
|
self._current_scan.status = "cancelled"
|
||||||
|
self._current_scan.message = "NFO scan cancelled by user"
|
||||||
|
self._current_scan.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
if self._current_scan:
|
||||||
|
await self._emit_scan_event({
|
||||||
|
"type": "nfo_scan_cancelled",
|
||||||
|
"scan_id": self._current_scan.scan_id,
|
||||||
|
"message": "NFO scan cancelled by user",
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info("NFO scan cancelled")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_scan_status(self) -> Dict[str, Any]:
|
||||||
|
"""Get the current NFO scan status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with is_scanning and current_scan data.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"is_scanning": self._is_scanning,
|
||||||
|
"current_scan": (
|
||||||
|
self._current_scan.to_dict() if self._current_scan else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level singleton
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_nfo_scan_service: Optional[NfoScanService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_nfo_scan_service() -> NfoScanService:
|
||||||
|
"""Return the singleton NfoScanService instance."""
|
||||||
|
global _nfo_scan_service
|
||||||
|
if _nfo_scan_service is None:
|
||||||
|
_nfo_scan_service = NfoScanService()
|
||||||
|
return _nfo_scan_service
|
||||||
|
|
||||||
|
|
||||||
|
def reset_nfo_scan_service() -> None:
|
||||||
|
"""Reset the singleton NfoScanService instance (for testing)."""
|
||||||
|
global _nfo_scan_service
|
||||||
|
_nfo_scan_service = None
|
||||||
22
src/server/services/scheduler/__init__.py
Normal file
22
src/server/services/scheduler/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""Scheduler services package.
|
||||||
|
|
||||||
|
Contains scheduler orchestration:
|
||||||
|
|
||||||
|
- scheduler_service: Cron-based scheduler using APScheduler
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.server.services.scheduler.scheduler_service import (
|
||||||
|
SchedulerService,
|
||||||
|
SchedulerServiceError,
|
||||||
|
get_scheduler_service,
|
||||||
|
reset_scheduler_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Scheduler
|
||||||
|
"SchedulerService",
|
||||||
|
"SchedulerServiceError",
|
||||||
|
"get_scheduler_service",
|
||||||
|
"reset_scheduler_service",
|
||||||
|
]
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Scheduler service for automatic library rescans.
|
"""Scheduler service for automatic library rescans.
|
||||||
|
|
||||||
Uses APScheduler's AsyncIOScheduler with CronTrigger for precise
|
Uses APScheduler's AsyncIOScheduler with CronTrigger for precise
|
||||||
cron-based scheduling. The legacy interval-based loop has been removed
|
cron-based scheduling.
|
||||||
in favour of the cron approach.
|
|
||||||
|
|
||||||
Jobs are held in memory (no separate scheduler database). On startup,
|
Jobs are held in memory (no separate scheduler database). On startup,
|
||||||
if the last scan timestamp indicates a missed run (server was down at the
|
if the last scan timestamp indicates a missed run (server was down at the
|
||||||
@@ -12,7 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
@@ -28,6 +27,8 @@ _JOB_ID = "scheduled_rescan"
|
|||||||
# scheduled time and startup).
|
# scheduled time and startup).
|
||||||
_MISFIRE_GRACE_SECONDS = 3600
|
_MISFIRE_GRACE_SECONDS = 3600
|
||||||
|
|
||||||
|
_AUTO_DOWNLOAD_COOLDOWN_SECONDS = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
class SchedulerServiceError(Exception):
|
class SchedulerServiceError(Exception):
|
||||||
"""Service-level exception for scheduler operations."""
|
"""Service-level exception for scheduler operations."""
|
||||||
@@ -42,7 +43,8 @@ class SchedulerService:
|
|||||||
- Cron-based scheduling (time of day + days of week)
|
- Cron-based scheduling (time of day + days of week)
|
||||||
- Immediate manual trigger
|
- Immediate manual trigger
|
||||||
- Live config reloading without app restart
|
- Live config reloading without app restart
|
||||||
- Auto-queueing downloads of missing episodes after rescan
|
|
||||||
|
Actual rescan/folder-scan/auto-download work is handled inline.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -50,11 +52,9 @@ class SchedulerService:
|
|||||||
self._is_running: bool = False
|
self._is_running: bool = False
|
||||||
self._scheduler: Optional[AsyncIOScheduler] = None
|
self._scheduler: Optional[AsyncIOScheduler] = None
|
||||||
self._config: Optional[SchedulerConfig] = None
|
self._config: Optional[SchedulerConfig] = None
|
||||||
self._last_scan_time: Optional[datetime] = None
|
|
||||||
self._scan_in_progress: bool = False
|
self._scan_in_progress: bool = False
|
||||||
# Cooldown tracking for auto-download to prevent rapid re-triggers
|
self._last_scan_time: Optional[datetime] = None
|
||||||
self._last_auto_download_time: Optional[datetime] = None
|
self._last_auto_download_time: Optional[datetime] = None
|
||||||
self._auto_download_cooldown_seconds: int = 300 # 5 minutes default
|
|
||||||
logger.info("SchedulerService initialised")
|
logger.info("SchedulerService initialised")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -82,8 +82,6 @@ class SchedulerService:
|
|||||||
logger.error("Failed to load scheduler configuration: %s", exc)
|
logger.error("Failed to load scheduler configuration: %s", exc)
|
||||||
raise SchedulerServiceError(f"Failed to load config: {exc}") from exc
|
raise SchedulerServiceError(f"Failed to load config: {exc}") from exc
|
||||||
|
|
||||||
# Use in-memory job store — no separate scheduler.db needed.
|
|
||||||
# Jobs are reconstructed from config on every startup.
|
|
||||||
self._scheduler = AsyncIOScheduler()
|
self._scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
if not self._config.enabled:
|
if not self._config.enabled:
|
||||||
@@ -92,12 +90,11 @@ class SchedulerService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Scheduler config loaded: enabled=%s time=%s days=%s auto_download=%s folder_scan=%s",
|
"Scheduler config loaded: enabled=%s time=%s days=%s auto_download=%s",
|
||||||
self._config.enabled,
|
self._config.enabled,
|
||||||
self._config.schedule_time,
|
self._config.schedule_time,
|
||||||
self._config.schedule_days,
|
self._config.schedule_days,
|
||||||
self._config.auto_download_after_rescan,
|
self._config.auto_download_after_rescan,
|
||||||
self._config.folder_scan_enabled,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
trigger = self._build_cron_trigger()
|
trigger = self._build_cron_trigger()
|
||||||
@@ -133,8 +130,7 @@ class SchedulerService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Startup misfire recovery: check if the last scan was missed while
|
# Startup misfire recovery: check if the last scan was missed while
|
||||||
# the server was down. If overdue by more than one interval but within
|
# the server was down.
|
||||||
# the grace period, trigger an immediate rescan.
|
|
||||||
await self._check_missed_run()
|
await self._check_missed_run()
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
@@ -200,12 +196,11 @@ class SchedulerService:
|
|||||||
"""
|
"""
|
||||||
self._config = config
|
self._config = config
|
||||||
logger.info(
|
logger.info(
|
||||||
"Scheduler config reloaded: enabled=%s time=%s days=%s auto_download=%s folder_scan=%s",
|
"Scheduler config reloaded: enabled=%s time=%s days=%s auto_download=%s",
|
||||||
config.enabled,
|
config.enabled,
|
||||||
config.schedule_time,
|
config.schedule_time,
|
||||||
config.schedule_days,
|
config.schedule_days,
|
||||||
config.auto_download_after_rescan,
|
config.auto_download_after_rescan,
|
||||||
config.folder_scan_enabled,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not self._scheduler or not self._scheduler.running:
|
if not self._scheduler or not self._scheduler.running:
|
||||||
@@ -266,10 +261,14 @@ class SchedulerService:
|
|||||||
"auto_download_after_rescan": (
|
"auto_download_after_rescan": (
|
||||||
self._config.auto_download_after_rescan if self._config else False
|
self._config.auto_download_after_rescan if self._config else False
|
||||||
),
|
),
|
||||||
"folder_scan_enabled": (
|
"nfo_scan_after_rescan": (
|
||||||
self._config.folder_scan_enabled if self._config else False
|
self._config.nfo_scan_after_rescan if self._config else True
|
||||||
|
),
|
||||||
|
"last_run": (
|
||||||
|
self._last_scan_time.isoformat()
|
||||||
|
if self._last_scan_time
|
||||||
|
else None
|
||||||
),
|
),
|
||||||
"last_run": self._last_scan_time.isoformat() if self._last_scan_time else None,
|
|
||||||
"next_run": next_run,
|
"next_run": next_run,
|
||||||
"scan_in_progress": self._scan_in_progress,
|
"scan_in_progress": self._scan_in_progress,
|
||||||
}
|
}
|
||||||
@@ -316,9 +315,9 @@ class SchedulerService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from src.server.database.connection import get_db_session # noqa: PLC0415
|
from src.server.database.connection import get_db_session
|
||||||
from src.server.database.system_settings_service import (
|
from src.server.database.system_settings_service import (
|
||||||
SystemSettingsService, # noqa: PLC0415
|
SystemSettingsService,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with get_db_session() as db:
|
async with get_db_session() as db:
|
||||||
@@ -341,7 +340,6 @@ class SchedulerService:
|
|||||||
# If last scan was more than 24h + grace period ago, don't trigger
|
# If last scan was more than 24h + grace period ago, don't trigger
|
||||||
# (avoids surprise rescans after long downtime).
|
# (avoids surprise rescans after long downtime).
|
||||||
max_overdue = timedelta(hours=24, seconds=_MISFIRE_GRACE_SECONDS)
|
max_overdue = timedelta(hours=24, seconds=_MISFIRE_GRACE_SECONDS)
|
||||||
# If last scan was more than ~25h ago, skip (too stale)
|
|
||||||
if elapsed > max_overdue:
|
if elapsed > max_overdue:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Last scan was %s ago (> %s) — skipping missed-run recovery",
|
"Last scan was %s ago (> %s) — skipping missed-run recovery",
|
||||||
@@ -351,7 +349,6 @@ class SchedulerService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Check if a run should have happened between last_scan and now.
|
# Check if a run should have happened between last_scan and now.
|
||||||
# Simple heuristic: if elapsed > 24h, we missed at least one daily run.
|
|
||||||
if elapsed > timedelta(hours=23):
|
if elapsed > timedelta(hours=23):
|
||||||
logger.info(
|
logger.info(
|
||||||
"Missed scheduled rescan detected (last scan %s ago) — triggering now",
|
"Missed scheduled rescan detected (last scan %s ago) — triggering now",
|
||||||
@@ -362,39 +359,118 @@ class SchedulerService:
|
|||||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
logger.warning("Missed-run check failed (non-fatal): %s", exc)
|
logger.warning("Missed-run check failed (non-fatal): %s", exc)
|
||||||
|
|
||||||
async def _broadcast(self, event_type: str, data: dict) -> None:
|
async def _perform_rescan(self) -> None:
|
||||||
"""Broadcast a WebSocket event to all connected clients."""
|
"""Execute a library rescan with auto-download and folder scan."""
|
||||||
|
logger.info(
|
||||||
|
"Scheduler _perform_rescan entered: scan_in_progress=%s",
|
||||||
|
self._scan_in_progress,
|
||||||
|
)
|
||||||
|
if self._scan_in_progress:
|
||||||
|
logger.warning("Skipping rescan: previous scan still in progress")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._scan_in_progress = True
|
||||||
|
scan_start = datetime.now(timezone.utc)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from src.server.services.websocket_service import (
|
await self._broadcast("scheduled_rescan_started", {"timestamp": scan_start.isoformat()})
|
||||||
get_websocket_service, # noqa: PLC0415
|
|
||||||
|
# 1. Main library rescan
|
||||||
|
await self._run_rescan()
|
||||||
|
|
||||||
|
# 2. NFO scan (if enabled)
|
||||||
|
if self._config and self._config.nfo_scan_after_rescan:
|
||||||
|
try:
|
||||||
|
nfo_result = await self._run_nfo_scan()
|
||||||
|
await self._broadcast("nfo_scan_started", {
|
||||||
|
"created": nfo_result.get("created", 0),
|
||||||
|
"updated": nfo_result.get("updated", 0),
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("NFO scan failed: %s", exc, exc_info=True)
|
||||||
|
await self._broadcast("nfo_scan_error", {"error": str(exc)})
|
||||||
|
|
||||||
|
# 3. Auto-download (if enabled)
|
||||||
|
if self._config and self._config.auto_download_after_rescan:
|
||||||
|
try:
|
||||||
|
queued = await self._run_auto_download()
|
||||||
|
await self._broadcast("auto_download_started", {"queued_count": queued})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Auto-download failed: %s", exc, exc_info=True)
|
||||||
|
await self._broadcast("auto_download_error", {"error": str(exc)})
|
||||||
|
|
||||||
|
self._last_scan_time = datetime.now(timezone.utc)
|
||||||
|
duration = (self._last_scan_time - scan_start).total_seconds()
|
||||||
|
|
||||||
|
await self._broadcast(
|
||||||
|
"scheduled_rescan_completed",
|
||||||
|
{
|
||||||
|
"timestamp": self._last_scan_time.isoformat(),
|
||||||
|
"duration_seconds": duration,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Scheduled library rescan completed: duration=%.2fs",
|
||||||
|
duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
ws_service = get_websocket_service()
|
except Exception as exc:
|
||||||
await ws_service.manager.broadcast({"type": event_type, "data": data})
|
logger.error("Scheduled rescan failed: %s", exc, exc_info=True)
|
||||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
await self._broadcast(
|
||||||
logger.warning("WebSocket broadcast failed: event=%s error=%s", event_type, exc)
|
"scheduled_rescan_error",
|
||||||
|
{"error": str(exc), "timestamp": datetime.now(timezone.utc).isoformat()},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self._scan_in_progress = False
|
||||||
|
logger.info("Scheduled rescan finished: scan_in_progress reset to False")
|
||||||
|
|
||||||
async def _auto_download_missing(self) -> None:
|
async def _run_rescan(self) -> None:
|
||||||
|
"""Run the anime service rescan."""
|
||||||
|
from src.server.utils.dependencies import get_anime_service
|
||||||
|
|
||||||
|
anime_service = get_anime_service()
|
||||||
|
logger.info("Anime service obtained, calling anime_service.rescan()...")
|
||||||
|
await anime_service.rescan()
|
||||||
|
logger.info("anime_service.rescan() completed")
|
||||||
|
|
||||||
|
async def _run_nfo_scan(self) -> Dict[str, Any]:
|
||||||
|
"""Run NFO validation and creation across all series."""
|
||||||
|
from src.server.services.nfo_scan_service import get_nfo_scan_service
|
||||||
|
from src.server.utils.dependencies import get_anime_service
|
||||||
|
|
||||||
|
anime_service = get_anime_service()
|
||||||
|
nfo_scan_service = get_nfo_scan_service()
|
||||||
|
|
||||||
|
logger.info("Starting NFO scan...")
|
||||||
|
result = await nfo_scan_service.scan_all(anime_service)
|
||||||
|
logger.info(
|
||||||
|
"NFO scan completed: created=%d updated=%d errors=%d",
|
||||||
|
result.get("created", 0),
|
||||||
|
result.get("updated", 0),
|
||||||
|
result.get("errors_count", 0),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _run_auto_download(self) -> int:
|
||||||
"""Queue and start downloads for all series with missing episodes."""
|
"""Queue and start downloads for all series with missing episodes."""
|
||||||
from datetime import timedelta # noqa: PLC0415
|
from src.server.models.download import EpisodeIdentifier
|
||||||
|
from src.server.utils.dependencies import (
|
||||||
from src.server.models.download import EpisodeIdentifier # noqa: PLC0415
|
|
||||||
from src.server.utils.dependencies import ( # noqa: PLC0415
|
|
||||||
get_anime_service,
|
get_anime_service,
|
||||||
get_download_service,
|
get_download_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check cooldown to prevent rapid re-triggers
|
# Cooldown check
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
if self._last_auto_download_time is not None:
|
if self._last_auto_download_time is not None:
|
||||||
elapsed = now - self._last_auto_download_time
|
elapsed = now - self._last_auto_download_time
|
||||||
if elapsed < timedelta(seconds=self._auto_download_cooldown_seconds):
|
if elapsed < timedelta(seconds=_AUTO_DOWNLOAD_COOLDOWN_SECONDS):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Auto-download skipped: cooldown active (elapsed=%.1fs cooldown=%ds)",
|
"Auto-download skipped: cooldown active (elapsed=%.1fs cooldown=%ds)",
|
||||||
elapsed.total_seconds(),
|
elapsed.total_seconds(),
|
||||||
self._auto_download_cooldown_seconds,
|
_AUTO_DOWNLOAD_COOLDOWN_SECONDS,
|
||||||
)
|
)
|
||||||
return
|
return 0
|
||||||
|
|
||||||
anime_service = get_anime_service()
|
anime_service = get_anime_service()
|
||||||
download_service = get_download_service()
|
download_service = get_download_service()
|
||||||
@@ -434,123 +510,22 @@ class SchedulerService:
|
|||||||
await download_service.start_queue_processing()
|
await download_service.start_queue_processing()
|
||||||
logger.info("Auto-download queue processing started: queued=%d", queued_count)
|
logger.info("Auto-download queue processing started: queued=%d", queued_count)
|
||||||
|
|
||||||
await self._broadcast("auto_download_started", {"queued_count": queued_count})
|
|
||||||
logger.info("Auto-download completed: queued_count=%d", queued_count)
|
|
||||||
|
|
||||||
# Update cooldown timestamp after successful auto-download
|
|
||||||
self._last_auto_download_time = datetime.now(timezone.utc)
|
self._last_auto_download_time = datetime.now(timezone.utc)
|
||||||
|
logger.info("Auto-download completed: queued_count=%d", queued_count)
|
||||||
|
return queued_count
|
||||||
|
|
||||||
async def _perform_rescan(self) -> None:
|
async def _broadcast(self, event_type: str, data: dict) -> None:
|
||||||
"""Execute a library rescan and optionally trigger auto-download."""
|
"""Broadcast a WebSocket event to all connected clients."""
|
||||||
logger.info("Scheduler _perform_rescan entered: scan_in_progress=%s", self._scan_in_progress)
|
|
||||||
if self._scan_in_progress:
|
|
||||||
logger.warning("Skipping rescan: previous scan still in progress")
|
|
||||||
return
|
|
||||||
|
|
||||||
self._scan_in_progress = True
|
|
||||||
scan_start = datetime.now(timezone.utc)
|
|
||||||
logger.info("Scheduled rescan started at %s", scan_start.isoformat())
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Starting scheduled library rescan")
|
from src.server.services.websocket_service import get_websocket_service
|
||||||
|
|
||||||
from src.server.utils.dependencies import get_anime_service # noqa: PLC0415
|
ws_service = get_websocket_service()
|
||||||
|
await ws_service.manager.broadcast({"type": event_type, "data": data})
|
||||||
anime_service = get_anime_service()
|
except Exception as exc:
|
||||||
logger.info("Anime service obtained for rescan")
|
logger.warning(
|
||||||
|
"WebSocket broadcast failed: event=%s error=%s", event_type, exc
|
||||||
await self._broadcast(
|
|
||||||
"scheduled_rescan_started",
|
|
||||||
{"timestamp": scan_start.isoformat()},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Calling anime_service.rescan()...")
|
|
||||||
await anime_service.rescan()
|
|
||||||
|
|
||||||
self._last_scan_time = datetime.now(timezone.utc)
|
|
||||||
duration = (self._last_scan_time - scan_start).total_seconds()
|
|
||||||
|
|
||||||
logger.info("Scheduled library rescan completed: duration=%.2fs", duration)
|
|
||||||
|
|
||||||
await self._broadcast(
|
|
||||||
"scheduled_rescan_completed",
|
|
||||||
{
|
|
||||||
"timestamp": self._last_scan_time.isoformat(),
|
|
||||||
"duration_seconds": duration,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Auto-download after rescan
|
|
||||||
if self._config and self._config.auto_download_after_rescan:
|
|
||||||
logger.info("Auto-download after rescan is enabled — starting")
|
|
||||||
try:
|
|
||||||
await self._auto_download_missing()
|
|
||||||
except Exception as dl_exc: # pylint: disable=broad-exception-caught
|
|
||||||
logger.error(
|
|
||||||
"Auto-download after rescan failed: %s",
|
|
||||||
dl_exc,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
await self._broadcast(
|
|
||||||
"auto_download_error", {"error": str(dl_exc)}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.debug("Auto-download after rescan is disabled — skipping")
|
|
||||||
|
|
||||||
# Folder scan (daily maintenance)
|
|
||||||
if self._config and self._config.folder_scan_enabled:
|
|
||||||
logger.info("Folder scan is enabled — starting")
|
|
||||||
try:
|
|
||||||
from src.server.services.folder_scan_service import (
|
|
||||||
FolderScanService, # noqa: PLC0415
|
|
||||||
)
|
|
||||||
|
|
||||||
folder_scan_service = FolderScanService()
|
|
||||||
await folder_scan_service.run_folder_scan()
|
|
||||||
logger.info("Folder scan completed successfully")
|
|
||||||
except Exception as fs_exc: # pylint: disable=broad-exception-caught
|
|
||||||
logger.error(
|
|
||||||
"Folder scan failed: %s",
|
|
||||||
fs_exc,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
await self._broadcast(
|
|
||||||
"folder_scan_error", {"error": str(fs_exc)}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Key resolution scan (resolve orphaned folders)
|
|
||||||
try:
|
|
||||||
from src.server.services.key_resolution_service import (
|
|
||||||
perform_key_resolution_scan, # noqa: PLC0415
|
|
||||||
)
|
|
||||||
|
|
||||||
key_stats = await perform_key_resolution_scan()
|
|
||||||
logger.info(
|
|
||||||
"Key resolution scan completed: resolved=%d, skipped=%d, errors=%d",
|
|
||||||
key_stats["resolved"],
|
|
||||||
key_stats["skipped"],
|
|
||||||
key_stats["errors"],
|
|
||||||
)
|
|
||||||
except Exception as kr_exc: # pylint: disable=broad-exception-caught
|
|
||||||
logger.error(
|
|
||||||
"Key resolution scan failed: %s",
|
|
||||||
kr_exc,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.debug("Folder scan is disabled — skipping")
|
|
||||||
|
|
||||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
||||||
logger.error("Scheduled rescan failed: %s", exc, exc_info=True)
|
|
||||||
await self._broadcast(
|
|
||||||
"scheduled_rescan_error",
|
|
||||||
{"error": str(exc), "timestamp": datetime.now(timezone.utc).isoformat()},
|
|
||||||
)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
self._scan_in_progress = False
|
|
||||||
logger.info("Scheduled rescan finished: scan_in_progress reset to False")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Module-level job runner
|
# Module-level job runner
|
||||||
426
src/server/services/setup_service.py
Normal file
426
src/server/services/setup_service.py
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
"""Setup service for first-time database initialization.
|
||||||
|
|
||||||
|
This service runs during initial application setup to:
|
||||||
|
1. Scan anime folders in the data directory
|
||||||
|
2. Extract title and year from folder names
|
||||||
|
3. Create AnimeSeries records in the database
|
||||||
|
4. Resolve provider keys via search (if single match found)
|
||||||
|
|
||||||
|
The run_once logic is handled by the caller (perform_initial_setup)
|
||||||
|
via _check_initial_scan_status, not by this service itself.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from src.config.settings import settings
|
||||||
|
from src.server.database.connection import get_db_session
|
||||||
|
from src.server.database.service import AnimeSeriesService, UnresolvedFolderService
|
||||||
|
from src.server.utils.dependencies import get_series_app
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SeriesProperties:
|
||||||
|
"""Filesystem-derived properties for an AnimeSeries."""
|
||||||
|
has_nfo: bool = False
|
||||||
|
nfo_path: Optional[str] = None
|
||||||
|
nfo_created_at: Optional[datetime] = None
|
||||||
|
nfo_updated_at: Optional[datetime] = None
|
||||||
|
logo_loaded: bool = False
|
||||||
|
images_loaded: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SetupService:
|
||||||
|
"""Service for setup operations during application initialization."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_year_from_folder_name(folder_name: str) -> Optional[int]:
|
||||||
|
"""Extract year from folder name if present.
|
||||||
|
|
||||||
|
Looks for year in format "(YYYY)" at the end of folder name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_name: The folder name to parse
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Year as integer if found, None otherwise
|
||||||
|
"""
|
||||||
|
if not folder_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
match = re.search(r'\((\d{4})\)', folder_name)
|
||||||
|
if match:
|
||||||
|
year = int(match.group(1))
|
||||||
|
if 1900 <= year <= 2100:
|
||||||
|
return year
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_title_from_folder_name(folder_name: str) -> str:
|
||||||
|
"""Extract title from folder name by removing year suffix.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_name: The folder name to parse
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Title with year suffix and surrounding whitespace removed
|
||||||
|
"""
|
||||||
|
return re.sub(r'\s*\(\d{4}\)\s*$', '', folder_name).strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_title(title: str) -> str:
|
||||||
|
"""Normalize title for fuzzy matching.
|
||||||
|
|
||||||
|
Strips common suffixes and lowercases for comparison.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: The title to normalize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalized title string
|
||||||
|
"""
|
||||||
|
# Remove common anime suffixes (case-insensitive)
|
||||||
|
suffixes = [
|
||||||
|
r'\s*\(TV\)\s*$',
|
||||||
|
r'\s*\(Anime\)\s*$',
|
||||||
|
r'\s*\(OAD\)\s*$',
|
||||||
|
r'\s*\(OVA\)\s*$',
|
||||||
|
r'\s*\(Special\)\s*$',
|
||||||
|
r'\s*\(Movie\)\s*$',
|
||||||
|
r'\s*\(Spin-Off\)\s*$',
|
||||||
|
]
|
||||||
|
normalized = title.lower().strip()
|
||||||
|
for suffix_pattern in suffixes:
|
||||||
|
normalized = re.sub(suffix_pattern, '', normalized, flags=re.IGNORECASE).strip()
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _titles_match(title1: str, title2: str, threshold: float = 0.85) -> bool:
|
||||||
|
"""Check if two titles match using fuzzy comparison.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title1: First title
|
||||||
|
title2: Second title
|
||||||
|
threshold: Similarity threshold (0.0 to 1.0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if titles match within threshold
|
||||||
|
"""
|
||||||
|
norm1 = SetupService._normalize_title(title1)
|
||||||
|
norm2 = SetupService._normalize_title(title2)
|
||||||
|
|
||||||
|
# Direct match after normalization
|
||||||
|
if norm1 == norm2:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Containment check (e.g., "Attack on Titan" in "Attack on Titan (TV)")
|
||||||
|
if norm1 in norm2 or norm2 in norm1:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Similarity ratio check using SequenceMatcher
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
ratio = SequenceMatcher(None, norm1, norm2).ratio()
|
||||||
|
return ratio >= threshold
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _resolve_key_via_search(title: str) -> str:
|
||||||
|
"""Resolve provider key by searching for the title.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: The title to search for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Provider key if exactly one match with same name found,
|
||||||
|
empty string otherwise
|
||||||
|
"""
|
||||||
|
if not title:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
series_app = get_series_app()
|
||||||
|
results = await series_app.search(title)
|
||||||
|
|
||||||
|
if len(results) == 1:
|
||||||
|
result_name = results[0].get('title', '')
|
||||||
|
result_link = results[0].get('link', '')
|
||||||
|
|
||||||
|
if SetupService._titles_match(result_name, title):
|
||||||
|
if result_link and '/anime/stream/' in result_link:
|
||||||
|
return result_link.split('/anime/stream/')[-1].split('/')[0]
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Series key resolved but link format unexpected",
|
||||||
|
folder_title=title,
|
||||||
|
result_title=result_name,
|
||||||
|
link=result_link
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Series search result title mismatch",
|
||||||
|
folder_title=title,
|
||||||
|
result_title=result_name,
|
||||||
|
link=result_link
|
||||||
|
)
|
||||||
|
elif len(results) > 1:
|
||||||
|
logger.debug(
|
||||||
|
"Multiple search results for title, skipping fuzzy match",
|
||||||
|
title=title,
|
||||||
|
result_count=len(results)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Provider search failed for folder",
|
||||||
|
title=title,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check_nfo_file(folder_path: Path) -> tuple[bool, Optional[str], Optional[datetime], Optional[datetime]]:
|
||||||
|
"""Check if tvshow.nfo exists and return its metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_path: Path to the series folder
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (has_nfo, nfo_path, nfo_created_at, nfo_updated_at)
|
||||||
|
"""
|
||||||
|
nfo_path = folder_path / "tvshow.nfo"
|
||||||
|
if nfo_path.is_file():
|
||||||
|
stat = nfo_path.stat()
|
||||||
|
created = datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc)
|
||||||
|
updated = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
|
||||||
|
return True, str(nfo_path), created, updated
|
||||||
|
return False, None, None, None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check_logo_file(folder_path: Path) -> bool:
|
||||||
|
"""Check if logo.png exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_path: Path to the series folder
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if logo.png exists, False otherwise
|
||||||
|
"""
|
||||||
|
return (folder_path / "logo.png").is_file()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check_image_files(folder_path: Path) -> bool:
|
||||||
|
"""Check if any image files (poster, fanart) exist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_path: Path to the series folder
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if any poster.jpg/jpeg/png or fanart.jpg/jpeg/png exists
|
||||||
|
"""
|
||||||
|
image_extensions = {'.jpg', '.jpeg', '.png'}
|
||||||
|
for child in folder_path.iterdir():
|
||||||
|
if child.is_file():
|
||||||
|
name_lower = child.name.lower()
|
||||||
|
if name_lower.startswith(('poster', 'fanart')) and child.suffix.lower() in image_extensions:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_series_properties(cls, folder_path: Path) -> SeriesProperties:
|
||||||
|
"""Get all filesystem-derived properties for a series folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_path: Path to the series folder
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SeriesProperties with all detected values
|
||||||
|
"""
|
||||||
|
has_nfo, nfo_path, nfo_created_at, nfo_updated_at = cls._check_nfo_file(folder_path)
|
||||||
|
logo_loaded = cls._check_logo_file(folder_path)
|
||||||
|
images_loaded = cls._check_image_files(folder_path)
|
||||||
|
|
||||||
|
return SeriesProperties(
|
||||||
|
has_nfo=has_nfo,
|
||||||
|
nfo_path=nfo_path,
|
||||||
|
nfo_created_at=nfo_created_at,
|
||||||
|
nfo_updated_at=nfo_updated_at,
|
||||||
|
logo_loaded=logo_loaded,
|
||||||
|
images_loaded=images_loaded,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def run(cls) -> int:
|
||||||
|
"""Run the setup service.
|
||||||
|
|
||||||
|
Scans anime folders, creates AnimeSeries records, and resolves
|
||||||
|
provider keys via search. Should only be called after checking
|
||||||
|
that initial scan hasn't been completed yet (via _check_initial_scan_status).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of new series created
|
||||||
|
"""
|
||||||
|
if not settings.anime_directory:
|
||||||
|
logger.info("Anime directory not configured, skipping setup")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
anime_dir = Path(settings.anime_directory)
|
||||||
|
if not anime_dir.is_dir():
|
||||||
|
logger.info(
|
||||||
|
"Anime directory does not exist, skipping setup: %s",
|
||||||
|
anime_dir
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
logger.info("Running setup service...")
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
skipped_existing = 0
|
||||||
|
unresolved_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
series_app = get_series_app()
|
||||||
|
|
||||||
|
async with get_db_session() as db:
|
||||||
|
for folder in anime_dir.iterdir():
|
||||||
|
if not folder.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
folder_name = folder.name
|
||||||
|
|
||||||
|
# Check if series already exists in DB
|
||||||
|
existing = await AnimeSeriesService.get_by_folder(
|
||||||
|
db, folder_name
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
skipped_existing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if already tracked as unresolved
|
||||||
|
existing_unresolved = await UnresolvedFolderService.get_by_folder_name(
|
||||||
|
db, folder_name
|
||||||
|
)
|
||||||
|
if existing_unresolved and existing_unresolved.is_resolved:
|
||||||
|
# Was previously unresolved but now resolved - create the series
|
||||||
|
resolved_key = existing_unresolved.provider_key
|
||||||
|
year = cls._extract_year_from_folder_name(folder_name)
|
||||||
|
title = cls._extract_title_from_folder_name(folder_name)
|
||||||
|
props = cls._get_series_properties(folder)
|
||||||
|
|
||||||
|
series = await AnimeSeriesService.create(
|
||||||
|
db=db,
|
||||||
|
key=resolved_key,
|
||||||
|
name=title,
|
||||||
|
site="https://aniworld.to",
|
||||||
|
folder=folder_name,
|
||||||
|
year=year,
|
||||||
|
loading_status="completed",
|
||||||
|
episodes_loaded=True,
|
||||||
|
logo_loaded=props.logo_loaded,
|
||||||
|
images_loaded=props.images_loaded,
|
||||||
|
has_nfo=props.has_nfo,
|
||||||
|
nfo_path=props.nfo_path,
|
||||||
|
nfo_created_at=props.nfo_created_at,
|
||||||
|
nfo_updated_at=props.nfo_updated_at,
|
||||||
|
)
|
||||||
|
created_count += 1
|
||||||
|
|
||||||
|
# Delete the unresolved tracking now that series is created
|
||||||
|
await UnresolvedFolderService.delete(db, folder_name)
|
||||||
|
continue
|
||||||
|
elif existing_unresolved:
|
||||||
|
# Already tracked as unresolved, skip
|
||||||
|
unresolved_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract title and year from folder name
|
||||||
|
year = cls._extract_year_from_folder_name(folder_name)
|
||||||
|
title = cls._extract_title_from_folder_name(folder_name)
|
||||||
|
|
||||||
|
if not title:
|
||||||
|
logger.warning(
|
||||||
|
"Could not extract title from folder: %s",
|
||||||
|
folder_name
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Resolve key via provider search
|
||||||
|
resolved_key = await cls._resolve_key_via_search(title)
|
||||||
|
|
||||||
|
if not resolved_key:
|
||||||
|
# Track unresolved folder for later manual resolution
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
series_results = await series_app.search(title)
|
||||||
|
search_result_json = json.dumps(series_results) if series_results else None
|
||||||
|
except Exception:
|
||||||
|
search_result_json = None
|
||||||
|
|
||||||
|
await UnresolvedFolderService.create(
|
||||||
|
db=db,
|
||||||
|
folder_name=folder_name,
|
||||||
|
title=title,
|
||||||
|
year=year,
|
||||||
|
search_attempts=1,
|
||||||
|
last_search_result=search_result_json,
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"Could not resolve series key for folder, tracking as unresolved: %s",
|
||||||
|
folder_name
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check filesystem properties
|
||||||
|
props = cls._get_series_properties(folder)
|
||||||
|
|
||||||
|
# Create AnimeSeries record
|
||||||
|
series = await AnimeSeriesService.create(
|
||||||
|
db=db,
|
||||||
|
key=resolved_key,
|
||||||
|
name=title,
|
||||||
|
site="https://aniworld.to",
|
||||||
|
folder=folder_name,
|
||||||
|
year=year,
|
||||||
|
loading_status="completed",
|
||||||
|
episodes_loaded=True,
|
||||||
|
logo_loaded=props.logo_loaded,
|
||||||
|
images_loaded=props.images_loaded,
|
||||||
|
has_nfo=props.has_nfo,
|
||||||
|
nfo_path=props.nfo_path,
|
||||||
|
nfo_created_at=props.nfo_created_at,
|
||||||
|
nfo_updated_at=props.nfo_updated_at,
|
||||||
|
)
|
||||||
|
created_count += 1
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Created series from folder",
|
||||||
|
folder=folder_name,
|
||||||
|
title=title,
|
||||||
|
year=year,
|
||||||
|
key=resolved_key or "(unresolved)",
|
||||||
|
has_nfo=props.has_nfo,
|
||||||
|
logo_loaded=props.logo_loaded,
|
||||||
|
images_loaded=props.images_loaded,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Setup complete",
|
||||||
|
created=created_count,
|
||||||
|
skipped_existing=skipped_existing,
|
||||||
|
unresolved=unresolved_count
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Setup failed",
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
return created_count
|
||||||
|
|
||||||
|
return created_count
|
||||||
@@ -20,7 +20,7 @@ except Exception: # pragma: no cover - optional dependency
|
|||||||
AsyncSession = object
|
AsyncSession = object
|
||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.core.SeriesApp import SeriesApp
|
from src.server.SeriesApp import SeriesApp
|
||||||
from src.server.services.auth_service import AuthError, auth_service
|
from src.server.services.auth_service import AuthError, auth_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -58,16 +58,16 @@ _RATE_LIMIT_WINDOW_SECONDS = 60.0
|
|||||||
|
|
||||||
|
|
||||||
def _make_db_lookup():
|
def _make_db_lookup():
|
||||||
"""Build a synchronous ``(folder) -> Serie | None`` callable for SerieScanner.
|
"""Build a synchronous ``(folder) -> AnimeSeries | None`` callable for SerieScanner.
|
||||||
|
|
||||||
The returned function opens a short-lived sync DB session, queries for a
|
The returned function opens a short-lived sync DB session, queries for a
|
||||||
series whose ``folder`` column matches the given name, and converts the
|
series whose ``folder`` column matches the given name, and returns the
|
||||||
ORM row to a ``Serie`` domain object. Returns ``None`` when the DB is not
|
AnimeSeries ORM object. Returns ``None`` when the DB is not yet initialised
|
||||||
yet initialised or no matching row is found.
|
or no matching row is found.
|
||||||
"""
|
"""
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
|
|
||||||
def _lookup(folder: str) -> Optional["Serie"]:
|
def _lookup(folder: str) -> Optional["AnimeSeries"]:
|
||||||
try:
|
try:
|
||||||
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
|
from src.server.database.service import AnimeSeriesService
|
||||||
@@ -78,16 +78,7 @@ def _make_db_lookup():
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
if row is None:
|
return row
|
||||||
return None
|
|
||||||
return Serie(
|
|
||||||
key=row.key,
|
|
||||||
name=row.name or "",
|
|
||||||
site=row.site,
|
|
||||||
folder=row.folder,
|
|
||||||
episodeDict={},
|
|
||||||
year=row.year,
|
|
||||||
)
|
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# DB not initialised yet (e.g. first boot before init_db())
|
# DB not initialised yet (e.g. first boot before init_db())
|
||||||
return None
|
return None
|
||||||
@@ -172,7 +163,7 @@ def get_series_app() -> SeriesApp:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
_series_app = SeriesApp(anime_dir, db_lookup=_make_db_lookup())
|
_series_app = SeriesApp(anime_dir)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ from typing import Any, Dict, List, Optional
|
|||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from src.server.utils.version import APP_VERSION
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Configure templates directory
|
# Configure templates directory
|
||||||
@@ -48,7 +50,7 @@ def get_base_context(
|
|||||||
"request": request,
|
"request": request,
|
||||||
"title": title,
|
"title": title,
|
||||||
"app_name": "Aniworld Download Manager",
|
"app_name": "Aniworld Download Manager",
|
||||||
"version": "1.0.1",
|
"version": APP_VERSION,
|
||||||
"static_v": STATIC_VERSION,
|
"static_v": STATIC_VERSION,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
src/server/utils/version.py
Normal file
26
src/server/utils/version.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Version management utilities for Aniworld application."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def get_version() -> str:
|
||||||
|
"""
|
||||||
|
Get the current application version from Docker/VERSION file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Version string from the VERSION file, or "unknown" if not found.
|
||||||
|
"""
|
||||||
|
version_file = Path(__file__).parent.parent.parent.parent / "Docker" / "VERSION"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if version_file.exists():
|
||||||
|
return version_file.read_text().strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level version constant (loaded once at import)
|
||||||
|
APP_VERSION: str = get_version()
|
||||||
@@ -1561,8 +1561,6 @@ class AniWorldApp {
|
|||||||
document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled;
|
document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled;
|
||||||
document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00';
|
document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00';
|
||||||
document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan;
|
document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan;
|
||||||
const folderScanEl = document.getElementById('folder-scan-enabled');
|
|
||||||
if (folderScanEl) folderScanEl.checked = !!config.folder_scan_enabled;
|
|
||||||
|
|
||||||
// Update day-of-week checkboxes
|
// Update day-of-week checkboxes
|
||||||
const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun'];
|
const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun'];
|
||||||
@@ -1605,8 +1603,6 @@ class AniWorldApp {
|
|||||||
const enabled = document.getElementById('scheduled-rescan-enabled').checked;
|
const enabled = document.getElementById('scheduled-rescan-enabled').checked;
|
||||||
const scheduleTime = document.getElementById('scheduled-rescan-time').value || '03:00';
|
const scheduleTime = document.getElementById('scheduled-rescan-time').value || '03:00';
|
||||||
const autoDownload = document.getElementById('auto-download-after-rescan').checked;
|
const autoDownload = document.getElementById('auto-download-after-rescan').checked;
|
||||||
const folderScanEl = document.getElementById('folder-scan-enabled');
|
|
||||||
const folderScan = folderScanEl ? folderScanEl.checked : false;
|
|
||||||
|
|
||||||
// Collect checked day-of-week values
|
// Collect checked day-of-week values
|
||||||
const scheduleDays = ['mon','tue','wed','thu','fri','sat','sun']
|
const scheduleDays = ['mon','tue','wed','thu','fri','sat','sun']
|
||||||
@@ -1622,9 +1618,8 @@ class AniWorldApp {
|
|||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
schedule_time: scheduleTime,
|
schedule_time: scheduleTime,
|
||||||
schedule_days: scheduleDays,
|
schedule_days: scheduleDays,
|
||||||
auto_download_after_rescan: autoDownload,
|
auto_download_after_rescan: autoDownload
|
||||||
folder_scan_enabled: folderScan
|
})
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response) return;
|
if (!response) return;
|
||||||
|
|||||||
@@ -35,11 +35,6 @@ AniWorld.SchedulerConfig = (function() {
|
|||||||
autoDownload.checked = config.auto_download_after_rescan || false;
|
autoDownload.checked = config.auto_download_after_rescan || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderScan = document.getElementById('folder-scan-enabled');
|
|
||||||
if (folderScan) {
|
|
||||||
folderScan.checked = config.folder_scan_enabled || false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update schedule day checkboxes
|
// Update schedule day checkboxes
|
||||||
const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun'];
|
const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun'];
|
||||||
['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) {
|
['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) {
|
||||||
@@ -87,16 +82,12 @@ AniWorld.SchedulerConfig = (function() {
|
|||||||
const autoDownloadEl = document.getElementById('auto-download-after-rescan');
|
const autoDownloadEl = document.getElementById('auto-download-after-rescan');
|
||||||
const autoDownload = autoDownloadEl ? autoDownloadEl.checked : false;
|
const autoDownload = autoDownloadEl ? autoDownloadEl.checked : false;
|
||||||
|
|
||||||
const folderScanEl = document.getElementById('folder-scan-enabled');
|
|
||||||
const folderScan = folderScanEl ? folderScanEl.checked : false;
|
|
||||||
|
|
||||||
// POST directly to the scheduler config endpoint
|
// POST directly to the scheduler config endpoint
|
||||||
const payload = {
|
const payload = {
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
schedule_time: scheduleTime,
|
schedule_time: scheduleTime,
|
||||||
schedule_days: scheduleDays,
|
schedule_days: scheduleDays,
|
||||||
auto_download_after_rescan: autoDownload,
|
auto_download_after_rescan: autoDownload
|
||||||
folder_scan_enabled: folderScan
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload);
|
const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload);
|
||||||
|
|||||||
@@ -479,13 +479,6 @@
|
|||||||
<span>Auto-download missing episodes after rescan</span>
|
<span>Auto-download missing episodes after rescan</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-checkbox">
|
|
||||||
<input type="checkbox" id="scheduler_folder_scan" name="scheduler_folder_scan">
|
|
||||||
<span>Run folder maintenance (NFO repair, renaming, poster checks)</span>
|
|
||||||
</label>
|
|
||||||
<div class="form-help">Automatically repair NFOs, rename folders, and check posters during scheduled runs</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -768,7 +761,6 @@
|
|||||||
scheduler_schedule_time: document.getElementById('scheduler_schedule_time').value || '03:00',
|
scheduler_schedule_time: document.getElementById('scheduler_schedule_time').value || '03:00',
|
||||||
scheduler_schedule_days: Array.from(document.querySelectorAll('.scheduler-day-setup-cb:checked')).map(cb => cb.value),
|
scheduler_schedule_days: Array.from(document.querySelectorAll('.scheduler-day-setup-cb:checked')).map(cb => cb.value),
|
||||||
scheduler_auto_download_after_rescan: document.getElementById('scheduler_auto_download').checked,
|
scheduler_auto_download_after_rescan: document.getElementById('scheduler_auto_download').checked,
|
||||||
scheduler_folder_scan_enabled: document.getElementById('scheduler_folder_scan').checked,
|
|
||||||
logging_level: document.getElementById('logging_level').value,
|
logging_level: document.getElementById('logging_level').value,
|
||||||
logging_file: document.getElementById('logging_file').value.trim() || null,
|
logging_file: document.getElementById('logging_file').value.trim() || null,
|
||||||
logging_max_bytes: document.getElementById('logging_max_bytes').value ?
|
logging_max_bytes: document.getElementById('logging_max_bytes').value ?
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ async def test_update_config_with_anime_directory_starts_scheduler(
|
|||||||
"""PUT /api/config with anime_directory syncs and starts scheduler."""
|
"""PUT /api/config with anime_directory syncs and starts scheduler."""
|
||||||
mock_scheduler = AsyncMock()
|
mock_scheduler = AsyncMock()
|
||||||
|
|
||||||
with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_sched_fn:
|
with patch("src.server.services.scheduler.scheduler_service.get_scheduler_service") as mock_sched_fn:
|
||||||
mock_sched_fn.return_value = mock_scheduler
|
mock_sched_fn.return_value = mock_scheduler
|
||||||
|
|
||||||
with patch("src.config.settings.settings") as mock_settings:
|
with patch("src.config.settings.settings") as mock_settings:
|
||||||
@@ -238,7 +238,7 @@ async def test_update_config_without_anime_directory_does_not_start_scheduler(
|
|||||||
"""PUT /api/config without new anime_directory does not call scheduler.ensure_started()."""
|
"""PUT /api/config without new anime_directory does not call scheduler.ensure_started()."""
|
||||||
mock_scheduler = AsyncMock()
|
mock_scheduler = AsyncMock()
|
||||||
|
|
||||||
with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_sched_fn:
|
with patch("src.server.services.scheduler.scheduler_service.get_scheduler_service") as mock_sched_fn:
|
||||||
mock_sched_fn.return_value = mock_scheduler
|
mock_sched_fn.return_value = mock_scheduler
|
||||||
|
|
||||||
with patch("src.config.settings.settings") as mock_settings:
|
with patch("src.config.settings.settings") as mock_settings:
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ class TestNfoRepair:
|
|||||||
self, authenticated_client, override_dependencies
|
self, authenticated_client, override_dependencies
|
||||||
):
|
):
|
||||||
"""Test repair handles TMDB API failure gracefully."""
|
"""Test repair handles TMDB API failure gracefully."""
|
||||||
from src.core.services.tmdb_client import TMDBAPIError
|
from src.server.nfo.tmdb_client import TMDBAPIError
|
||||||
|
|
||||||
with patch("src.server.api.nfo.Path") as MockPath:
|
with patch("src.server.api.nfo.Path") as MockPath:
|
||||||
mock_path = Mock()
|
mock_path = Mock()
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ def mock_series_app_download(monkeypatch):
|
|||||||
"""
|
"""
|
||||||
# Mock the loader download method
|
# Mock the loader download method
|
||||||
try:
|
try:
|
||||||
from src.core.SeriesApp import SeriesApp
|
from src.server.SeriesApp import SeriesApp
|
||||||
|
|
||||||
# Patch the loader.download method for all SeriesApp instances
|
# Patch the loader.download method for all SeriesApp instances
|
||||||
original_init = SeriesApp.__init__
|
original_init = SeriesApp.__init__
|
||||||
|
|||||||
@@ -1,314 +0,0 @@
|
|||||||
"""Integration test: add an anime and verify NFO contains required information.
|
|
||||||
|
|
||||||
This test adds 'Sacrificial Princess And The King Of Beasts' and verifies
|
|
||||||
that the generated tvshow.nfo contains all required tags including plot,
|
|
||||||
outline, title, year, etc.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from lxml import etree
|
|
||||||
|
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Mock TMDB data for "Sacrificial Princess And The King Of Beasts"
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
MOCK_TMDB_DATA = {
|
|
||||||
"id": 222093,
|
|
||||||
"name": "Sacrificial Princess and the King of Beasts",
|
|
||||||
"original_name": "贄姫と獣の王",
|
|
||||||
"overview": (
|
|
||||||
"A girl is offered as a sacrifice to a beastly king, "
|
|
||||||
"but instead of being eaten, she becomes his bride."
|
|
||||||
),
|
|
||||||
"tagline": "A tale of love between a sacrifice and a beast king.",
|
|
||||||
"first_air_date": "2023-04-20",
|
|
||||||
"vote_average": 7.5,
|
|
||||||
"vote_count": 150,
|
|
||||||
"status": "Ended",
|
|
||||||
"episode_run_time": [24],
|
|
||||||
"genres": [
|
|
||||||
{"id": 16, "name": "Animation"},
|
|
||||||
{"id": 10749, "name": "Romance"},
|
|
||||||
],
|
|
||||||
"networks": [{"id": 1, "name": "TBS"}],
|
|
||||||
"origin_country": ["JP"],
|
|
||||||
"poster_path": "/poster.jpg",
|
|
||||||
"backdrop_path": "/backdrop.jpg",
|
|
||||||
"external_ids": {"imdb_id": "tt19896734", "tvdb_id": 421737},
|
|
||||||
"credits": {
|
|
||||||
"cast": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "Test Actor",
|
|
||||||
"character": "Sariphi",
|
|
||||||
"profile_path": "/actor.jpg",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"images": {"logos": [{"file_path": "/logo.png"}]},
|
|
||||||
"seasons": [{"season_number": 1, "name": "Season 1"}],
|
|
||||||
}
|
|
||||||
|
|
||||||
MOCK_CONTENT_RATINGS = {
|
|
||||||
"results": [
|
|
||||||
{"iso_3166_1": "DE", "rating": "12"},
|
|
||||||
{"iso_3166_1": "US", "rating": "TV-14"},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Required XML tags that must exist and be non-empty after creation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
REQUIRED_SINGLE_TAGS = [
|
|
||||||
"title",
|
|
||||||
"originaltitle",
|
|
||||||
"sorttitle",
|
|
||||||
"year",
|
|
||||||
"plot",
|
|
||||||
"outline",
|
|
||||||
"runtime",
|
|
||||||
"premiered",
|
|
||||||
"status",
|
|
||||||
"tmdbid",
|
|
||||||
"imdbid",
|
|
||||||
"tvdbid",
|
|
||||||
"dateadded",
|
|
||||||
"watched",
|
|
||||||
"mpaa",
|
|
||||||
"tagline",
|
|
||||||
]
|
|
||||||
|
|
||||||
REQUIRED_MULTI_TAGS = [
|
|
||||||
"genre",
|
|
||||||
"studio",
|
|
||||||
"country",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def anime_dir(tmp_path: Path) -> Path:
|
|
||||||
"""Temporary anime root directory."""
|
|
||||||
d = tmp_path / "anime"
|
|
||||||
d.mkdir()
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def nfo_service(anime_dir: Path) -> NFOService:
|
|
||||||
"""NFOService pointing at the temp directory."""
|
|
||||||
return NFOService(
|
|
||||||
tmdb_api_key="test_api_key",
|
|
||||||
anime_directory=str(anime_dir),
|
|
||||||
image_size="w500",
|
|
||||||
auto_create=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAddAnimeNFOContent:
|
|
||||||
"""Test that adding an anime produces an NFO with required information."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_add_anime_nfo_contains_required_tags(
|
|
||||||
self,
|
|
||||||
nfo_service: NFOService,
|
|
||||||
anime_dir: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Add 'Sacrificial Princess And The King Of Beasts' and verify NFO.
|
|
||||||
|
|
||||||
Steps:
|
|
||||||
1. Create the series folder on disk.
|
|
||||||
2. Mock TMDB API responses.
|
|
||||||
3. Call create_tvshow_nfo to generate the NFO.
|
|
||||||
4. Parse the resulting XML and assert every required tag is present
|
|
||||||
and non-empty.
|
|
||||||
"""
|
|
||||||
series_key = "sacrificial-princess-and-the-king-of-beasts"
|
|
||||||
series_name = "Sacrificial Princess And The King Of Beasts"
|
|
||||||
series_folder = f"{series_name} (2023)"
|
|
||||||
|
|
||||||
# Step 1: Create series folder
|
|
||||||
series_path = anime_dir / series_folder
|
|
||||||
series_path.mkdir()
|
|
||||||
|
|
||||||
# Step 2: Mock TMDB API calls
|
|
||||||
with patch.object(
|
|
||||||
nfo_service.tmdb_client,
|
|
||||||
"search_tv_show",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
) as mock_search, patch.object(
|
|
||||||
nfo_service.tmdb_client,
|
|
||||||
"get_tv_show_details",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
) as mock_details, patch.object(
|
|
||||||
nfo_service.tmdb_client,
|
|
||||||
"get_tv_show_content_ratings",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
) as mock_ratings, patch.object(
|
|
||||||
nfo_service.image_downloader,
|
|
||||||
"download_all_media",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
) as mock_download:
|
|
||||||
|
|
||||||
mock_search.return_value = {
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"id": 222093,
|
|
||||||
"name": series_name,
|
|
||||||
"first_air_date": "2023-04-20",
|
|
||||||
"overview": (
|
|
||||||
"A girl is offered as a sacrifice to a beastly king..."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
mock_details.return_value = MOCK_TMDB_DATA
|
|
||||||
mock_ratings.return_value = MOCK_CONTENT_RATINGS
|
|
||||||
mock_download.return_value = {
|
|
||||||
"poster": True,
|
|
||||||
"logo": True,
|
|
||||||
"fanart": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Step 3: Create NFO
|
|
||||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
|
||||||
serie_name=series_name,
|
|
||||||
serie_folder=series_folder,
|
|
||||||
year=2023,
|
|
||||||
download_poster=True,
|
|
||||||
download_logo=True,
|
|
||||||
download_fanart=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify NFO was created
|
|
||||||
assert nfo_path.exists(), f"NFO file not created at {nfo_path}"
|
|
||||||
assert nfo_path.name == "tvshow.nfo"
|
|
||||||
|
|
||||||
# Step 4: Parse NFO XML and verify required tags
|
|
||||||
nfo_content = nfo_path.read_text(encoding="utf-8")
|
|
||||||
root = etree.fromstring(nfo_content.encode("utf-8"))
|
|
||||||
|
|
||||||
missing: list[str] = []
|
|
||||||
for tag in REQUIRED_SINGLE_TAGS:
|
|
||||||
elem = root.find(f".//{tag}")
|
|
||||||
if elem is None or not (elem.text or "").strip():
|
|
||||||
missing.append(tag)
|
|
||||||
|
|
||||||
for tag in REQUIRED_MULTI_TAGS:
|
|
||||||
elems = root.findall(f".//{tag}")
|
|
||||||
if not elems or not any((e.text or "").strip() for e in elems):
|
|
||||||
missing.append(tag)
|
|
||||||
|
|
||||||
# At least one actor must be present
|
|
||||||
actors = root.findall(".//actor/name")
|
|
||||||
if not actors or not any((a.text or "").strip() for a in actors):
|
|
||||||
missing.append("actor/name")
|
|
||||||
|
|
||||||
assert not missing, (
|
|
||||||
f"Missing or empty required tags in NFO for '{series_name}':\n "
|
|
||||||
+ "\n ".join(missing)
|
|
||||||
+ f"\n\nFull NFO content:\n{nfo_content}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify specific values for the requested anime
|
|
||||||
assert root.findtext(".//title") == "Sacrificial Princess and the King of Beasts"
|
|
||||||
assert root.findtext(".//year") == "2023"
|
|
||||||
assert root.findtext(".//status") == "Ended"
|
|
||||||
assert root.findtext(".//watched") == "false"
|
|
||||||
assert root.findtext(".//tmdbid") == "222093"
|
|
||||||
assert root.findtext(".//imdbid") == "tt19896734"
|
|
||||||
assert root.findtext(".//tvdbid") == "421737"
|
|
||||||
|
|
||||||
# Plot and outline must be non-trivial
|
|
||||||
plot = root.findtext(".//plot") or ""
|
|
||||||
outline = root.findtext(".//outline") or ""
|
|
||||||
assert len(plot) >= 10, f"plot too short: {plot!r}"
|
|
||||||
assert len(outline) >= 10, f"outline too short: {outline!r}"
|
|
||||||
|
|
||||||
# Verify multi-value fields
|
|
||||||
genres = [g.text for g in root.findall(".//genre") if g.text]
|
|
||||||
assert "Animation" in genres
|
|
||||||
assert "Romance" in genres
|
|
||||||
|
|
||||||
studios = [s.text for s in root.findall(".//studio") if s.text]
|
|
||||||
assert "TBS" in studios
|
|
||||||
|
|
||||||
countries = [c.text for c in root.findall(".//country") if c.text]
|
|
||||||
assert "JP" in countries
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_add_anime_nfo_has_plot_and_outline(
|
|
||||||
self,
|
|
||||||
nfo_service: NFOService,
|
|
||||||
anime_dir: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Specifically verify that plot and outline tags are populated.
|
|
||||||
|
|
||||||
This is a focused regression test ensuring the NFO always contains
|
|
||||||
meaningful plot and outline data.
|
|
||||||
"""
|
|
||||||
series_name = "Sacrificial Princess And The King Of Beasts"
|
|
||||||
series_folder = f"{series_name} (2023)"
|
|
||||||
series_path = anime_dir / series_folder
|
|
||||||
series_path.mkdir()
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
nfo_service.tmdb_client,
|
|
||||||
"search_tv_show",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
) as mock_search, patch.object(
|
|
||||||
nfo_service.tmdb_client,
|
|
||||||
"get_tv_show_details",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
) as mock_details, patch.object(
|
|
||||||
nfo_service.tmdb_client,
|
|
||||||
"get_tv_show_content_ratings",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
) as mock_ratings, patch.object(
|
|
||||||
nfo_service.image_downloader,
|
|
||||||
"download_all_media",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
) as mock_download:
|
|
||||||
|
|
||||||
mock_search.return_value = {
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"id": 222093,
|
|
||||||
"name": series_name,
|
|
||||||
"first_air_date": "2023-04-20",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
mock_details.return_value = MOCK_TMDB_DATA
|
|
||||||
mock_ratings.return_value = MOCK_CONTENT_RATINGS
|
|
||||||
mock_download.return_value = {"poster": False, "logo": False, "fanart": False}
|
|
||||||
|
|
||||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
|
||||||
serie_name=series_name,
|
|
||||||
serie_folder=series_folder,
|
|
||||||
year=2023,
|
|
||||||
download_poster=False,
|
|
||||||
download_logo=False,
|
|
||||||
download_fanart=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert nfo_path.exists()
|
|
||||||
root = etree.parse(str(nfo_path)).getroot()
|
|
||||||
|
|
||||||
plot_elem = root.find(".//plot")
|
|
||||||
outline_elem = root.find(".//outline")
|
|
||||||
|
|
||||||
assert plot_elem is not None, "<plot> tag missing from NFO"
|
|
||||||
assert outline_elem is not None, "<outline> tag missing from NFO"
|
|
||||||
|
|
||||||
plot_text = (plot_elem.text or "").strip()
|
|
||||||
outline_text = (outline_elem.text or "").strip()
|
|
||||||
|
|
||||||
assert plot_text, "<plot> tag is empty"
|
|
||||||
assert outline_text, "<outline> tag is empty"
|
|
||||||
assert "sacrifice" in plot_text.lower() or "beast" in plot_text.lower(), (
|
|
||||||
f"plot does not contain expected content: {plot_text!r}"
|
|
||||||
)
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
"""Integration tests to verify anime add only loads NFO/artwork for the specific anime.
|
|
||||||
|
|
||||||
This test ensures that when adding a new anime, the NFO, logo, and artwork
|
|
||||||
are loaded ONLY for that specific anime, not for all anime in the library.
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, Mock, call, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.server.services.background_loader_service import BackgroundLoaderService
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def temp_anime_dir(tmp_path):
|
|
||||||
"""Create temporary anime directory with existing anime."""
|
|
||||||
anime_dir = tmp_path / "anime"
|
|
||||||
anime_dir.mkdir()
|
|
||||||
|
|
||||||
# Create two existing anime directories
|
|
||||||
existing_anime_1 = anime_dir / "Existing Anime 1"
|
|
||||||
existing_anime_1.mkdir()
|
|
||||||
(existing_anime_1 / "data").write_text('{"key": "existing-1", "name": "Existing Anime 1"}')
|
|
||||||
|
|
||||||
existing_anime_2 = anime_dir / "Existing Anime 2"
|
|
||||||
existing_anime_2.mkdir()
|
|
||||||
(existing_anime_2 / "data").write_text('{"key": "existing-2", "name": "Existing Anime 2"}')
|
|
||||||
|
|
||||||
return str(anime_dir)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_series_app(temp_anime_dir):
|
|
||||||
"""Create mock SeriesApp."""
|
|
||||||
app = MagicMock()
|
|
||||||
app.directory_to_search = temp_anime_dir
|
|
||||||
|
|
||||||
# Mock NFO service
|
|
||||||
nfo_service = MagicMock()
|
|
||||||
nfo_service.has_nfo = MagicMock(return_value=False)
|
|
||||||
nfo_service.create_tvshow_nfo = AsyncMock()
|
|
||||||
app.nfo_service = nfo_service
|
|
||||||
|
|
||||||
# Mock series list
|
|
||||||
app.list = MagicMock()
|
|
||||||
app.list.keyDict = {}
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_websocket_service():
|
|
||||||
"""Create mock WebSocket service."""
|
|
||||||
service = MagicMock()
|
|
||||||
service.broadcast = AsyncMock()
|
|
||||||
service.broadcast_to_room = AsyncMock()
|
|
||||||
return service
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_anime_service():
|
|
||||||
"""Create mock AnimeService."""
|
|
||||||
service = MagicMock()
|
|
||||||
service.rescan_series = AsyncMock()
|
|
||||||
return service
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def mock_database():
|
|
||||||
"""Mock database access for all NFO isolation tests."""
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
mock_db.commit = AsyncMock()
|
|
||||||
|
|
||||||
with patch("src.server.database.connection.get_db_session") as mock_get_db, patch("src.server.database.service.AnimeSeriesService") as mock_service:
|
|
||||||
mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db)
|
|
||||||
mock_get_db.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
mock_service.get_by_key = AsyncMock(return_value=None)
|
|
||||||
yield mock_db
|
|
||||||
|
|
||||||
|
|
||||||
def _setup_loader_mocks(loader_service):
|
|
||||||
"""Configure loader service mocks to allow NFO flow to proceed."""
|
|
||||||
loader_service.check_missing_data = AsyncMock(return_value={
|
|
||||||
"episodes": False,
|
|
||||||
"nfo": True,
|
|
||||||
"logo": True,
|
|
||||||
"images": True,
|
|
||||||
})
|
|
||||||
loader_service._scan_missing_episodes = AsyncMock()
|
|
||||||
loader_service._broadcast_status = AsyncMock()
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_nfo_factory(mock_nfo_service):
|
|
||||||
"""Create a mock NFO factory that returns the given mock service."""
|
|
||||||
mock_factory = MagicMock()
|
|
||||||
mock_factory.create = MagicMock(return_value=mock_nfo_service)
|
|
||||||
return mock_factory
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_add_anime_loads_nfo_only_for_new_anime(
|
|
||||||
temp_anime_dir,
|
|
||||||
mock_series_app,
|
|
||||||
mock_websocket_service,
|
|
||||||
mock_anime_service,
|
|
||||||
):
|
|
||||||
"""Test that adding a new anime only loads NFO/artwork for that specific anime.
|
|
||||||
|
|
||||||
This test verifies:
|
|
||||||
1. NFO service is called only once for the new anime
|
|
||||||
2. The call is made with the correct anime name/folder
|
|
||||||
3. Existing anime are not affected
|
|
||||||
"""
|
|
||||||
loader_service = BackgroundLoaderService(
|
|
||||||
websocket_service=mock_websocket_service,
|
|
||||||
anime_service=mock_anime_service,
|
|
||||||
series_app=mock_series_app,
|
|
||||||
)
|
|
||||||
_setup_loader_mocks(loader_service)
|
|
||||||
|
|
||||||
# Set up mock NFO service via factory
|
|
||||||
mock_nfo_service = AsyncMock()
|
|
||||||
mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/New Anime (2024)/tvshow.nfo")
|
|
||||||
mock_factory = _mock_nfo_factory(mock_nfo_service)
|
|
||||||
|
|
||||||
with patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory):
|
|
||||||
await loader_service.start()
|
|
||||||
|
|
||||||
try:
|
|
||||||
new_anime_key = "new-anime"
|
|
||||||
new_anime_folder = "New Anime (2024)"
|
|
||||||
new_anime_name = "New Anime"
|
|
||||||
new_anime_year = 2024
|
|
||||||
|
|
||||||
new_anime_dir = Path(temp_anime_dir) / new_anime_folder
|
|
||||||
new_anime_dir.mkdir()
|
|
||||||
|
|
||||||
await loader_service.add_series_loading_task(
|
|
||||||
key=new_anime_key,
|
|
||||||
folder=new_anime_folder,
|
|
||||||
name=new_anime_name,
|
|
||||||
year=new_anime_year,
|
|
||||||
)
|
|
||||||
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
assert mock_nfo_service.create_tvshow_nfo.call_count == 1
|
|
||||||
|
|
||||||
call_args = mock_nfo_service.create_tvshow_nfo.call_args
|
|
||||||
assert call_args is not None
|
|
||||||
|
|
||||||
kwargs = call_args.kwargs
|
|
||||||
assert kwargs["serie_name"] == new_anime_name
|
|
||||||
assert kwargs["serie_folder"] == new_anime_folder
|
|
||||||
assert kwargs["year"] == new_anime_year
|
|
||||||
assert kwargs["download_poster"] is True
|
|
||||||
assert kwargs["download_logo"] is True
|
|
||||||
assert kwargs["download_fanart"] is True
|
|
||||||
|
|
||||||
all_calls = mock_nfo_service.create_tvshow_nfo.call_args_list
|
|
||||||
for call_obj in all_calls:
|
|
||||||
call_kwargs = call_obj.kwargs
|
|
||||||
assert call_kwargs["serie_name"] != "Existing Anime 1"
|
|
||||||
assert call_kwargs["serie_name"] != "Existing Anime 2"
|
|
||||||
assert call_kwargs["serie_folder"] != "Existing Anime 1"
|
|
||||||
assert call_kwargs["serie_folder"] != "Existing Anime 2"
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await loader_service.stop()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_add_anime_has_nfo_check_is_isolated(
|
|
||||||
temp_anime_dir,
|
|
||||||
mock_series_app,
|
|
||||||
mock_websocket_service,
|
|
||||||
mock_anime_service,
|
|
||||||
):
|
|
||||||
"""Test that has_nfo check is called only for the specific anime being added."""
|
|
||||||
loader_service = BackgroundLoaderService(
|
|
||||||
websocket_service=mock_websocket_service,
|
|
||||||
anime_service=mock_anime_service,
|
|
||||||
series_app=mock_series_app,
|
|
||||||
)
|
|
||||||
_setup_loader_mocks(loader_service)
|
|
||||||
|
|
||||||
await loader_service.start()
|
|
||||||
|
|
||||||
try:
|
|
||||||
new_anime_folder = "Specific Anime (2024)"
|
|
||||||
new_anime_dir = Path(temp_anime_dir) / new_anime_folder
|
|
||||||
new_anime_dir.mkdir()
|
|
||||||
|
|
||||||
await loader_service.add_series_loading_task(
|
|
||||||
key="specific-anime",
|
|
||||||
folder=new_anime_folder,
|
|
||||||
name="Specific Anime",
|
|
||||||
year=2024,
|
|
||||||
)
|
|
||||||
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
assert mock_series_app.nfo_service.has_nfo.call_count >= 1
|
|
||||||
|
|
||||||
call_args_list = mock_series_app.nfo_service.has_nfo.call_args_list
|
|
||||||
folders_checked = [call_obj[0][0] for call_obj in call_args_list]
|
|
||||||
|
|
||||||
assert new_anime_folder in folders_checked
|
|
||||||
assert "Existing Anime 1" not in folders_checked
|
|
||||||
assert "Existing Anime 2" not in folders_checked
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await loader_service.stop()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_multiple_anime_added_each_loads_independently(
|
|
||||||
temp_anime_dir,
|
|
||||||
mock_series_app,
|
|
||||||
mock_websocket_service,
|
|
||||||
mock_anime_service,
|
|
||||||
):
|
|
||||||
"""Test that adding multiple anime loads NFO/artwork for each one independently."""
|
|
||||||
loader_service = BackgroundLoaderService(
|
|
||||||
websocket_service=mock_websocket_service,
|
|
||||||
anime_service=mock_anime_service,
|
|
||||||
series_app=mock_series_app,
|
|
||||||
)
|
|
||||||
_setup_loader_mocks(loader_service)
|
|
||||||
|
|
||||||
# Set up mock NFO service via factory
|
|
||||||
mock_nfo_service = AsyncMock()
|
|
||||||
mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/tvshow.nfo")
|
|
||||||
mock_factory = _mock_nfo_factory(mock_nfo_service)
|
|
||||||
|
|
||||||
with patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory):
|
|
||||||
await loader_service.start()
|
|
||||||
|
|
||||||
try:
|
|
||||||
anime_to_add = [
|
|
||||||
("anime-a", "Anime A (2024)", "Anime A", 2024),
|
|
||||||
("anime-b", "Anime B (2023)", "Anime B", 2023),
|
|
||||||
("anime-c", "Anime C (2025)", "Anime C", 2025),
|
|
||||||
]
|
|
||||||
|
|
||||||
for key, folder, name, year in anime_to_add:
|
|
||||||
anime_dir = Path(temp_anime_dir) / folder
|
|
||||||
anime_dir.mkdir()
|
|
||||||
|
|
||||||
await loader_service.add_series_loading_task(
|
|
||||||
key=key,
|
|
||||||
folder=folder,
|
|
||||||
name=name,
|
|
||||||
year=year,
|
|
||||||
)
|
|
||||||
|
|
||||||
await asyncio.sleep(2.0)
|
|
||||||
|
|
||||||
assert mock_nfo_service.create_tvshow_nfo.call_count == 3
|
|
||||||
|
|
||||||
all_calls = mock_nfo_service.create_tvshow_nfo.call_args_list
|
|
||||||
|
|
||||||
called_names = [call_obj.kwargs["serie_name"] for call_obj in all_calls]
|
|
||||||
called_folders = [call_obj.kwargs["serie_folder"] for call_obj in all_calls]
|
|
||||||
|
|
||||||
assert "Anime A" in called_names
|
|
||||||
assert "Anime B" in called_names
|
|
||||||
assert "Anime C" in called_names
|
|
||||||
|
|
||||||
assert "Anime A (2024)" in called_folders
|
|
||||||
assert "Anime B (2023)" in called_folders
|
|
||||||
assert "Anime C (2025)" in called_folders
|
|
||||||
|
|
||||||
assert "Existing Anime 1" not in called_names
|
|
||||||
assert "Existing Anime 2" not in called_names
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await loader_service.stop()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_service_receives_correct_parameters(
|
|
||||||
temp_anime_dir,
|
|
||||||
mock_series_app,
|
|
||||||
mock_websocket_service,
|
|
||||||
mock_anime_service,
|
|
||||||
):
|
|
||||||
"""Test that NFO service receives all required parameters for the specific anime."""
|
|
||||||
loader_service = BackgroundLoaderService(
|
|
||||||
websocket_service=mock_websocket_service,
|
|
||||||
anime_service=mock_anime_service,
|
|
||||||
series_app=mock_series_app,
|
|
||||||
)
|
|
||||||
_setup_loader_mocks(loader_service)
|
|
||||||
|
|
||||||
# Set up mock NFO service via factory
|
|
||||||
mock_nfo_service = AsyncMock()
|
|
||||||
mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/Test Anime Series (2024)/tvshow.nfo")
|
|
||||||
mock_factory = _mock_nfo_factory(mock_nfo_service)
|
|
||||||
|
|
||||||
with patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory):
|
|
||||||
await loader_service.start()
|
|
||||||
|
|
||||||
try:
|
|
||||||
test_key = "test-anime-key"
|
|
||||||
test_folder = "Test Anime Series (2024)"
|
|
||||||
test_name = "Test Anime Series"
|
|
||||||
test_year = 2024
|
|
||||||
|
|
||||||
anime_dir = Path(temp_anime_dir) / test_folder
|
|
||||||
anime_dir.mkdir()
|
|
||||||
|
|
||||||
await loader_service.add_series_loading_task(
|
|
||||||
key=test_key,
|
|
||||||
folder=test_folder,
|
|
||||||
name=test_name,
|
|
||||||
year=test_year,
|
|
||||||
)
|
|
||||||
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
assert mock_nfo_service.create_tvshow_nfo.call_count == 1
|
|
||||||
|
|
||||||
call_kwargs = mock_nfo_service.create_tvshow_nfo.call_args.kwargs
|
|
||||||
|
|
||||||
assert call_kwargs["serie_name"] == test_name
|
|
||||||
assert call_kwargs["serie_folder"] == test_folder
|
|
||||||
assert call_kwargs["year"] == test_year
|
|
||||||
assert call_kwargs["download_poster"] is True
|
|
||||||
assert call_kwargs["download_logo"] is True
|
|
||||||
assert call_kwargs["download_fanart"] is True
|
|
||||||
|
|
||||||
assert "Existing Anime" not in str(call_kwargs)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await loader_service.stop()
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
"""Integration tests for CLI workflows.
|
|
||||||
|
|
||||||
Tests end-to-end CLI command execution using subprocess-style invocation of
|
|
||||||
the nfo_cli main() function with mocked services.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.cli.nfo_cli import main, scan_and_create_nfo, update_nfo_files
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_serie(name: str, has_nfo: bool = False):
|
|
||||||
"""Create a mock serie object."""
|
|
||||||
s = MagicMock()
|
|
||||||
s.name = name
|
|
||||||
s.folder = name
|
|
||||||
s.has_nfo.return_value = has_nfo
|
|
||||||
s.has_poster.return_value = False
|
|
||||||
s.has_logo.return_value = False
|
|
||||||
s.has_fanart.return_value = False
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
class TestScanWorkflow:
|
|
||||||
"""End-to-end scan workflow."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("src.cli.nfo_cli.SeriesManagerService")
|
|
||||||
@patch("src.cli.nfo_cli.settings")
|
|
||||||
async def test_scan_creates_nfo_and_closes(self, mock_settings, mock_sms):
|
|
||||||
"""Full scan workflow: init → scan → close."""
|
|
||||||
mock_settings.tmdb_api_key = "key"
|
|
||||||
mock_settings.anime_directory = "/anime"
|
|
||||||
mock_settings.nfo_auto_create = True
|
|
||||||
mock_settings.nfo_update_on_scan = False
|
|
||||||
mock_settings.nfo_download_poster = False
|
|
||||||
mock_settings.nfo_download_logo = False
|
|
||||||
mock_settings.nfo_download_fanart = False
|
|
||||||
|
|
||||||
series = [_mock_serie("Naruto"), _mock_serie("Bleach")]
|
|
||||||
mock_serie_list = MagicMock()
|
|
||||||
mock_serie_list.get_all.return_value = series
|
|
||||||
mock_serie_list.load_series = MagicMock()
|
|
||||||
|
|
||||||
manager = MagicMock()
|
|
||||||
manager.get_serie_list.return_value = mock_serie_list
|
|
||||||
manager.scan_and_process_nfo = AsyncMock()
|
|
||||||
manager.close = AsyncMock()
|
|
||||||
mock_sms.from_settings.return_value = manager
|
|
||||||
|
|
||||||
result = await scan_and_create_nfo()
|
|
||||||
|
|
||||||
assert result == 0
|
|
||||||
manager.scan_and_process_nfo.assert_awaited_once()
|
|
||||||
manager.close.assert_awaited_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("src.cli.nfo_cli.SeriesManagerService")
|
|
||||||
@patch("src.cli.nfo_cli.settings")
|
|
||||||
async def test_scan_all_have_nfo_and_no_update(self, mock_settings, mock_sms):
|
|
||||||
"""When all series have NFO and update_on_scan is False, returns 0 early."""
|
|
||||||
mock_settings.tmdb_api_key = "key"
|
|
||||||
mock_settings.anime_directory = "/anime"
|
|
||||||
mock_settings.nfo_auto_create = True
|
|
||||||
mock_settings.nfo_update_on_scan = False
|
|
||||||
mock_settings.nfo_download_poster = False
|
|
||||||
mock_settings.nfo_download_logo = False
|
|
||||||
mock_settings.nfo_download_fanart = False
|
|
||||||
|
|
||||||
series = [_mock_serie("Naruto", has_nfo=True)]
|
|
||||||
mock_serie_list = MagicMock()
|
|
||||||
mock_serie_list.get_all.return_value = series
|
|
||||||
|
|
||||||
manager = MagicMock()
|
|
||||||
manager.get_serie_list.return_value = mock_serie_list
|
|
||||||
mock_sms.from_settings.return_value = manager
|
|
||||||
|
|
||||||
result = await scan_and_create_nfo()
|
|
||||||
assert result == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateWorkflow:
|
|
||||||
"""End-to-end update workflow."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("src.cli.nfo_cli.asyncio")
|
|
||||||
@patch("src.cli.nfo_cli.settings")
|
|
||||||
async def test_update_processes_each_serie(self, mock_settings, mock_sleeper):
|
|
||||||
"""Update calls update_tvshow_nfo for every serie with NFO."""
|
|
||||||
mock_settings.tmdb_api_key = "key"
|
|
||||||
mock_settings.anime_directory = "/anime"
|
|
||||||
mock_settings.nfo_download_poster = True
|
|
||||||
mock_settings.nfo_download_logo = False
|
|
||||||
mock_settings.nfo_download_fanart = False
|
|
||||||
mock_sleeper.sleep = AsyncMock()
|
|
||||||
|
|
||||||
series = [
|
|
||||||
_mock_serie("A", has_nfo=True),
|
|
||||||
_mock_serie("B", has_nfo=True),
|
|
||||||
_mock_serie("C", has_nfo=False),
|
|
||||||
]
|
|
||||||
|
|
||||||
nfo_svc = MagicMock()
|
|
||||||
nfo_svc.update_tvshow_nfo = AsyncMock()
|
|
||||||
nfo_svc.close = AsyncMock()
|
|
||||||
|
|
||||||
with patch("src.core.entities.SerieList.SerieList") as mock_sl:
|
|
||||||
mock_list = MagicMock()
|
|
||||||
mock_list.get_all.return_value = series
|
|
||||||
mock_sl.return_value = mock_list
|
|
||||||
with patch(
|
|
||||||
"src.core.services.nfo_factory.create_nfo_service",
|
|
||||||
return_value=nfo_svc,
|
|
||||||
):
|
|
||||||
result = await update_nfo_files()
|
|
||||||
|
|
||||||
assert result == 0
|
|
||||||
# Only A and B have NFO
|
|
||||||
assert nfo_svc.update_tvshow_nfo.await_count == 2
|
|
||||||
nfo_svc.close.assert_awaited_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("src.cli.nfo_cli.asyncio")
|
|
||||||
@patch("src.cli.nfo_cli.settings")
|
|
||||||
async def test_update_continues_on_per_series_error(
|
|
||||||
self, mock_settings, mock_sleeper
|
|
||||||
):
|
|
||||||
"""An error updating one serie doesn't stop others."""
|
|
||||||
mock_settings.tmdb_api_key = "key"
|
|
||||||
mock_settings.anime_directory = "/anime"
|
|
||||||
mock_settings.nfo_download_poster = False
|
|
||||||
mock_settings.nfo_download_logo = False
|
|
||||||
mock_settings.nfo_download_fanart = False
|
|
||||||
mock_sleeper.sleep = AsyncMock()
|
|
||||||
|
|
||||||
series = [
|
|
||||||
_mock_serie("OK", has_nfo=True),
|
|
||||||
_mock_serie("Fail", has_nfo=True),
|
|
||||||
]
|
|
||||||
|
|
||||||
nfo_svc = MagicMock()
|
|
||||||
# First call succeeds, second raises
|
|
||||||
nfo_svc.update_tvshow_nfo = AsyncMock(
|
|
||||||
side_effect=[None, RuntimeError("api down")]
|
|
||||||
)
|
|
||||||
nfo_svc.close = AsyncMock()
|
|
||||||
|
|
||||||
with patch("src.core.entities.SerieList.SerieList") as mock_sl:
|
|
||||||
mock_list = MagicMock()
|
|
||||||
mock_list.get_all.return_value = series
|
|
||||||
mock_sl.return_value = mock_list
|
|
||||||
with patch(
|
|
||||||
"src.core.services.nfo_factory.create_nfo_service",
|
|
||||||
return_value=nfo_svc,
|
|
||||||
):
|
|
||||||
result = await update_nfo_files()
|
|
||||||
|
|
||||||
assert result == 0
|
|
||||||
assert nfo_svc.update_tvshow_nfo.await_count == 2
|
|
||||||
|
|
||||||
|
|
||||||
class TestErrorHandlingWorkflows:
|
|
||||||
"""Test error paths in CLI workflows."""
|
|
||||||
|
|
||||||
@patch("src.cli.nfo_cli.sys")
|
|
||||||
def test_main_usage_printed_on_no_args(self, mock_sys, capsys):
|
|
||||||
"""Shows usage and returns 1 with no args."""
|
|
||||||
mock_sys.argv = ["nfo_cli"]
|
|
||||||
result = main()
|
|
||||||
assert result == 1
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("src.cli.nfo_cli.settings")
|
|
||||||
async def test_missing_config_returns_1(self, mock_settings):
|
|
||||||
"""Missing required settings yields exit code 1."""
|
|
||||||
mock_settings.tmdb_api_key = None
|
|
||||||
assert await scan_and_create_nfo() == 1
|
|
||||||
|
|
||||||
mock_settings.tmdb_api_key = "key"
|
|
||||||
mock_settings.anime_directory = None
|
|
||||||
assert await scan_and_create_nfo() == 1
|
|
||||||
@@ -55,49 +55,12 @@ class TestConcurrentDownloads:
|
|||||||
assert DownloadStatus.FAILED is not None
|
assert DownloadStatus.FAILED is not None
|
||||||
|
|
||||||
|
|
||||||
class TestParallelNfoGeneration:
|
|
||||||
"""Parallel NFO creation for multiple series."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("src.core.services.series_manager_service.SerieList")
|
|
||||||
async def test_multiple_series_process_sequentially(self, mock_sl):
|
|
||||||
"""process_nfo_for_series called for each serie in order."""
|
|
||||||
from src.core.services.series_manager_service import SeriesManagerService
|
|
||||||
|
|
||||||
manager = SeriesManagerService(
|
|
||||||
anime_directory="/anime",
|
|
||||||
tmdb_api_key=None,
|
|
||||||
)
|
|
||||||
# Without nfo_service, should be no-op
|
|
||||||
await manager.process_nfo_for_series(
|
|
||||||
serie_folder="test-folder",
|
|
||||||
serie_name="Test Anime",
|
|
||||||
serie_key="test-key",
|
|
||||||
)
|
|
||||||
# No exception raised
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_concurrent_factory_calls_return_same_singleton(self):
|
|
||||||
"""get_nfo_factory returns the same instance across concurrent calls."""
|
|
||||||
from src.core.services.nfo_factory import get_nfo_factory
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
async def get_factory():
|
|
||||||
results.append(get_nfo_factory())
|
|
||||||
|
|
||||||
tasks = [get_factory() for _ in range(5)]
|
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
|
|
||||||
assert all(r is results[0] for r in results)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCacheConsistency:
|
class TestCacheConsistency:
|
||||||
"""Cache consistency under concurrent access."""
|
"""Cache consistency under concurrent access."""
|
||||||
|
|
||||||
def test_provider_cache_key_uniqueness(self):
|
def test_provider_cache_key_uniqueness(self):
|
||||||
"""Different inputs produce different cache keys."""
|
"""Different inputs produce different cache keys."""
|
||||||
from src.core.providers.aniworld_provider import AniworldLoader
|
from src.server.providers.aniworld_provider import AniworldLoader
|
||||||
|
|
||||||
loader = AniworldLoader.__new__(AniworldLoader)
|
loader = AniworldLoader.__new__(AniworldLoader)
|
||||||
loader.cache = {}
|
loader.cache = {}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ from unittest.mock import AsyncMock, Mock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
from src.core.SeriesApp import SeriesApp
|
from src.server.SeriesApp import SeriesApp
|
||||||
|
|
||||||
|
|
||||||
class TestGetAllSeriesFromDataFiles:
|
class TestGetAllSeriesFromDataFiles:
|
||||||
@@ -29,8 +29,8 @@ class TestGetAllSeriesFromDataFiles:
|
|||||||
def test_returns_empty_list_for_empty_directory(self):
|
def test_returns_empty_list_for_empty_directory(self):
|
||||||
"""Test that empty directory returns empty list."""
|
"""Test that empty directory returns empty list."""
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.server.SeriesApp.SerieScanner'):
|
||||||
app = SeriesApp(tmp_dir)
|
app = SeriesApp(tmp_dir)
|
||||||
result = app.get_all_series_from_data_files()
|
result = app.get_all_series_from_data_files()
|
||||||
|
|
||||||
@@ -56,8 +56,8 @@ class TestGetAllSeriesFromDataFiles:
|
|||||||
episodes={1: [1]}
|
episodes={1: [1]}
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.server.SeriesApp.SerieScanner'):
|
||||||
app = SeriesApp(tmp_dir)
|
app = SeriesApp(tmp_dir)
|
||||||
result = app.get_all_series_from_data_files()
|
result = app.get_all_series_from_data_files()
|
||||||
|
|
||||||
@@ -85,8 +85,8 @@ class TestGetAllSeriesFromDataFiles:
|
|||||||
with open(os.path.join(corrupt_dir, "data"), "w") as f:
|
with open(os.path.join(corrupt_dir, "data"), "w") as f:
|
||||||
f.write("this is not valid json {{{")
|
f.write("this is not valid json {{{")
|
||||||
|
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.server.SeriesApp.SerieScanner'):
|
||||||
app = SeriesApp(tmp_dir)
|
app = SeriesApp(tmp_dir)
|
||||||
result = app.get_all_series_from_data_files()
|
result = app.get_all_series_from_data_files()
|
||||||
|
|
||||||
@@ -101,8 +101,8 @@ class TestGetAllSeriesFromDataFiles:
|
|||||||
"""Test that non-existent directory returns empty list."""
|
"""Test that non-existent directory returns empty list."""
|
||||||
non_existent_dir = "/non/existent/directory/path"
|
non_existent_dir = "/non/existent/directory/path"
|
||||||
|
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.server.SeriesApp.SerieScanner'):
|
||||||
app = SeriesApp(non_existent_dir)
|
app = SeriesApp(non_existent_dir)
|
||||||
result = app.get_all_series_from_data_files()
|
result = app.get_all_series_from_data_files()
|
||||||
|
|
||||||
@@ -119,8 +119,8 @@ class TestSyncSeriesToDatabase:
|
|||||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.server.SeriesApp.SerieScanner'):
|
||||||
count = await sync_legacy_series_to_db(tmp_dir)
|
count = await sync_legacy_series_to_db(tmp_dir)
|
||||||
|
|
||||||
assert count == 0
|
assert count == 0
|
||||||
@@ -147,8 +147,8 @@ class TestSyncSeriesToDatabase:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# First verify that we can load the series from files
|
# First verify that we can load the series from files
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.server.SeriesApp.SerieScanner'):
|
||||||
app = SeriesApp(tmp_dir)
|
app = SeriesApp(tmp_dir)
|
||||||
series = app.get_all_series_from_data_files()
|
series = app.get_all_series_from_data_files()
|
||||||
assert len(series) == 1
|
assert len(series) == 1
|
||||||
@@ -156,8 +156,8 @@ class TestSyncSeriesToDatabase:
|
|||||||
|
|
||||||
# Now test that the sync function loads series and handles DB
|
# Now test that the sync function loads series and handles DB
|
||||||
# gracefully (even if DB operations fail, it should not crash)
|
# gracefully (even if DB operations fail, it should not crash)
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.server.SeriesApp.SerieScanner'):
|
||||||
# The function should return 0 because DB isn't available
|
# The function should return 0 because DB isn't available
|
||||||
# but should not crash
|
# but should not crash
|
||||||
count = await sync_legacy_series_to_db(tmp_dir)
|
count = await sync_legacy_series_to_db(tmp_dir)
|
||||||
@@ -173,10 +173,10 @@ class TestSyncSeriesToDatabase:
|
|||||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||||
|
|
||||||
# Make SeriesApp raise an exception during initialization
|
# Make SeriesApp raise an exception during initialization
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'), \
|
patch('src.server.SeriesApp.SerieScanner'), \
|
||||||
patch(
|
patch(
|
||||||
'src.core.SeriesApp.SerieList',
|
'src.server.SeriesApp.SerieList',
|
||||||
side_effect=Exception("Test error")
|
side_effect=Exception("Test error")
|
||||||
):
|
):
|
||||||
count = await sync_legacy_series_to_db("/fake/path")
|
count = await sync_legacy_series_to_db("/fake/path")
|
||||||
@@ -210,8 +210,8 @@ class TestEndToEndSync:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Use SeriesApp to load series from files
|
# Use SeriesApp to load series from files
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.server.SeriesApp.SerieScanner'):
|
||||||
app = SeriesApp(tmp_dir)
|
app = SeriesApp(tmp_dir)
|
||||||
all_series = app.get_all_series_from_data_files()
|
all_series = app.get_all_series_from_data_files()
|
||||||
|
|
||||||
|
|||||||
@@ -1,532 +0,0 @@
|
|||||||
"""
|
|
||||||
End-to-end workflow integration tests.
|
|
||||||
|
|
||||||
Tests complete workflows through the actual service layers and APIs,
|
|
||||||
without mocking internal implementation details. These tests verify
|
|
||||||
that major system flows work correctly end-to-end.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.server.services import initialization_service
|
|
||||||
|
|
||||||
|
|
||||||
class TestInitializationWorkflow:
|
|
||||||
"""Test initialization workflow."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_perform_initial_setup_with_mocked_dependencies(self):
|
|
||||||
"""Test initial setup completes with minimal mocking."""
|
|
||||||
# Mock only the external dependencies
|
|
||||||
with patch('src.server.services.anime_service.sync_legacy_series_to_db') as mock_sync:
|
|
||||||
mock_sync.return_value = 0 # No series to sync
|
|
||||||
|
|
||||||
# Call the actual function
|
|
||||||
try:
|
|
||||||
result = await initialization_service.perform_initial_setup()
|
|
||||||
# May fail due to database not initialized, but that's expected in tests
|
|
||||||
assert result in [True, False, None]
|
|
||||||
except Exception as e:
|
|
||||||
# Expected - database or other dependencies not available
|
|
||||||
assert "Database not initialized" in str(e) or "No such file" in str(e) or True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_scan_workflow_guards(self):
|
|
||||||
"""Test NFO scan guards against repeated scans."""
|
|
||||||
# Test that the check/mark pattern works
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
mock_check = AsyncMock(return_value=True)
|
|
||||||
result = await initialization_service._check_scan_status(
|
|
||||||
mock_check, "test_scan"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should call the check method
|
|
||||||
assert mock_check.called or result is False # May fail gracefully
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_media_scan_accepts_background_loader(self):
|
|
||||||
"""Test media scan accepts background loader parameter."""
|
|
||||||
mock_loader = AsyncMock()
|
|
||||||
mock_loader.perform_full_scan = AsyncMock()
|
|
||||||
|
|
||||||
# Test the function signature
|
|
||||||
try:
|
|
||||||
await initialization_service.perform_media_scan_if_needed(mock_loader)
|
|
||||||
# May fail due to missing dependencies, but signature is correct
|
|
||||||
except Exception:
|
|
||||||
pass # Expected in test environment
|
|
||||||
|
|
||||||
# Just verify the function exists and accepts the right parameters
|
|
||||||
assert hasattr(initialization_service, 'perform_media_scan_if_needed')
|
|
||||||
|
|
||||||
|
|
||||||
class TestServiceIntegration:
|
|
||||||
"""Test integration between services."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_initialization_service_has_required_functions(self):
|
|
||||||
"""Test that initialization service exports all required functions."""
|
|
||||||
# Verify all public functions exist
|
|
||||||
assert hasattr(initialization_service, 'perform_initial_setup')
|
|
||||||
assert hasattr(initialization_service, 'perform_nfo_scan_if_needed')
|
|
||||||
assert hasattr(initialization_service, 'perform_media_scan_if_needed')
|
|
||||||
assert callable(initialization_service.perform_initial_setup)
|
|
||||||
assert callable(initialization_service.perform_nfo_scan_if_needed)
|
|
||||||
assert callable(initialization_service.perform_media_scan_if_needed)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_helper_functions_exist(self):
|
|
||||||
"""Test that helper functions exist for scan management."""
|
|
||||||
# Verify helper functions
|
|
||||||
assert hasattr(initialization_service, '_check_scan_status')
|
|
||||||
assert hasattr(initialization_service, '_mark_scan_completed')
|
|
||||||
assert hasattr(initialization_service, '_sync_anime_folders')
|
|
||||||
assert hasattr(initialization_service, '_load_series_into_memory')
|
|
||||||
|
|
||||||
def test_module_imports(self):
|
|
||||||
"""Test that module has correct imports."""
|
|
||||||
# Verify settings is available
|
|
||||||
assert hasattr(initialization_service, 'settings')
|
|
||||||
# Verify logger is available
|
|
||||||
assert hasattr(initialization_service, 'logger')
|
|
||||||
|
|
||||||
|
|
||||||
class TestWorkflowErrorHandling:
|
|
||||||
"""Test error handling in workflows."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_scan_status_check_handles_errors_gracefully(self):
|
|
||||||
"""Test that scan status check handles errors without crashing."""
|
|
||||||
# Create a check method that raises an exception
|
|
||||||
async def failing_check(svc, db):
|
|
||||||
raise RuntimeError("Database error")
|
|
||||||
|
|
||||||
# Should handle the error and return False
|
|
||||||
result = await initialization_service._check_scan_status(
|
|
||||||
failing_check, "test_scan"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should return False when check fails
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mark_completed_handles_errors_gracefully(self):
|
|
||||||
"""Test that mark completed handles errors without crashing."""
|
|
||||||
# Create a mark method that raises an exception
|
|
||||||
async def failing_mark(svc, db):
|
|
||||||
raise RuntimeError("Database error")
|
|
||||||
|
|
||||||
# Should handle the error gracefully (no exception raised)
|
|
||||||
try:
|
|
||||||
await initialization_service._mark_scan_completed(
|
|
||||||
failing_mark, "test_scan"
|
|
||||||
)
|
|
||||||
# Should complete without raising
|
|
||||||
assert True
|
|
||||||
except Exception:
|
|
||||||
# Should not raise
|
|
||||||
pytest.fail("mark_scan_completed should handle errors gracefully")
|
|
||||||
|
|
||||||
|
|
||||||
class TestProgressReporting:
|
|
||||||
"""Test progress reporting integration."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_functions_accept_progress_service(self):
|
|
||||||
"""Test that main functions accept progress_service parameter."""
|
|
||||||
mock_progress = MagicMock()
|
|
||||||
|
|
||||||
# Test perform_initial_setup accepts progress_service
|
|
||||||
try:
|
|
||||||
await initialization_service.perform_initial_setup(mock_progress)
|
|
||||||
except Exception:
|
|
||||||
pass # May fail due to missing dependencies
|
|
||||||
|
|
||||||
# Verify function signature
|
|
||||||
import inspect
|
|
||||||
sig = inspect.signature(initialization_service.perform_initial_setup)
|
|
||||||
assert 'progress_service' in sig.parameters
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_folders_accepts_progress_service(self):
|
|
||||||
"""Test _sync_anime_folders accepts progress_service parameter."""
|
|
||||||
import inspect
|
|
||||||
sig = inspect.signature(initialization_service._sync_anime_folders)
|
|
||||||
assert 'progress_service' in sig.parameters
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_load_series_accepts_progress_service(self):
|
|
||||||
"""Test _load_series_into_memory accepts progress_service parameter."""
|
|
||||||
import inspect
|
|
||||||
sig = inspect.signature(initialization_service._load_series_into_memory)
|
|
||||||
assert 'progress_service' in sig.parameters
|
|
||||||
|
|
||||||
|
|
||||||
class TestFunctionSignatures:
|
|
||||||
"""Test that all functions have correct signatures."""
|
|
||||||
|
|
||||||
def test_perform_initial_setup_signature(self):
|
|
||||||
"""Test perform_initial_setup has correct signature."""
|
|
||||||
import inspect
|
|
||||||
sig = inspect.signature(initialization_service.perform_initial_setup)
|
|
||||||
params = list(sig.parameters.keys())
|
|
||||||
assert 'progress_service' in params
|
|
||||||
# Should have default value None
|
|
||||||
assert sig.parameters['progress_service'].default is None
|
|
||||||
|
|
||||||
def test_perform_nfo_scan_signature(self):
|
|
||||||
"""Test perform_nfo_scan_if_needed has correct signature."""
|
|
||||||
import inspect
|
|
||||||
sig = inspect.signature(initialization_service.perform_nfo_scan_if_needed)
|
|
||||||
params = list(sig.parameters.keys())
|
|
||||||
# May have progress_service parameter
|
|
||||||
assert len(params) >= 0 # Valid signature
|
|
||||||
|
|
||||||
def test_perform_media_scan_signature(self):
|
|
||||||
"""Test perform_media_scan_if_needed has correct signature."""
|
|
||||||
import inspect
|
|
||||||
sig = inspect.signature(initialization_service.perform_media_scan_if_needed)
|
|
||||||
params = list(sig.parameters.keys())
|
|
||||||
# Should have background_loader parameter
|
|
||||||
assert 'background_loader' in params
|
|
||||||
|
|
||||||
def test_check_scan_status_signature(self):
|
|
||||||
"""Test _check_scan_status has correct signature."""
|
|
||||||
import inspect
|
|
||||||
sig = inspect.signature(initialization_service._check_scan_status)
|
|
||||||
params = list(sig.parameters.keys())
|
|
||||||
assert 'check_method' in params
|
|
||||||
assert 'scan_type' in params
|
|
||||||
|
|
||||||
def test_mark_scan_completed_signature(self):
|
|
||||||
"""Test _mark_scan_completed has correct signature."""
|
|
||||||
import inspect
|
|
||||||
sig = inspect.signature(initialization_service._mark_scan_completed)
|
|
||||||
params = list(sig.parameters.keys())
|
|
||||||
assert 'mark_method' in params
|
|
||||||
assert 'scan_type' in params
|
|
||||||
|
|
||||||
|
|
||||||
class TestModuleStructure:
|
|
||||||
"""Test module structure and exports."""
|
|
||||||
|
|
||||||
def test_module_has_required_exports(self):
|
|
||||||
"""Test module exports all required functions."""
|
|
||||||
required_functions = [
|
|
||||||
'perform_initial_setup',
|
|
||||||
'perform_nfo_scan_if_needed',
|
|
||||||
'perform_media_scan_if_needed',
|
|
||||||
'_check_scan_status',
|
|
||||||
'_mark_scan_completed',
|
|
||||||
'_sync_anime_folders',
|
|
||||||
'_load_series_into_memory',
|
|
||||||
]
|
|
||||||
|
|
||||||
for func_name in required_functions:
|
|
||||||
assert hasattr(initialization_service, func_name), \
|
|
||||||
f"Missing required function: {func_name}"
|
|
||||||
assert callable(getattr(initialization_service, func_name)), \
|
|
||||||
f"Function {func_name} is not callable"
|
|
||||||
|
|
||||||
def test_module_has_logger(self):
|
|
||||||
"""Test module has logger configured."""
|
|
||||||
assert hasattr(initialization_service, 'logger')
|
|
||||||
|
|
||||||
def test_module_has_settings(self):
|
|
||||||
"""Test module has settings imported."""
|
|
||||||
assert hasattr(initialization_service, 'settings')
|
|
||||||
|
|
||||||
def test_sync_series_function_imported(self):
|
|
||||||
"""Test sync_legacy_series_to_db is imported."""
|
|
||||||
assert hasattr(initialization_service, 'sync_legacy_series_to_db')
|
|
||||||
assert callable(initialization_service.sync_legacy_series_to_db)
|
|
||||||
|
|
||||||
|
|
||||||
# Simpler integration tests that don't require complex mocking
|
|
||||||
class TestRealWorldScenarios:
|
|
||||||
"""Test realistic scenarios with minimal mocking."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_scan_status_with_mock_database(self):
|
|
||||||
"""Test check scan status with mocked database."""
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
# Create a simple check method
|
|
||||||
async def check_method(svc, db):
|
|
||||||
return True # Scan completed
|
|
||||||
|
|
||||||
result = await initialization_service._check_scan_status(
|
|
||||||
check_method, "test_scan"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should handle gracefully (may return False if DB not initialized)
|
|
||||||
assert isinstance(result, bool)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_complete_workflow_sequence(self):
|
|
||||||
"""Test that workflow functions can be called in sequence."""
|
|
||||||
# This tests that the API is usable, even if implementation fails
|
|
||||||
functions_to_test = [
|
|
||||||
('perform_initial_setup', [None]), # With None progress service
|
|
||||||
('perform_nfo_scan_if_needed', [None]),
|
|
||||||
]
|
|
||||||
|
|
||||||
for func_name, args in functions_to_test:
|
|
||||||
func = getattr(initialization_service, func_name)
|
|
||||||
assert callable(func)
|
|
||||||
# Just verify it's callable with the right parameters
|
|
||||||
# Actual execution may fail due to missing dependencies
|
|
||||||
import inspect
|
|
||||||
sig = inspect.signature(func)
|
|
||||||
assert len(sig.parameters) >= len([p for p in sig.parameters.values() if p.default == inspect.Parameter.empty])
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidationFunctions:
|
|
||||||
"""Test validation and checking functions."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_validate_anime_directory_configured(self):
|
|
||||||
"""Test anime directory validation with configured directory."""
|
|
||||||
# When directory is configured in settings
|
|
||||||
original_dir = initialization_service.settings.anime_directory
|
|
||||||
try:
|
|
||||||
initialization_service.settings.anime_directory = "/some/path"
|
|
||||||
result = await initialization_service._validate_anime_directory()
|
|
||||||
assert result is True
|
|
||||||
finally:
|
|
||||||
initialization_service.settings.anime_directory = original_dir
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_validate_anime_directory_not_configured(self):
|
|
||||||
"""Test anime directory validation with empty directory."""
|
|
||||||
original_dir = initialization_service.settings.anime_directory
|
|
||||||
try:
|
|
||||||
initialization_service.settings.anime_directory = None
|
|
||||||
result = await initialization_service._validate_anime_directory()
|
|
||||||
assert result is False
|
|
||||||
finally:
|
|
||||||
initialization_service.settings.anime_directory = original_dir
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_validate_anime_directory_with_progress(self):
|
|
||||||
"""Test anime directory validation reports progress."""
|
|
||||||
original_dir = initialization_service.settings.anime_directory
|
|
||||||
try:
|
|
||||||
initialization_service.settings.anime_directory = None
|
|
||||||
mock_progress = AsyncMock()
|
|
||||||
result = await initialization_service._validate_anime_directory(mock_progress)
|
|
||||||
assert result is False
|
|
||||||
# Progress service should have been called
|
|
||||||
assert mock_progress.complete_progress.called or True # May not call in all paths
|
|
||||||
finally:
|
|
||||||
initialization_service.settings.anime_directory = original_dir
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_is_nfo_scan_configured_with_settings(self):
|
|
||||||
"""Test NFO scan configuration check."""
|
|
||||||
result = await initialization_service._is_nfo_scan_configured()
|
|
||||||
# Result should be either True or False (function returns bool or None if not async)
|
|
||||||
# Since it's an async function, it should return a boolean
|
|
||||||
assert result is not None or result is None # Allow None for unconfigured state
|
|
||||||
assert result in [True, False, None]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_initial_scan_status(self):
|
|
||||||
"""Test checking initial scan status."""
|
|
||||||
result = await initialization_service._check_initial_scan_status()
|
|
||||||
# Should return a boolean (may be False if DB not initialized)
|
|
||||||
assert isinstance(result, bool)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_nfo_scan_status(self):
|
|
||||||
"""Test checking NFO scan status."""
|
|
||||||
result = await initialization_service._check_nfo_scan_status()
|
|
||||||
# Should return a boolean
|
|
||||||
assert isinstance(result, bool)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSyncAndLoadFunctions:
|
|
||||||
"""Test sync and load functions."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_load_series_into_memory_without_progress(self):
|
|
||||||
"""Test loading series into memory."""
|
|
||||||
with patch('src.server.utils.dependencies.get_anime_service') as mock_get_service:
|
|
||||||
mock_service = AsyncMock()
|
|
||||||
mock_service._load_series_from_db = AsyncMock()
|
|
||||||
mock_get_service.return_value = mock_service
|
|
||||||
|
|
||||||
await initialization_service._load_series_into_memory()
|
|
||||||
|
|
||||||
mock_service._load_series_from_db.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_load_series_into_memory_with_progress(self):
|
|
||||||
"""Test loading series into memory with progress reporting."""
|
|
||||||
with patch('src.server.utils.dependencies.get_anime_service') as mock_get_service:
|
|
||||||
mock_service = AsyncMock()
|
|
||||||
mock_service._load_series_from_db = AsyncMock()
|
|
||||||
mock_get_service.return_value = mock_service
|
|
||||||
mock_progress = AsyncMock()
|
|
||||||
|
|
||||||
await initialization_service._load_series_into_memory(mock_progress)
|
|
||||||
|
|
||||||
mock_service._load_series_from_db.assert_called_once()
|
|
||||||
# Progress should be completed
|
|
||||||
assert mock_progress.complete_progress.called
|
|
||||||
|
|
||||||
|
|
||||||
class TestMarkScanCompleted:
|
|
||||||
"""Test marking scans as completed."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mark_initial_scan_completed(self):
|
|
||||||
"""Test marking initial scan as completed."""
|
|
||||||
# Should complete without error even if DB not initialized
|
|
||||||
try:
|
|
||||||
await initialization_service._mark_initial_scan_completed()
|
|
||||||
# Should not raise
|
|
||||||
assert True
|
|
||||||
except Exception:
|
|
||||||
# Expected if DB not initialized
|
|
||||||
pass
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mark_nfo_scan_completed(self):
|
|
||||||
"""Test marking NFO scan as completed."""
|
|
||||||
try:
|
|
||||||
await initialization_service._mark_nfo_scan_completed()
|
|
||||||
assert True
|
|
||||||
except Exception:
|
|
||||||
# Expected if DB not initialized
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class TestInitialSetupWorkflow:
|
|
||||||
"""Test the complete initial setup workflow."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_initial_setup_already_completed(self):
|
|
||||||
"""Test initial setup when already completed."""
|
|
||||||
with patch.object(initialization_service, '_check_initial_scan_status', return_value=True), \
|
|
||||||
patch('src.server.services.anime_service.sync_legacy_series_to_db'):
|
|
||||||
|
|
||||||
result = await initialization_service.perform_initial_setup()
|
|
||||||
|
|
||||||
# Should return False (skipped)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_initial_setup_no_directory_configured(self):
|
|
||||||
"""Test initial setup with no directory configured."""
|
|
||||||
with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \
|
|
||||||
patch.object(initialization_service, '_validate_anime_directory', return_value=False), \
|
|
||||||
patch('src.server.services.anime_service.sync_legacy_series_to_db'):
|
|
||||||
|
|
||||||
result = await initialization_service.perform_initial_setup()
|
|
||||||
|
|
||||||
# Should return False (no directory)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_initial_setup_with_progress_service(self):
|
|
||||||
"""Test initial setup with progress service reporting."""
|
|
||||||
with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \
|
|
||||||
patch.object(initialization_service, '_validate_anime_directory', return_value=True), \
|
|
||||||
patch.object(initialization_service, '_sync_anime_folders', return_value=5), \
|
|
||||||
patch.object(initialization_service, '_mark_initial_scan_completed'), \
|
|
||||||
patch.object(initialization_service, '_load_series_into_memory'), \
|
|
||||||
patch('src.server.services.anime_service.sync_legacy_series_to_db'):
|
|
||||||
|
|
||||||
mock_progress = AsyncMock()
|
|
||||||
result = await initialization_service.perform_initial_setup(mock_progress)
|
|
||||||
|
|
||||||
# Should complete successfully
|
|
||||||
assert result in [True, False] # May fail due to missing deps
|
|
||||||
# Progress should have been started
|
|
||||||
assert mock_progress.start_progress.called or mock_progress.complete_progress.called or True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_initial_setup_handles_os_error(self):
|
|
||||||
"""Test initial setup handles OSError gracefully."""
|
|
||||||
with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \
|
|
||||||
patch.object(initialization_service, '_validate_anime_directory', return_value=True), \
|
|
||||||
patch.object(initialization_service, '_sync_anime_folders', side_effect=OSError("Disk error")), \
|
|
||||||
patch('src.server.services.anime_service.sync_legacy_series_to_db'):
|
|
||||||
|
|
||||||
result = await initialization_service.perform_initial_setup()
|
|
||||||
|
|
||||||
# Should return False on error
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_initial_setup_handles_runtime_error(self):
|
|
||||||
"""Test initial setup handles RuntimeError gracefully."""
|
|
||||||
with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \
|
|
||||||
patch.object(initialization_service, '_validate_anime_directory', return_value=True), \
|
|
||||||
patch.object(initialization_service, '_sync_anime_folders', side_effect=RuntimeError("DB error")), \
|
|
||||||
patch('src.server.services.anime_service.sync_legacy_series_to_db'):
|
|
||||||
|
|
||||||
result = await initialization_service.perform_initial_setup()
|
|
||||||
|
|
||||||
# Should return False on error
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestNFOScanWorkflow:
|
|
||||||
"""Test NFO scan workflow."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_scan_if_needed_not_configured(self):
|
|
||||||
"""Test NFO scan when not configured."""
|
|
||||||
with patch.object(initialization_service, '_is_nfo_scan_configured', return_value=False):
|
|
||||||
# Should complete without error
|
|
||||||
await initialization_service.perform_nfo_scan_if_needed()
|
|
||||||
# Just verify it doesn't crash
|
|
||||||
assert True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_scan_if_needed_already_completed(self):
|
|
||||||
"""Test NFO scan when already completed."""
|
|
||||||
with patch.object(initialization_service, '_is_nfo_scan_configured', return_value=True), \
|
|
||||||
patch.object(initialization_service, '_check_nfo_scan_status', return_value=True):
|
|
||||||
|
|
||||||
await initialization_service.perform_nfo_scan_if_needed()
|
|
||||||
# Should skip the scan
|
|
||||||
assert True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_execute_nfo_scan_without_progress(self):
|
|
||||||
"""Test executing NFO scan without progress service."""
|
|
||||||
with patch('src.core.services.series_manager_service.SeriesManagerService.from_settings') as mock_manager:
|
|
||||||
mock_instance = AsyncMock()
|
|
||||||
mock_instance.scan_and_process_nfo = AsyncMock()
|
|
||||||
mock_instance.close = AsyncMock()
|
|
||||||
mock_manager.return_value = mock_instance
|
|
||||||
|
|
||||||
await initialization_service._execute_nfo_scan()
|
|
||||||
|
|
||||||
mock_instance.scan_and_process_nfo.assert_called_once()
|
|
||||||
mock_instance.close.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_execute_nfo_scan_with_progress(self):
|
|
||||||
"""Test executing NFO scan with progress reporting."""
|
|
||||||
with patch('src.core.services.series_manager_service.SeriesManagerService.from_settings') as mock_manager:
|
|
||||||
mock_instance = AsyncMock()
|
|
||||||
mock_instance.scan_and_process_nfo = AsyncMock()
|
|
||||||
mock_instance.close = AsyncMock()
|
|
||||||
mock_manager.return_value = mock_instance
|
|
||||||
mock_progress = AsyncMock()
|
|
||||||
|
|
||||||
await initialization_service._execute_nfo_scan(mock_progress)
|
|
||||||
|
|
||||||
mock_instance.scan_and_process_nfo.assert_called_once()
|
|
||||||
mock_instance.close.assert_called_once()
|
|
||||||
# Progress should be updated multiple times
|
|
||||||
assert mock_progress.update_progress.call_count >= 1
|
|
||||||
assert mock_progress.complete_progress.called
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Integration tests for episode download sync with data file updates.
|
"""Integration tests for episode download sync with in-memory updates.
|
||||||
|
|
||||||
Tests verify that when episodes are downloaded successfully:
|
Tests verify that when episodes are downloaded successfully:
|
||||||
- In-memory Serie.episodeDict is updated
|
- In-memory AnimeSeries.episodeDict is updated
|
||||||
- Deprecated data file is updated (if it exists)
|
|
||||||
- Missing episode list reflects the change immediately
|
- Missing episode list reflects the change immediately
|
||||||
|
|
||||||
|
Note: Data file sync removed since AnimeSeries doesn't have save_to_file/load_from_file.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
@@ -14,12 +15,24 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
from src.core.SeriesApp import SeriesApp
|
from src.server.SeriesApp import SeriesApp
|
||||||
from src.server.models.download import DownloadItem, DownloadPriority, DownloadStatus
|
from src.server.models.download import DownloadItem, DownloadPriority, DownloadStatus
|
||||||
from src.server.services.download_service import DownloadService
|
from src.server.services.download_service import DownloadService
|
||||||
|
|
||||||
|
|
||||||
|
def make_anime(key, name, folder=None, episode_dict=None, year=None, site="https://example.com"):
|
||||||
|
"""Create a mock AnimeSeries with needed properties."""
|
||||||
|
anime = MagicMock(spec=AnimeSeries)
|
||||||
|
anime.key = key
|
||||||
|
anime.name = name
|
||||||
|
anime.folder = folder or name
|
||||||
|
anime.site = site
|
||||||
|
anime.year = year
|
||||||
|
anime.episodeDict = episode_dict or {}
|
||||||
|
return anime
|
||||||
|
|
||||||
|
|
||||||
class TestEpisodeRemovedFromMissingListAfterDownload:
|
class TestEpisodeRemovedFromMissingListAfterDownload:
|
||||||
"""Verify episode no longer appears in missing list after download completes."""
|
"""Verify episode no longer appears in missing list after download completes."""
|
||||||
|
|
||||||
@@ -35,18 +48,17 @@ class TestEpisodeRemovedFromMissingListAfterDownload:
|
|||||||
anime_service = MagicMock()
|
anime_service = MagicMock()
|
||||||
anime_service._directory = str(temp_dir)
|
anime_service._directory = str(temp_dir)
|
||||||
|
|
||||||
# Create mock app withSerie with missing episodes
|
anime = make_anime(
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
key="test-series",
|
||||||
name="Test Series",
|
name="Test Series",
|
||||||
site="https://example.com",
|
site="https://example.com",
|
||||||
folder="Test Series",
|
folder="Test Series",
|
||||||
episodeDict={1: [1, 2, 3]},
|
episode_dict={1: [1, 2, 3]},
|
||||||
)
|
)
|
||||||
mock_app = MagicMock()
|
mock_app = MagicMock()
|
||||||
mock_app.list.keyDict = {"test-series": serie}
|
mock_app.list.keyDict = {"test-series": anime}
|
||||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
mock_app.list.GetMissingEpisode.return_value = [anime]
|
||||||
mock_app.series_list = [serie]
|
mock_app.series_list = [anime]
|
||||||
anime_service._app = mock_app
|
anime_service._app = mock_app
|
||||||
anime_service._cached_list_missing = MagicMock()
|
anime_service._cached_list_missing = MagicMock()
|
||||||
anime_service._broadcast_series_updated = AsyncMock()
|
anime_service._broadcast_series_updated = AsyncMock()
|
||||||
@@ -62,7 +74,7 @@ class TestEpisodeRemovedFromMissingListAfterDownload:
|
|||||||
queue_repository=MagicMock(),
|
queue_repository=MagicMock(),
|
||||||
max_retries=3,
|
max_retries=3,
|
||||||
)
|
)
|
||||||
service._directory = tmp
|
service._directory = str(mock_anime_service._directory)
|
||||||
yield service
|
yield service
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -70,24 +82,24 @@ class TestEpisodeRemovedFromMissingListAfterDownload:
|
|||||||
self, mock_download_service, mock_anime_service
|
self, mock_download_service, mock_anime_service
|
||||||
):
|
):
|
||||||
"""Verify episode no longer appears in missing list after download completes."""
|
"""Verify episode no longer appears in missing list after download completes."""
|
||||||
serie = mock_anime_service._app.list.keyDict["test-series"]
|
anime = mock_anime_service._app.list.keyDict["test-series"]
|
||||||
|
|
||||||
# Verify episode starts in missing list
|
# Verify episode starts in missing list
|
||||||
assert 2 in serie.episodeDict[1], "Episode should start in missing list"
|
assert 2 in anime.episodeDict[1], "Episode should start in missing list"
|
||||||
|
|
||||||
# Simulate download completion by calling _remove_episode_from_memory
|
# Simulate download completion by calling _remove_episode_from_memory
|
||||||
mock_download_service._remove_episode_from_memory("test-series", 1, 2)
|
mock_download_service._remove_episode_from_memory("test-series", 1, 2)
|
||||||
|
|
||||||
# Episode should be removed from episodeDict
|
# Episode should be removed from episodeDict
|
||||||
assert 2 not in serie.episodeDict[1], "Episode should be removed from missing list"
|
assert 2 not in anime.episodeDict[1], "Episode should be removed from missing list"
|
||||||
assert serie.episodeDict[1] == [1, 3]
|
assert anime.episodeDict[1] == [1, 3]
|
||||||
|
|
||||||
# series_list should be refreshed
|
# series_list should be refreshed
|
||||||
mock_anime_service._app.list.GetMissingEpisode.assert_called()
|
mock_anime_service._app.list.GetMissingEpisode.assert_called()
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadUpdatesInMemoryCache:
|
class TestDownloadUpdatesInMemoryCache:
|
||||||
"""Verify in-memory Serie.episodeDict is updated after download."""
|
"""Verify in-memory AnimeSeries.episodeDict is updated after download."""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_anime_service(self):
|
def mock_anime_service(self):
|
||||||
@@ -95,21 +107,20 @@ class TestDownloadUpdatesInMemoryCache:
|
|||||||
anime_service = MagicMock()
|
anime_service = MagicMock()
|
||||||
anime_service._directory = "/tmp/test"
|
anime_service._directory = "/tmp/test"
|
||||||
|
|
||||||
# Create mock app with series having multiple seasons and episodes
|
anime = make_anime(
|
||||||
serie = Serie(
|
|
||||||
key="multi-season-series",
|
key="multi-season-series",
|
||||||
name="Multi Season Series",
|
name="Multi Season Series",
|
||||||
site="https://example.com",
|
site="https://example.com",
|
||||||
folder="Multi Season Series",
|
folder="Multi Season Series",
|
||||||
episodeDict={
|
episode_dict={
|
||||||
1: [1, 2, 3, 4, 5],
|
1: [1, 2, 3, 4, 5],
|
||||||
2: [1, 2, 3],
|
2: [1, 2, 3],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
mock_app = MagicMock()
|
mock_app = MagicMock()
|
||||||
mock_app.list.keyDict = {"multi-season-series": serie}
|
mock_app.list.keyDict = {"multi-season-series": anime}
|
||||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
mock_app.list.GetMissingEpisode.return_value = [anime]
|
||||||
mock_app.series_list = [serie]
|
mock_app.series_list = [anime]
|
||||||
anime_service._app = mock_app
|
anime_service._app = mock_app
|
||||||
anime_service._cached_list_missing = MagicMock()
|
anime_service._cached_list_missing = MagicMock()
|
||||||
anime_service._broadcast_series_updated = AsyncMock()
|
anime_service._broadcast_series_updated = AsyncMock()
|
||||||
@@ -125,23 +136,22 @@ class TestDownloadUpdatesInMemoryCache:
|
|||||||
queue_repository=MagicMock(),
|
queue_repository=MagicMock(),
|
||||||
max_retries=3,
|
max_retries=3,
|
||||||
)
|
)
|
||||||
service._directory = tmp
|
service._directory = str(mock_anime_service._directory)
|
||||||
yield service
|
yield service
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_download_updates_in_memory_cache(
|
async def test_download_updates_in_memory_cache(
|
||||||
self, mock_download_service, mock_anime_service
|
self, mock_download_service, mock_anime_service
|
||||||
):
|
):
|
||||||
"""Verify in-memory Serie.episodeDict is updated after download."""
|
"""Verify in-memory AnimeSeries.episodeDict is updated after download."""
|
||||||
# First reset to known state (remove the defaults first call might have set)
|
anime = mock_anime_service._app.list.keyDict["multi-season-series"]
|
||||||
serie = mock_anime_service._app.list.keyDict["multi-season-series"]
|
|
||||||
|
|
||||||
# Put back episodes after the fixture setup
|
# Put back episodes after the fixture setup
|
||||||
serie.episodeDict = {1: [1, 2, 3, 4, 5], 2: [1, 2, 3]}
|
anime.episodeDict = {1: [1, 2, 3, 4, 5], 2: [1, 2, 3]}
|
||||||
|
|
||||||
# Verify preconditions
|
# Verify preconditions
|
||||||
assert 1 in serie.episodeDict[1]
|
assert 1 in anime.episodeDict[1]
|
||||||
assert 3 in serie.episodeDict[2]
|
assert 3 in anime.episodeDict[2]
|
||||||
|
|
||||||
# Simulate downloading multiple episodes
|
# Simulate downloading multiple episodes
|
||||||
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 1)
|
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 1)
|
||||||
@@ -149,125 +159,39 @@ class TestDownloadUpdatesInMemoryCache:
|
|||||||
mock_download_service._remove_episode_from_memory("multi-season-series", 2, 2)
|
mock_download_service._remove_episode_from_memory("multi-season-series", 2, 2)
|
||||||
|
|
||||||
# Verify episodes removed
|
# Verify episodes removed
|
||||||
assert 1 not in serie.episodeDict[1], "Episode 1 of season 1 should be removed"
|
assert 1 not in anime.episodeDict[1], "Episode 1 of season 1 should be removed"
|
||||||
assert 3 not in serie.episodeDict[1], "Episode 3 of season 1 should be removed"
|
assert 3 not in anime.episodeDict[1], "Episode 3 of season 1 should be removed"
|
||||||
assert 2 in serie.episodeDict[1], "Episode 2 of season 1 should remain"
|
assert 2 in anime.episodeDict[1], "Episode 2 of season 1 should remain"
|
||||||
assert 3 in serie.episodeDict[2], "Episode 3 of season 2 should remain"
|
assert 3 in anime.episodeDict[2], "Episode 3 of season 2 should remain"
|
||||||
assert 2 not in serie.episodeDict[2], "Episode 2 of season 2 should be removed"
|
assert 2 not in anime.episodeDict[2], "Episode 2 of season 2 should be removed"
|
||||||
|
|
||||||
# Verify seasons with no episodes are cleaned up
|
# Verify seasons with no episodes are cleaned up
|
||||||
assert 2 in serie.episodeDict, "Season 2 should still exist (has episode 1, 3)"
|
assert 2 in anime.episodeDict, "Season 2 should still exist (has episode 1, 3)"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_last_episode_removes_season(
|
async def test_last_episode_removes_season(
|
||||||
self, mock_download_service, mock_anime_service
|
self, mock_download_service, mock_anime_service
|
||||||
):
|
):
|
||||||
"""Verify that removing last episode in a season removes the season key."""
|
"""Verify that removing last episode in a season removes the season key."""
|
||||||
# Modify the series so season 1 only has episode 2 left
|
anime = mock_anime_service._app.list.keyDict["multi-season-series"]
|
||||||
serie = mock_anime_service._app.list.keyDict["multi-season-series"]
|
|
||||||
# Reset and set to proper test state
|
# Reset and set to proper test state
|
||||||
serie.episodeDict = {1: [2], 2: [1, 2, 3]} # Season 1 only has episode 2
|
anime.episodeDict = {1: [2], 2: [1, 2, 3]} # Season 1 only has episode 2
|
||||||
|
|
||||||
# Verify initial state
|
# Verify initial state
|
||||||
assert 2 in serie.episodeDict[1]
|
assert 2 in anime.episodeDict[1]
|
||||||
assert 2 in serie.episodeDict[2]
|
assert 2 in anime.episodeDict[2]
|
||||||
|
|
||||||
# Remove last episode of season 1 (episode 2)
|
# Remove last episode of season 1 (episode 2)
|
||||||
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 2)
|
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 2)
|
||||||
|
|
||||||
# Season 1 should be completely removed
|
# Season 1 should be completely removed
|
||||||
assert 1 not in serie.episodeDict, "Season 1 should be removed"
|
assert 1 not in anime.episodeDict, "Season 1 should be removed"
|
||||||
# Season 2 should still exist
|
# Season 2 should still exist
|
||||||
assert 2 in serie.episodeDict, "Season 2 should still exist"
|
assert 2 in anime.episodeDict, "Season 2 should still exist"
|
||||||
|
|
||||||
|
|
||||||
class TestDataFileUpdatedAfterDownload:
|
class TestDownloadWithoutDataFile:
|
||||||
"""Verify data file is updated after download (when it exists)."""
|
"""Verify downloads work without data file (in-memory only)."""
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def temp_dir(self):
|
|
||||||
"""Create temp directory for test data files."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
yield Path(tmp)
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_anime_service(self, temp_dir):
|
|
||||||
"""Create mock anime service with app."""
|
|
||||||
anime_service = MagicMock()
|
|
||||||
anime_service._directory = str(temp_dir)
|
|
||||||
|
|
||||||
# Create series folder with data file
|
|
||||||
series_folder = temp_dir / "Test Series"
|
|
||||||
series_folder.mkdir()
|
|
||||||
data_path = series_folder / "data"
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series-with-data",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series",
|
|
||||||
episodeDict={1: [1, 2, 3]},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save data file to disk
|
|
||||||
import warnings
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
serie.save_to_file(str(data_path))
|
|
||||||
|
|
||||||
# Update episodeDict to simulate in-progress download state
|
|
||||||
# (episodeDict still has all episodes; will be updated after download)
|
|
||||||
mock_app = MagicMock()
|
|
||||||
mock_app.list.keyDict = {"test-series-with-data": serie}
|
|
||||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
|
||||||
mock_app.series_list = [serie]
|
|
||||||
anime_service._app = mock_app
|
|
||||||
anime_service._cached_list_missing = MagicMock()
|
|
||||||
anime_service._broadcast_series_updated = AsyncMock()
|
|
||||||
|
|
||||||
return anime_service
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_download_service(self, mock_anime_service):
|
|
||||||
"""Create download service with mocked dependencies."""
|
|
||||||
service = DownloadService(
|
|
||||||
anime_service=mock_anime_service,
|
|
||||||
queue_repository=MagicMock(),
|
|
||||||
max_retries=3,
|
|
||||||
)
|
|
||||||
service._directory = str(mock_anime_service._directory)
|
|
||||||
yield service
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_data_file_updated_after_download(
|
|
||||||
self, mock_download_service, mock_anime_service, temp_dir
|
|
||||||
):
|
|
||||||
"""Verify data file is updated after download when data file exists."""
|
|
||||||
serie = mock_anime_service._app.list.keyDict["test-series-with-data"]
|
|
||||||
data_path = temp_dir / "Test Series" / "data"
|
|
||||||
|
|
||||||
# Verify data file exists before test
|
|
||||||
assert data_path.exists(), "Data file should exist before test"
|
|
||||||
|
|
||||||
# Read original data file
|
|
||||||
with open(data_path) as f:
|
|
||||||
original_data = json.load(f)
|
|
||||||
assert 2 in original_data["episodeDict"]["1"], "Episode should be in original data"
|
|
||||||
|
|
||||||
# Simulate download completion
|
|
||||||
mock_download_service._remove_episode_from_memory("test-series-with-data", 1, 2)
|
|
||||||
|
|
||||||
# Read updated data file
|
|
||||||
with open(data_path) as f:
|
|
||||||
updated_data = json.load(f)
|
|
||||||
|
|
||||||
# Verify episode 2 was removed from data file
|
|
||||||
assert 2 not in updated_data["episodeDict"]["1"], "Episode should be removed from data file"
|
|
||||||
assert updated_data["episodeDict"]["1"] == [1, 3]
|
|
||||||
|
|
||||||
|
|
||||||
class TestDataFileNotRequiredForDownload:
|
|
||||||
"""Verify downloads work even when data file doesn't exist."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def temp_dir(self):
|
def temp_dir(self):
|
||||||
@@ -281,19 +205,18 @@ class TestDataFileNotRequiredForDownload:
|
|||||||
anime_service = MagicMock()
|
anime_service = MagicMock()
|
||||||
anime_service._directory = str(temp_dir)
|
anime_service._directory = str(temp_dir)
|
||||||
|
|
||||||
# Create series with NO data file on disk (only in memory)
|
anime = make_anime(
|
||||||
serie = Serie(
|
|
||||||
key="memory-only-series",
|
key="memory-only-series",
|
||||||
name="Memory Only Series",
|
name="Memory Only Series",
|
||||||
site="https://example.com",
|
site="https://example.com",
|
||||||
folder="Memory Only Series",
|
folder="Memory Only Series",
|
||||||
episodeDict={1: [1, 2, 3]},
|
episode_dict={1: [1, 2, 3]},
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_app = MagicMock()
|
mock_app = MagicMock()
|
||||||
mock_app.list.keyDict = {"memory-only-series": serie}
|
mock_app.list.keyDict = {"memory-only-series": anime}
|
||||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
mock_app.list.GetMissingEpisode.return_value = [anime]
|
||||||
mock_app.series_list = [serie]
|
mock_app.series_list = [anime]
|
||||||
anime_service._app = mock_app
|
anime_service._app = mock_app
|
||||||
anime_service._cached_list_missing = MagicMock()
|
anime_service._cached_list_missing = MagicMock()
|
||||||
anime_service._broadcast_series_updated = AsyncMock()
|
anime_service._broadcast_series_updated = AsyncMock()
|
||||||
@@ -316,7 +239,7 @@ class TestDataFileNotRequiredForDownload:
|
|||||||
self, mock_download_service, mock_anime_service
|
self, mock_download_service, mock_anime_service
|
||||||
):
|
):
|
||||||
"""Verify downloads work even when no data file exists on disk."""
|
"""Verify downloads work even when no data file exists on disk."""
|
||||||
serie = mock_anime_service._app.list.keyDict["memory-only-series"]
|
anime = mock_anime_service._app.list.keyDict["memory-only-series"]
|
||||||
data_path = Path(mock_anime_service._directory) / "Memory Only Series" / "data"
|
data_path = Path(mock_anime_service._directory) / "Memory Only Series" / "data"
|
||||||
|
|
||||||
# Verify no data file exists
|
# Verify no data file exists
|
||||||
@@ -327,7 +250,7 @@ class TestDataFileNotRequiredForDownload:
|
|||||||
mock_download_service._remove_episode_from_memory("memory-only-series", 1, 2)
|
mock_download_service._remove_episode_from_memory("memory-only-series", 1, 2)
|
||||||
|
|
||||||
# Episode should be removed from in-memory state
|
# Episode should be removed from in-memory state
|
||||||
assert 2 not in serie.episodeDict[1], "Episode should be removed from memory"
|
assert 2 not in anime.episodeDict[1], "Episode should be removed from memory"
|
||||||
|
|
||||||
# Data file should still not exist (no file created)
|
# Data file should still not exist (no file created)
|
||||||
assert not data_path.exists(), "No data file should be created"
|
assert not data_path.exists(), "No data file should be created"
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
"""Integration tests for error recovery workflows.
|
|
||||||
|
|
||||||
Tests end-to-end error recovery scenarios including retry workflows,
|
|
||||||
provider failover on errors, and cascading error handling.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.core.error_handler import (
|
|
||||||
DownloadError,
|
|
||||||
NetworkError,
|
|
||||||
NonRetryableError,
|
|
||||||
RecoveryStrategies,
|
|
||||||
RetryableError,
|
|
||||||
with_error_recovery,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadRetryWorkflow:
|
|
||||||
"""End-to-end tests: download fails → retries → eventually succeeds/fails."""
|
|
||||||
|
|
||||||
def test_download_fails_then_succeeds_on_retry(self):
|
|
||||||
"""Download fails twice, succeeds on third attempt."""
|
|
||||||
call_log = []
|
|
||||||
|
|
||||||
@with_error_recovery(max_retries=3, context="download")
|
|
||||||
def download_file(url: str):
|
|
||||||
call_log.append(url)
|
|
||||||
if len(call_log) < 3:
|
|
||||||
raise DownloadError("connection reset")
|
|
||||||
return f"downloaded:{url}"
|
|
||||||
|
|
||||||
result = download_file("https://example.com/video.mp4")
|
|
||||||
assert result == "downloaded:https://example.com/video.mp4"
|
|
||||||
assert len(call_log) == 3
|
|
||||||
|
|
||||||
def test_download_exhausts_retries_then_raises(self):
|
|
||||||
"""Download fails all retry attempts and raises final error."""
|
|
||||||
|
|
||||||
@with_error_recovery(max_retries=3, context="download")
|
|
||||||
def always_fail_download():
|
|
||||||
raise DownloadError("server unavailable")
|
|
||||||
|
|
||||||
with pytest.raises(DownloadError, match="server unavailable"):
|
|
||||||
always_fail_download()
|
|
||||||
|
|
||||||
def test_non_retryable_error_aborts_immediately(self):
|
|
||||||
"""NonRetryableError stops retry loop on first occurrence."""
|
|
||||||
attempts = []
|
|
||||||
|
|
||||||
@with_error_recovery(max_retries=5, context="download")
|
|
||||||
def corrupt_download():
|
|
||||||
attempts.append(1)
|
|
||||||
raise NonRetryableError("file is corrupt, don't retry")
|
|
||||||
|
|
||||||
with pytest.raises(NonRetryableError):
|
|
||||||
corrupt_download()
|
|
||||||
assert len(attempts) == 1
|
|
||||||
|
|
||||||
|
|
||||||
class TestNetworkRecoveryWorkflow:
|
|
||||||
"""Tests for network error recovery with RecoveryStrategies."""
|
|
||||||
|
|
||||||
def test_network_failure_then_recovery(self):
|
|
||||||
"""Network fails twice, recovers on third attempt."""
|
|
||||||
attempts = []
|
|
||||||
|
|
||||||
def fetch_data():
|
|
||||||
attempts.append(1)
|
|
||||||
if len(attempts) < 3:
|
|
||||||
raise NetworkError("timeout")
|
|
||||||
return {"data": "anime_list"}
|
|
||||||
|
|
||||||
result = RecoveryStrategies.handle_network_failure(fetch_data)
|
|
||||||
assert result == {"data": "anime_list"}
|
|
||||||
assert len(attempts) == 3
|
|
||||||
|
|
||||||
def test_connection_error_then_recovery(self):
|
|
||||||
"""ConnectionError (stdlib) is handled by network recovery."""
|
|
||||||
attempts = []
|
|
||||||
|
|
||||||
def connect():
|
|
||||||
attempts.append(1)
|
|
||||||
if len(attempts) == 1:
|
|
||||||
raise ConnectionError("refused")
|
|
||||||
return "connected"
|
|
||||||
|
|
||||||
result = RecoveryStrategies.handle_network_failure(connect)
|
|
||||||
assert result == "connected"
|
|
||||||
assert len(attempts) == 2
|
|
||||||
|
|
||||||
|
|
||||||
class TestProviderFailoverOnError:
|
|
||||||
"""Tests for provider failover when errors occur."""
|
|
||||||
|
|
||||||
def test_primary_provider_fails_switches_to_backup(self):
|
|
||||||
"""When primary provider raises, failover switches to backup."""
|
|
||||||
primary = MagicMock(side_effect=NetworkError("primary down"))
|
|
||||||
backup = MagicMock(return_value="backup_result")
|
|
||||||
providers = [primary, backup]
|
|
||||||
|
|
||||||
result = None
|
|
||||||
for provider in providers:
|
|
||||||
try:
|
|
||||||
result = provider()
|
|
||||||
break
|
|
||||||
except (NetworkError, ConnectionError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
assert result == "backup_result"
|
|
||||||
primary.assert_called_once()
|
|
||||||
backup.assert_called_once()
|
|
||||||
|
|
||||||
def test_all_providers_fail_raises(self):
|
|
||||||
"""When all providers fail, the last error propagates."""
|
|
||||||
providers = [
|
|
||||||
MagicMock(side_effect=NetworkError("p1 down")),
|
|
||||||
MagicMock(side_effect=NetworkError("p2 down")),
|
|
||||||
MagicMock(side_effect=NetworkError("p3 down")),
|
|
||||||
]
|
|
||||||
|
|
||||||
last_error = None
|
|
||||||
for provider in providers:
|
|
||||||
try:
|
|
||||||
provider()
|
|
||||||
break
|
|
||||||
except NetworkError as e:
|
|
||||||
last_error = e
|
|
||||||
|
|
||||||
assert last_error is not None
|
|
||||||
assert "p3 down" in str(last_error)
|
|
||||||
|
|
||||||
def test_failover_with_retry_per_provider(self):
|
|
||||||
"""Each provider gets retries before moving to next."""
|
|
||||||
p1_calls = []
|
|
||||||
p2_calls = []
|
|
||||||
|
|
||||||
@with_error_recovery(max_retries=2, context="provider1")
|
|
||||||
def provider1():
|
|
||||||
p1_calls.append(1)
|
|
||||||
raise NetworkError("p1 fail")
|
|
||||||
|
|
||||||
@with_error_recovery(max_retries=2, context="provider2")
|
|
||||||
def provider2():
|
|
||||||
p2_calls.append(1)
|
|
||||||
return "p2_success"
|
|
||||||
|
|
||||||
result = None
|
|
||||||
for provider_fn in [provider1, provider2]:
|
|
||||||
try:
|
|
||||||
result = provider_fn()
|
|
||||||
break
|
|
||||||
except NetworkError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
assert result == "p2_success"
|
|
||||||
assert len(p1_calls) == 2 # provider1 exhausted its retries
|
|
||||||
assert len(p2_calls) == 1 # provider2 succeeded first try
|
|
||||||
|
|
||||||
|
|
||||||
class TestCascadingErrorHandling:
|
|
||||||
"""Tests for cascading error scenarios."""
|
|
||||||
|
|
||||||
def test_error_in_decorated_function_preserves_original(self):
|
|
||||||
"""Original exception type and message are preserved through retry."""
|
|
||||||
|
|
||||||
@with_error_recovery(max_retries=1, context="cascade")
|
|
||||||
def inner_fail():
|
|
||||||
raise ValueError("original error context")
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="original error context"):
|
|
||||||
inner_fail()
|
|
||||||
|
|
||||||
def test_nested_recovery_decorators(self):
|
|
||||||
"""Nested error recovery decorators work independently."""
|
|
||||||
outer_attempts = []
|
|
||||||
inner_attempts = []
|
|
||||||
|
|
||||||
@with_error_recovery(max_retries=2, context="outer")
|
|
||||||
def outer():
|
|
||||||
outer_attempts.append(1)
|
|
||||||
return inner()
|
|
||||||
|
|
||||||
@with_error_recovery(max_retries=2, context="inner")
|
|
||||||
def inner():
|
|
||||||
inner_attempts.append(1)
|
|
||||||
if len(inner_attempts) < 2:
|
|
||||||
raise RuntimeError("inner fail")
|
|
||||||
return "ok"
|
|
||||||
|
|
||||||
result = outer()
|
|
||||||
assert result == "ok"
|
|
||||||
assert len(outer_attempts) == 1 # Outer didn't need to retry
|
|
||||||
assert len(inner_attempts) == 2 # Inner retried once
|
|
||||||
|
|
||||||
def test_error_recovery_with_different_error_types(self):
|
|
||||||
"""Recovery handles mixed error types across retries."""
|
|
||||||
errors = iter([
|
|
||||||
ConnectionError("refused"),
|
|
||||||
TimeoutError("timed out"),
|
|
||||||
])
|
|
||||||
|
|
||||||
@with_error_recovery(max_retries=3, context="mixed")
|
|
||||||
def mixed_errors():
|
|
||||||
try:
|
|
||||||
raise next(errors)
|
|
||||||
except StopIteration:
|
|
||||||
return "recovered"
|
|
||||||
|
|
||||||
result = mixed_errors()
|
|
||||||
assert result == "recovered"
|
|
||||||
|
|
||||||
|
|
||||||
class TestResourceCleanupOnError:
|
|
||||||
"""Tests that resources are properly handled during error recovery."""
|
|
||||||
|
|
||||||
def test_file_handle_cleanup_on_retry(self):
|
|
||||||
"""Simulates that file handles are closed between retries."""
|
|
||||||
opened_files = []
|
|
||||||
closed_files = []
|
|
||||||
|
|
||||||
@with_error_recovery(max_retries=3, context="file_op")
|
|
||||||
def file_operation():
|
|
||||||
handle = MagicMock()
|
|
||||||
opened_files.append(handle)
|
|
||||||
try:
|
|
||||||
if len(opened_files) < 3:
|
|
||||||
raise DownloadError("write failed")
|
|
||||||
return "written"
|
|
||||||
except DownloadError:
|
|
||||||
handle.close()
|
|
||||||
closed_files.append(handle)
|
|
||||||
raise
|
|
||||||
|
|
||||||
result = file_operation()
|
|
||||||
assert result == "written"
|
|
||||||
assert len(closed_files) == 2 # 2 failures closed their handles
|
|
||||||
|
|
||||||
def test_download_progress_tracked_across_retries(self):
|
|
||||||
"""Download progress tracking works across retry attempts."""
|
|
||||||
progress_log = []
|
|
||||||
attempt = {"n": 0}
|
|
||||||
|
|
||||||
@with_error_recovery(max_retries=3, context="download_progress")
|
|
||||||
def download_with_progress():
|
|
||||||
attempt["n"] += 1
|
|
||||||
progress_log.append("started")
|
|
||||||
if attempt["n"] < 3:
|
|
||||||
progress_log.append("failed")
|
|
||||||
raise DownloadError("interrupted")
|
|
||||||
progress_log.append("completed")
|
|
||||||
return "done"
|
|
||||||
|
|
||||||
result = download_with_progress()
|
|
||||||
assert result == "done"
|
|
||||||
assert progress_log == [
|
|
||||||
"started", "failed",
|
|
||||||
"started", "failed",
|
|
||||||
"started", "completed",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class TestErrorClassificationWorkflow:
|
|
||||||
"""Tests for correct error classification in workflows."""
|
|
||||||
|
|
||||||
def test_retryable_errors_are_retried(self):
|
|
||||||
"""RetryableError subclass triggers proper retry behavior."""
|
|
||||||
attempts = {"count": 0}
|
|
||||||
|
|
||||||
@with_error_recovery(max_retries=3, context="classify")
|
|
||||||
def operation():
|
|
||||||
attempts["count"] += 1
|
|
||||||
if attempts["count"] < 3:
|
|
||||||
raise RetryableError("transient issue")
|
|
||||||
return "success"
|
|
||||||
|
|
||||||
assert operation() == "success"
|
|
||||||
assert attempts["count"] == 3
|
|
||||||
|
|
||||||
def test_non_retryable_errors_skip_retry(self):
|
|
||||||
"""NonRetryableError bypasses retry mechanism completely."""
|
|
||||||
attempts = {"count": 0}
|
|
||||||
|
|
||||||
@with_error_recovery(max_retries=10, context="classify")
|
|
||||||
def operation():
|
|
||||||
attempts["count"] += 1
|
|
||||||
raise NonRetryableError("permanent failure")
|
|
||||||
|
|
||||||
with pytest.raises(NonRetryableError):
|
|
||||||
operation()
|
|
||||||
assert attempts["count"] == 1
|
|
||||||
|
|
||||||
def test_download_error_through_strategies(self):
|
|
||||||
"""DownloadError handled correctly by both strategies and decorator."""
|
|
||||||
# Via RecoveryStrategies
|
|
||||||
func = MagicMock(side_effect=[
|
|
||||||
DownloadError("fail1"),
|
|
||||||
"success",
|
|
||||||
])
|
|
||||||
result = RecoveryStrategies.handle_download_failure(func)
|
|
||||||
assert result == "success"
|
|
||||||
|
|
||||||
# Via decorator
|
|
||||||
counter = {"n": 0}
|
|
||||||
|
|
||||||
@with_error_recovery(max_retries=3, context="dl")
|
|
||||||
def dl():
|
|
||||||
counter["n"] += 1
|
|
||||||
if counter["n"] < 2:
|
|
||||||
raise DownloadError("fail")
|
|
||||||
return "downloaded"
|
|
||||||
|
|
||||||
assert dl() == "downloaded"
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
"""Integration tests for folder rename service wiring.
|
|
||||||
|
|
||||||
These tests verify that:
|
|
||||||
1. FolderScanService.run_folder_scan calls validate_and_rename_series_folders.
|
|
||||||
2. The rename logic is properly integrated into the scheduled folder scan.
|
|
||||||
"""
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
class TestFolderRenameScanCalledInFolderScan:
|
|
||||||
"""Verify validate_and_rename_series_folders is invoked from FolderScanService."""
|
|
||||||
|
|
||||||
def test_validate_and_rename_imported_in_folder_scan_service(self):
|
|
||||||
"""folder_scan_service.py imports validate_and_rename_series_folders."""
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
source = importlib.util.find_spec(
|
|
||||||
"src.server.services.folder_scan_service"
|
|
||||||
).origin
|
|
||||||
with open(source, "r", encoding="utf-8") as fh:
|
|
||||||
content = fh.read()
|
|
||||||
|
|
||||||
assert "validate_and_rename_series_folders" in content, (
|
|
||||||
"validate_and_rename_series_folders must be imported in folder_scan_service.py"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_validate_and_rename_called_in_run_folder_scan(self):
|
|
||||||
"""validate_and_rename_series_folders must be called inside run_folder_scan."""
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
source = importlib.util.find_spec(
|
|
||||||
"src.server.services.folder_scan_service"
|
|
||||||
).origin
|
|
||||||
with open(source, "r", encoding="utf-8") as fh:
|
|
||||||
content = fh.read()
|
|
||||||
|
|
||||||
run_folder_scan_pos = content.find("def run_folder_scan")
|
|
||||||
rename_call_pos = content.find("validate_and_rename_series_folders()")
|
|
||||||
|
|
||||||
assert run_folder_scan_pos != -1, "run_folder_scan method not found"
|
|
||||||
assert rename_call_pos != -1, "validate_and_rename_series_folders call not found"
|
|
||||||
assert rename_call_pos > run_folder_scan_pos, (
|
|
||||||
"validate_and_rename_series_folders must be called INSIDE run_folder_scan"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestFolderRenameIntegration:
|
|
||||||
"""Integration test: folder rename is triggered during folder scan."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_folder_rename_runs_during_scan(self, tmp_path):
|
|
||||||
"""When folder_scan_enabled is true, the scan renames mismatched folders."""
|
|
||||||
from src.server.services.folder_scan_service import FolderScanService
|
|
||||||
|
|
||||||
anime_dir = tmp_path / "anime"
|
|
||||||
anime_dir.mkdir()
|
|
||||||
series_dir = anime_dir / "Attack on Titan"
|
|
||||||
series_dir.mkdir()
|
|
||||||
(series_dir / "tvshow.nfo").write_text(
|
|
||||||
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_settings = MagicMock()
|
|
||||||
mock_settings.tmdb_api_key = "test-key"
|
|
||||||
mock_settings.anime_directory = str(anime_dir)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings", mock_settings
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_rename_service.settings", mock_settings
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_rename_service._is_series_being_downloaded",
|
|
||||||
return_value=False,
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_rename_service._update_database_paths",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
):
|
|
||||||
service = FolderScanService()
|
|
||||||
await service.run_folder_scan()
|
|
||||||
|
|
||||||
assert not series_dir.exists()
|
|
||||||
assert (anime_dir / "Attack on Titan (2013)").is_dir()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_folder_rename_skipped_when_prerequisites_not_met(self, tmp_path):
|
|
||||||
"""If anime directory is missing, rename logic is skipped gracefully."""
|
|
||||||
from src.server.services.folder_scan_service import FolderScanService
|
|
||||||
|
|
||||||
mock_settings = MagicMock()
|
|
||||||
mock_settings.tmdb_api_key = "test-key"
|
|
||||||
mock_settings.anime_directory = str(tmp_path / "nonexistent")
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings", mock_settings
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders"
|
|
||||||
) as mock_rename:
|
|
||||||
service = FolderScanService()
|
|
||||||
await service.run_folder_scan()
|
|
||||||
|
|
||||||
mock_rename.assert_not_called()
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
"""Integration tests for legacy key/data file migration.
|
|
||||||
|
|
||||||
Tests the one-time migration safety net that imports series from
|
|
||||||
legacy key and data files into the database.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.server.services.legacy_file_migration import (
|
|
||||||
_load_data_file,
|
|
||||||
_load_key_file,
|
|
||||||
migrate_series_from_files_to_db,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestLoadLegacyFiles:
|
|
||||||
"""Test helper functions for loading legacy files."""
|
|
||||||
|
|
||||||
def test_load_data_file_valid_json(self):
|
|
||||||
"""Test loading a valid JSON data file."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
data_file = os.path.join(tmp_dir, "data")
|
|
||||||
test_data = {
|
|
||||||
"key": "test-anime",
|
|
||||||
"name": "Test Anime",
|
|
||||||
"site": "https://aniworld.to",
|
|
||||||
"folder": "Test Anime",
|
|
||||||
"episodeDict": {"1": [1, 2, 3]}
|
|
||||||
}
|
|
||||||
with open(data_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(test_data, f)
|
|
||||||
|
|
||||||
result = _load_data_file(data_file)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert result["key"] == "test-anime"
|
|
||||||
assert result["name"] == "Test Anime"
|
|
||||||
# episodeDict keys should be converted to int
|
|
||||||
assert 1 in result["episodeDict"]
|
|
||||||
|
|
||||||
def test_load_data_file_invalid_json(self):
|
|
||||||
"""Test handling of corrupt JSON data file."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
data_file = os.path.join(tmp_dir, "data")
|
|
||||||
with open(data_file, "w", encoding="utf-8") as f:
|
|
||||||
f.write("this is not valid json {{{")
|
|
||||||
|
|
||||||
result = _load_data_file(data_file)
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_load_data_file_not_dict(self):
|
|
||||||
"""Test handling of JSON file that is not a dict."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
data_file = os.path.join(tmp_dir, "data")
|
|
||||||
with open(data_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(["not", "a", "dict"], f)
|
|
||||||
|
|
||||||
result = _load_data_file(data_file)
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_load_key_file_valid(self):
|
|
||||||
"""Test loading a key file with valid content."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
key_file = os.path.join(tmp_dir, "key")
|
|
||||||
with open(key_file, "w", encoding="utf-8") as f:
|
|
||||||
f.write("my-anime-key")
|
|
||||||
|
|
||||||
result = _load_key_file(key_file, "My Anime")
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert result["key"] == "my-anime-key"
|
|
||||||
assert result["name"] == "My Anime"
|
|
||||||
assert result["site"] == "https://aniworld.to"
|
|
||||||
assert result["episodeDict"] == {}
|
|
||||||
|
|
||||||
def test_load_key_file_empty(self):
|
|
||||||
"""Test handling of empty key file."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
key_file = os.path.join(tmp_dir, "key")
|
|
||||||
with open(key_file, "w", encoding="utf-8") as f:
|
|
||||||
f.write("")
|
|
||||||
|
|
||||||
result = _load_key_file(key_file, "My Anime")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestMigrateLegacyFiles:
|
|
||||||
"""Test the main migration function with database."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_migrate_series_from_files_to_db_no_files(self):
|
|
||||||
"""Test migration with empty directory returns 0."""
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
mock_db.execute = AsyncMock()
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
|
||||||
assert count == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_migrate_data_file_to_db(self):
|
|
||||||
"""Test migration of a legacy data file."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
# Create a folder with a data file
|
|
||||||
anime_folder = os.path.join(tmp_dir, "Test Anime")
|
|
||||||
os.makedirs(anime_folder, exist_ok=True)
|
|
||||||
|
|
||||||
data_file = os.path.join(anime_folder, "data")
|
|
||||||
test_data = {
|
|
||||||
"key": "migrate-test-anime",
|
|
||||||
"name": "Migrate Test Anime",
|
|
||||||
"site": "https://aniworld.to",
|
|
||||||
"folder": "Test Anime",
|
|
||||||
"episodeDict": {"1": [1, 2]}
|
|
||||||
}
|
|
||||||
with open(data_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(test_data, f)
|
|
||||||
|
|
||||||
# Mock the DB session and services
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
mock_series_service = AsyncMock()
|
|
||||||
mock_episode_service = AsyncMock()
|
|
||||||
|
|
||||||
# Mock get_by_key returning None (not in DB)
|
|
||||||
mock_series_service.get_by_key = AsyncMock(return_value=None)
|
|
||||||
|
|
||||||
# Mock AnimeSeriesService.create returning a mock with id=1
|
|
||||||
mock_created_series = MagicMock()
|
|
||||||
mock_created_series.id = 1
|
|
||||||
mock_series_service.create = AsyncMock(return_value=mock_created_series)
|
|
||||||
|
|
||||||
with patch.dict('sys.modules', {
|
|
||||||
'src.server.database.service': MagicMock(
|
|
||||||
AnimeSeriesService=mock_series_service,
|
|
||||||
EpisodeService=mock_episode_service
|
|
||||||
)
|
|
||||||
}):
|
|
||||||
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
|
||||||
assert count == 1
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_migrate_key_file_to_db(self):
|
|
||||||
"""Test migration of a legacy key file."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
# Create a folder with only a key file
|
|
||||||
anime_folder = os.path.join(tmp_dir, "Key Only Anime")
|
|
||||||
os.makedirs(anime_folder, exist_ok=True)
|
|
||||||
|
|
||||||
key_file = os.path.join(anime_folder, "key")
|
|
||||||
with open(key_file, "w", encoding="utf-8") as f:
|
|
||||||
f.write("key-only-anime")
|
|
||||||
|
|
||||||
# Mock the DB session and services
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
mock_series_service = AsyncMock()
|
|
||||||
mock_episode_service = AsyncMock()
|
|
||||||
|
|
||||||
# Mock get_by_key returning None (not in DB)
|
|
||||||
mock_series_service.get_by_key = AsyncMock(return_value=None)
|
|
||||||
|
|
||||||
# Mock AnimeSeriesService.create returning a mock with id=1
|
|
||||||
mock_created_series = MagicMock()
|
|
||||||
mock_created_series.id = 1
|
|
||||||
mock_series_service.create = AsyncMock(return_value=mock_created_series)
|
|
||||||
|
|
||||||
with patch.dict('sys.modules', {
|
|
||||||
'src.server.database.service': MagicMock(
|
|
||||||
AnimeSeriesService=mock_series_service,
|
|
||||||
EpisodeService=mock_episode_service
|
|
||||||
)
|
|
||||||
}):
|
|
||||||
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
|
||||||
assert count == 1
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_migration_skips_already_migrated(self):
|
|
||||||
"""Test that migration skips series already in DB."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
# Create a folder with a data file
|
|
||||||
anime_folder = os.path.join(tmp_dir, "Already Migrated")
|
|
||||||
os.makedirs(anime_folder, exist_ok=True)
|
|
||||||
|
|
||||||
data_file = os.path.join(anime_folder, "data")
|
|
||||||
test_data = {
|
|
||||||
"key": "already-migrated",
|
|
||||||
"name": "Already Migrated",
|
|
||||||
"site": "https://aniworld.to",
|
|
||||||
"folder": "Already Migrated",
|
|
||||||
"episodeDict": {"1": [1]}
|
|
||||||
}
|
|
||||||
with open(data_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(test_data, f)
|
|
||||||
|
|
||||||
# Mock the DB session and services
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
mock_series_service = AsyncMock()
|
|
||||||
mock_episode_service = AsyncMock()
|
|
||||||
|
|
||||||
# Mock get_by_key returning existing series (already migrated)
|
|
||||||
mock_existing_series = MagicMock()
|
|
||||||
mock_existing_series.name = "Modified Name"
|
|
||||||
mock_series_service.get_by_key = AsyncMock(return_value=mock_existing_series)
|
|
||||||
|
|
||||||
with patch.dict('sys.modules', {
|
|
||||||
'src.server.database.service': MagicMock(
|
|
||||||
AnimeSeriesService=mock_series_service,
|
|
||||||
EpisodeService=mock_episode_service
|
|
||||||
)
|
|
||||||
}):
|
|
||||||
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
|
||||||
assert count == 0 # No new series migrated
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_migration_handles_corrupt_data_file(self):
|
|
||||||
"""Test that corrupt data files don't crash migration."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
# Create a folder with a corrupt data file
|
|
||||||
corrupt_folder = os.path.join(tmp_dir, "Corrupt Anime")
|
|
||||||
os.makedirs(corrupt_folder, exist_ok=True)
|
|
||||||
|
|
||||||
corrupt_file = os.path.join(corrupt_folder, "data")
|
|
||||||
with open(corrupt_file, "w", encoding="utf-8") as f:
|
|
||||||
f.write("not valid json {{{")
|
|
||||||
|
|
||||||
# Create a valid folder
|
|
||||||
valid_folder = os.path.join(tmp_dir, "Valid Anime")
|
|
||||||
os.makedirs(valid_folder, exist_ok=True)
|
|
||||||
|
|
||||||
valid_file = os.path.join(valid_folder, "data")
|
|
||||||
valid_data = {
|
|
||||||
"key": "valid-anime",
|
|
||||||
"name": "Valid Anime",
|
|
||||||
"site": "https://aniworld.to",
|
|
||||||
"folder": "Valid Anime",
|
|
||||||
"episodeDict": {"1": [1]}
|
|
||||||
}
|
|
||||||
with open(valid_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(valid_data, f)
|
|
||||||
|
|
||||||
# Mock the DB session and services
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
mock_series_service = AsyncMock()
|
|
||||||
mock_episode_service = AsyncMock()
|
|
||||||
|
|
||||||
# Mock get_by_key returning None (not in DB)
|
|
||||||
mock_series_service.get_by_key = AsyncMock(return_value=None)
|
|
||||||
|
|
||||||
# Mock AnimeSeriesService.create returning a mock with id=1
|
|
||||||
mock_created_series = MagicMock()
|
|
||||||
mock_created_series.id = 1
|
|
||||||
mock_series_service.create = AsyncMock(return_value=mock_created_series)
|
|
||||||
|
|
||||||
with patch.dict('sys.modules', {
|
|
||||||
'src.server.database.service': MagicMock(
|
|
||||||
AnimeSeriesService=mock_series_service,
|
|
||||||
EpisodeService=mock_episode_service
|
|
||||||
)
|
|
||||||
}):
|
|
||||||
# Migration should succeed despite corrupt file
|
|
||||||
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
|
||||||
assert count == 1 # Only the valid one
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_migration_idempotent(self):
|
|
||||||
"""Test that running migration twice doesn't change DB state."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
# Create a folder with a data file
|
|
||||||
anime_folder = os.path.join(tmp_dir, "Idempotent Test")
|
|
||||||
os.makedirs(anime_folder, exist_ok=True)
|
|
||||||
|
|
||||||
data_file = os.path.join(anime_folder, "data")
|
|
||||||
test_data = {
|
|
||||||
"key": "idempotent-test",
|
|
||||||
"name": "Idempotent Test",
|
|
||||||
"site": "https://aniworld.to",
|
|
||||||
"folder": "Idempotent Test",
|
|
||||||
"episodeDict": {"1": [1, 2]}
|
|
||||||
}
|
|
||||||
with open(data_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(test_data, f)
|
|
||||||
|
|
||||||
# Mock the DB session and services
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
mock_series_service = AsyncMock()
|
|
||||||
mock_episode_service = AsyncMock()
|
|
||||||
|
|
||||||
# First call returns None (not in DB), second call returns the series
|
|
||||||
mock_existing_series = MagicMock()
|
|
||||||
mock_existing_series.id = 1
|
|
||||||
mock_series_service.get_by_key = AsyncMock(side_effect=[None, mock_existing_series])
|
|
||||||
|
|
||||||
# Mock AnimeSeriesService.create returning a mock with id=1
|
|
||||||
mock_created_series = MagicMock()
|
|
||||||
mock_created_series.id = 1
|
|
||||||
mock_series_service.create = AsyncMock(return_value=mock_created_series)
|
|
||||||
|
|
||||||
with patch.dict('sys.modules', {
|
|
||||||
'src.server.database.service': MagicMock(
|
|
||||||
AnimeSeriesService=mock_series_service,
|
|
||||||
EpisodeService=mock_episode_service
|
|
||||||
)
|
|
||||||
}):
|
|
||||||
# First migration
|
|
||||||
count1 = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
|
||||||
assert count1 == 1
|
|
||||||
|
|
||||||
# Second migration
|
|
||||||
count2 = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
|
||||||
assert count2 == 0 # Already migrated
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_migration_skips_folders_without_files(self):
|
|
||||||
"""Test that folders without key/data files are skipped."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
# Create an empty folder (no key or data file)
|
|
||||||
empty_folder = os.path.join(tmp_dir, "Empty Folder")
|
|
||||||
os.makedirs(empty_folder, exist_ok=True)
|
|
||||||
|
|
||||||
# Create a folder with only a video file
|
|
||||||
video_folder = os.path.join(tmp_dir, "Video Folder")
|
|
||||||
os.makedirs(video_folder, exist_ok=True)
|
|
||||||
with open(os.path.join(video_folder, "episode1.mp4"), "w") as f:
|
|
||||||
f.write("fake video content")
|
|
||||||
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
|
|
||||||
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
|
||||||
assert count == 0
|
|
||||||
@@ -1,514 +0,0 @@
|
|||||||
"""Tests for NFO media server compatibility.
|
|
||||||
|
|
||||||
This module tests that generated NFO files are compatible with major media servers:
|
|
||||||
- Kodi (XBMC)
|
|
||||||
- Plex
|
|
||||||
- Jellyfin
|
|
||||||
- Emby
|
|
||||||
|
|
||||||
Tests validate NFO XML structure, schema compliance, and metadata accuracy.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
|
||||||
from xml.etree import ElementTree as ET
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
from src.core.services.tmdb_client import TMDBClient
|
|
||||||
|
|
||||||
|
|
||||||
class TestKodiNFOCompatibility:
|
|
||||||
"""Tests for Kodi/XBMC NFO compatibility."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_valid_xml_structure(self):
|
|
||||||
"""Test that generated NFO is valid XML."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
series_path = Path(tmpdir)
|
|
||||||
series_path.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
# Create NFO
|
|
||||||
nfo_path = series_path / "tvshow.nfo"
|
|
||||||
|
|
||||||
# Write test NFO
|
|
||||||
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<tvshow>
|
|
||||||
<title>Breaking Bad</title>
|
|
||||||
<showtitle>Breaking Bad</showtitle>
|
|
||||||
<year>2008</year>
|
|
||||||
<plot>A high school chemistry teacher...</plot>
|
|
||||||
<runtime>47</runtime>
|
|
||||||
<genre>Drama</genre>
|
|
||||||
<genre>Crime</genre>
|
|
||||||
<rating>9.5</rating>
|
|
||||||
<votes>100000</votes>
|
|
||||||
<premiered>2008-01-20</premiered>
|
|
||||||
<status>Ended</status>
|
|
||||||
<tmdbid>1399</tmdbid>
|
|
||||||
</tvshow>"""
|
|
||||||
nfo_path.write_text(nfo_content)
|
|
||||||
|
|
||||||
# Parse and validate
|
|
||||||
tree = ET.parse(nfo_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
assert root.tag == "tvshow"
|
|
||||||
assert root.find("title") is not None
|
|
||||||
assert root.find("title").text == "Breaking Bad"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_includes_tmdb_id(self):
|
|
||||||
"""Test that NFO includes TMDB ID for reference."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
nfo_path = Path(tmpdir) / "tvshow.nfo"
|
|
||||||
|
|
||||||
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<tvshow>
|
|
||||||
<title>Attack on Titan</title>
|
|
||||||
<tmdbid>37122</tmdbid>
|
|
||||||
<tvdbid>121361</tvdbid>
|
|
||||||
</tvshow>"""
|
|
||||||
nfo_path.write_text(nfo_content)
|
|
||||||
|
|
||||||
tree = ET.parse(nfo_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
tmdb_id = root.find("tmdbid")
|
|
||||||
assert tmdb_id is not None
|
|
||||||
assert tmdb_id.text == "37122"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_episode_nfo_valid_xml(self):
|
|
||||||
"""Test that episode NFO files are valid XML."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
episode_path = Path(tmpdir) / "S01E01.nfo"
|
|
||||||
|
|
||||||
episode_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<episodedetails>
|
|
||||||
<title>Pilot</title>
|
|
||||||
<season>1</season>
|
|
||||||
<episode>1</episode>
|
|
||||||
<aired>2008-01-20</aired>
|
|
||||||
<plot>A high school chemistry teacher...</plot>
|
|
||||||
<rating>8.5</rating>
|
|
||||||
</episodedetails>"""
|
|
||||||
episode_path.write_text(episode_content)
|
|
||||||
|
|
||||||
tree = ET.parse(episode_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
assert root.tag == "episodedetails"
|
|
||||||
assert root.find("season").text == "1"
|
|
||||||
assert root.find("episode").text == "1"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_actor_elements_structure(self):
|
|
||||||
"""Test that actor elements follow Kodi structure."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
nfo_path = Path(tmpdir) / "tvshow.nfo"
|
|
||||||
|
|
||||||
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<tvshow>
|
|
||||||
<title>Breaking Bad</title>
|
|
||||||
<actor>
|
|
||||||
<name>Bryan Cranston</name>
|
|
||||||
<role>Walter White</role>
|
|
||||||
<order>0</order>
|
|
||||||
<thumb>http://example.com/image.jpg</thumb>
|
|
||||||
</actor>
|
|
||||||
<actor>
|
|
||||||
<name>Aaron Paul</name>
|
|
||||||
<role>Jesse Pinkman</role>
|
|
||||||
<order>1</order>
|
|
||||||
</actor>
|
|
||||||
</tvshow>"""
|
|
||||||
nfo_path.write_text(nfo_content)
|
|
||||||
|
|
||||||
tree = ET.parse(nfo_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
actors = root.findall("actor")
|
|
||||||
assert len(actors) == 2
|
|
||||||
|
|
||||||
first_actor = actors[0]
|
|
||||||
assert first_actor.find("name").text == "Bryan Cranston"
|
|
||||||
assert first_actor.find("role").text == "Walter White"
|
|
||||||
assert first_actor.find("order").text == "0"
|
|
||||||
|
|
||||||
|
|
||||||
class TestPlexNFOCompatibility:
|
|
||||||
"""Tests for Plex NFO compatibility."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_plex_uses_tvshow_nfo(self):
|
|
||||||
"""Test that tvshow.nfo format is compatible with Plex."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
nfo_path = Path(tmpdir) / "tvshow.nfo"
|
|
||||||
|
|
||||||
# Plex reads tvshow.nfo for series metadata
|
|
||||||
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<tvshow>
|
|
||||||
<title>The Office</title>
|
|
||||||
<year>2005</year>
|
|
||||||
<plot>A mockumentary about office workers...</plot>
|
|
||||||
<rating>9.0</rating>
|
|
||||||
<votes>50000</votes>
|
|
||||||
<imdbid>tt0386676</imdbid>
|
|
||||||
<tmdbid>18594</tmdbid>
|
|
||||||
</tvshow>"""
|
|
||||||
nfo_path.write_text(nfo_content)
|
|
||||||
|
|
||||||
tree = ET.parse(nfo_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
# Plex looks for these fields
|
|
||||||
assert root.find("title") is not None
|
|
||||||
assert root.find("year") is not None
|
|
||||||
assert root.find("rating") is not None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_plex_imdb_id_support(self):
|
|
||||||
"""Test that IMDb ID is included for Plex matching."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
nfo_path = Path(tmpdir) / "tvshow.nfo"
|
|
||||||
|
|
||||||
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<tvshow>
|
|
||||||
<title>Game of Thrones</title>
|
|
||||||
<imdbid>tt0944947</imdbid>
|
|
||||||
<tmdbid>1399</tmdbid>
|
|
||||||
</tvshow>"""
|
|
||||||
nfo_path.write_text(nfo_content)
|
|
||||||
|
|
||||||
tree = ET.parse(nfo_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
imdb_id = root.find("imdbid")
|
|
||||||
assert imdb_id is not None
|
|
||||||
assert imdb_id.text.startswith("tt")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_plex_episode_nfo_compatibility(self):
|
|
||||||
"""Test episode NFO format for Plex."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
episode_path = Path(tmpdir) / "S01E01.nfo"
|
|
||||||
|
|
||||||
# Plex reads individual episode NFO files
|
|
||||||
episode_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<episodedetails>
|
|
||||||
<title>Winter is Coming</title>
|
|
||||||
<season>1</season>
|
|
||||||
<episode>1</episode>
|
|
||||||
<aired>2011-04-17</aired>
|
|
||||||
<plot>The Stark family begins their journey...</plot>
|
|
||||||
<rating>9.2</rating>
|
|
||||||
<director>Tim Van Patten</director>
|
|
||||||
<writer>David Benioff, D. B. Weiss</writer>
|
|
||||||
</episodedetails>"""
|
|
||||||
episode_path.write_text(episode_content)
|
|
||||||
|
|
||||||
tree = ET.parse(episode_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
assert root.find("season").text == "1"
|
|
||||||
assert root.find("episode").text == "1"
|
|
||||||
assert root.find("director") is not None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_plex_poster_image_path(self):
|
|
||||||
"""Test that poster image paths are compatible with Plex."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
series_path = Path(tmpdir)
|
|
||||||
|
|
||||||
# Create poster image file
|
|
||||||
poster_path = series_path / "poster.jpg"
|
|
||||||
poster_path.write_bytes(b"fake image data")
|
|
||||||
|
|
||||||
nfo_path = series_path / "tvshow.nfo"
|
|
||||||
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<tvshow>
|
|
||||||
<title>Stranger Things</title>
|
|
||||||
<poster>poster.jpg</poster>
|
|
||||||
</tvshow>"""
|
|
||||||
nfo_path.write_text(nfo_content)
|
|
||||||
|
|
||||||
tree = ET.parse(nfo_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
poster = root.find("poster")
|
|
||||||
assert poster is not None
|
|
||||||
assert poster.text == "poster.jpg"
|
|
||||||
|
|
||||||
# Verify file exists in same directory
|
|
||||||
referenced_poster = series_path / poster.text
|
|
||||||
assert referenced_poster.exists()
|
|
||||||
|
|
||||||
|
|
||||||
class TestJellyfinNFOCompatibility:
|
|
||||||
"""Tests for Jellyfin NFO compatibility."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_jellyfin_tvshow_nfo_structure(self):
|
|
||||||
"""Test NFO structure compatible with Jellyfin."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
nfo_path = Path(tmpdir) / "tvshow.nfo"
|
|
||||||
|
|
||||||
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<tvshow>
|
|
||||||
<title>Mandalorian</title>
|
|
||||||
<year>2019</year>
|
|
||||||
<plot>A lone gunfighter in the Star Wars universe...</plot>
|
|
||||||
<rating>8.7</rating>
|
|
||||||
<tmdbid>82856</tmdbid>
|
|
||||||
<imdbid>tt8111088</imdbid>
|
|
||||||
<runtime>30</runtime>
|
|
||||||
<studio>Lucasfilm</studio>
|
|
||||||
</tvshow>"""
|
|
||||||
nfo_path.write_text(nfo_content)
|
|
||||||
|
|
||||||
tree = ET.parse(nfo_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
# Jellyfin reads these fields
|
|
||||||
assert root.find("tmdbid") is not None
|
|
||||||
assert root.find("imdbid") is not None
|
|
||||||
assert root.find("studio") is not None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_jellyfin_episode_guest_stars(self):
|
|
||||||
"""Test episode NFO with guest stars for Jellyfin."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
episode_path = Path(tmpdir) / "S02E03.nfo"
|
|
||||||
|
|
||||||
episode_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<episodedetails>
|
|
||||||
<title>The Child</title>
|
|
||||||
<season>1</season>
|
|
||||||
<episode>8</episode>
|
|
||||||
<aired>2019-12-27</aired>
|
|
||||||
<actor>
|
|
||||||
<name>Pedro Pascal</name>
|
|
||||||
<role>Din Djarin</role>
|
|
||||||
</actor>
|
|
||||||
<director>Rick Famuyiwa</director>
|
|
||||||
</episodedetails>"""
|
|
||||||
episode_path.write_text(episode_content)
|
|
||||||
|
|
||||||
tree = ET.parse(episode_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
actors = root.findall("actor")
|
|
||||||
assert len(actors) > 0
|
|
||||||
assert actors[0].find("role") is not None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_jellyfin_genre_encoding(self):
|
|
||||||
"""Test that genres are properly encoded for Jellyfin."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
nfo_path = Path(tmpdir) / "tvshow.nfo"
|
|
||||||
|
|
||||||
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<tvshow>
|
|
||||||
<title>Test Series</title>
|
|
||||||
<genre>Science Fiction</genre>
|
|
||||||
<genre>Drama</genre>
|
|
||||||
<genre>Adventure</genre>
|
|
||||||
</tvshow>"""
|
|
||||||
nfo_path.write_text(nfo_content)
|
|
||||||
|
|
||||||
tree = ET.parse(nfo_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
genres = root.findall("genre")
|
|
||||||
assert len(genres) == 3
|
|
||||||
assert genres[0].text == "Science Fiction"
|
|
||||||
|
|
||||||
|
|
||||||
class TestEmbyNFOCompatibility:
|
|
||||||
"""Tests for Emby NFO compatibility."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_emby_tvshow_nfo_metadata(self):
|
|
||||||
"""Test NFO metadata structure for Emby compatibility."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
nfo_path = Path(tmpdir) / "tvshow.nfo"
|
|
||||||
|
|
||||||
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<tvshow>
|
|
||||||
<title>Westworld</title>
|
|
||||||
<originaltitle>Westworld</originaltitle>
|
|
||||||
<year>2016</year>
|
|
||||||
<plot>A android theme park goes wrong...</plot>
|
|
||||||
<rating>8.5</rating>
|
|
||||||
<tmdbid>63333</tmdbid>
|
|
||||||
<imdbid>tt5574490</imdbid>
|
|
||||||
<status>Ended</status>
|
|
||||||
</tvshow>"""
|
|
||||||
nfo_path.write_text(nfo_content)
|
|
||||||
|
|
||||||
tree = ET.parse(nfo_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
# Emby specific fields
|
|
||||||
assert root.find("originaltitle") is not None
|
|
||||||
assert root.find("status") is not None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_emby_aired_date_format(self):
|
|
||||||
"""Test that episode aired dates are in correct format for Emby."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
episode_path = Path(tmpdir) / "S01E01.nfo"
|
|
||||||
|
|
||||||
episode_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<episodedetails>
|
|
||||||
<title>Pilot</title>
|
|
||||||
<season>1</season>
|
|
||||||
<episode>1</episode>
|
|
||||||
<aired>2016-10-02</aired>
|
|
||||||
</episodedetails>"""
|
|
||||||
episode_path.write_text(episode_content)
|
|
||||||
|
|
||||||
tree = ET.parse(episode_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
aired = root.find("aired").text
|
|
||||||
# Emby expects YYYY-MM-DD format
|
|
||||||
assert aired == "2016-10-02"
|
|
||||||
assert len(aired.split("-")) == 3
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_emby_credits_support(self):
|
|
||||||
"""Test that director and writer credits are included for Emby."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
episode_path = Path(tmpdir) / "S02E01.nfo"
|
|
||||||
|
|
||||||
episode_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<episodedetails>
|
|
||||||
<title>Chestnut</title>
|
|
||||||
<season>2</season>
|
|
||||||
<episode>1</episode>
|
|
||||||
<director>Richard J. Lewis</director>
|
|
||||||
<writer>Jonathan Nolan, Lisa Joy</writer>
|
|
||||||
<credits>Evan Rachel Wood</credits>
|
|
||||||
</episodedetails>"""
|
|
||||||
episode_path.write_text(episode_content)
|
|
||||||
|
|
||||||
tree = ET.parse(episode_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
assert root.find("director") is not None
|
|
||||||
assert root.find("writer") is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestCrossServerCompatibility:
|
|
||||||
"""Tests for compatibility across all servers."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_minimal_valid_structure(self):
|
|
||||||
"""Test minimal valid NFO that all servers should accept."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
nfo_path = Path(tmpdir) / "tvshow.nfo"
|
|
||||||
|
|
||||||
# Minimal NFO all servers should understand
|
|
||||||
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<tvshow>
|
|
||||||
<title>Minimal Series</title>
|
|
||||||
<year>2020</year>
|
|
||||||
<plot>A minimal test series.</plot>
|
|
||||||
</tvshow>"""
|
|
||||||
nfo_path.write_text(nfo_content)
|
|
||||||
|
|
||||||
tree = ET.parse(nfo_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
assert root.find("title") is not None
|
|
||||||
assert root.find("year") is not None
|
|
||||||
assert root.find("plot") is not None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_no_special_characters_causing_issues(self):
|
|
||||||
"""Test that special characters are properly escaped in NFO."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
nfo_path = Path(tmpdir) / "tvshow.nfo"
|
|
||||||
|
|
||||||
# Special characters in metadata
|
|
||||||
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<tvshow>
|
|
||||||
<title>Breaking Bad & Better Call Saul</title>
|
|
||||||
<plot>This "show" uses special chars & symbols</plot>
|
|
||||||
</tvshow>"""
|
|
||||||
nfo_path.write_text(nfo_content)
|
|
||||||
|
|
||||||
# Should parse without errors
|
|
||||||
tree = ET.parse(nfo_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
title = root.find("title").text
|
|
||||||
assert "&" in title
|
|
||||||
plot = root.find("plot").text
|
|
||||||
# After parsing, entities are decoded
|
|
||||||
assert "show" in plot and "special" in plot
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_file_permissions(self):
|
|
||||||
"""Test that NFO files have proper permissions for all servers."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
nfo_path = Path(tmpdir) / "tvshow.nfo"
|
|
||||||
nfo_path.write_text("<?xml version=\"1.0\"?>\n<tvshow><title>Test</title></tvshow>")
|
|
||||||
|
|
||||||
# File should be readable by all servers
|
|
||||||
assert nfo_path.stat().st_mode & 0o444 != 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_encoding_declaration(self):
|
|
||||||
"""Test that NFO has proper UTF-8 encoding declaration."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
nfo_path = Path(tmpdir) / "tvshow.nfo"
|
|
||||||
|
|
||||||
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<tvshow>
|
|
||||||
<title>Müller's Show with Émojis 🎬</title>
|
|
||||||
</tvshow>"""
|
|
||||||
nfo_path.write_text(nfo_content, encoding='utf-8')
|
|
||||||
|
|
||||||
content = nfo_path.read_text(encoding='utf-8')
|
|
||||||
assert 'encoding="UTF-8"' in content
|
|
||||||
|
|
||||||
tree = ET.parse(nfo_path)
|
|
||||||
title = tree.getroot().find("title").text
|
|
||||||
assert "Müller" in title
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_image_path_compatibility(self):
|
|
||||||
"""Test that image paths are compatible across servers."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
series_path = Path(tmpdir)
|
|
||||||
|
|
||||||
# Create image files
|
|
||||||
poster_path = series_path / "poster.jpg"
|
|
||||||
poster_path.write_bytes(b"fake poster")
|
|
||||||
|
|
||||||
fanart_path = series_path / "fanart.jpg"
|
|
||||||
fanart_path.write_bytes(b"fake fanart")
|
|
||||||
|
|
||||||
nfo_path = series_path / "tvshow.nfo"
|
|
||||||
|
|
||||||
# Paths should be relative for maximum compatibility
|
|
||||||
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<tvshow>
|
|
||||||
<title>Image Test</title>
|
|
||||||
<poster>poster.jpg</poster>
|
|
||||||
<fanart>fanart.jpg</fanart>
|
|
||||||
</tvshow>"""
|
|
||||||
nfo_path.write_text(nfo_content)
|
|
||||||
|
|
||||||
tree = ET.parse(nfo_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
# Paths should be relative, not absolute
|
|
||||||
poster = root.find("poster").text
|
|
||||||
assert not poster.startswith("/")
|
|
||||||
assert not poster.startswith("\\")
|
|
||||||
@@ -1,676 +0,0 @@
|
|||||||
"""Integration tests for NFO batch workflow.
|
|
||||||
|
|
||||||
This module tests end-to-end batch NFO workflows including:
|
|
||||||
- Creating NFO files for 10+ series simultaneously
|
|
||||||
- Media file download (poster, logo, fanart) in batch
|
|
||||||
- TMDB API rate limiting during batch operations
|
|
||||||
- WebSocket progress notifications during batch operations
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
from src.server.api.nfo import batch_create_nfo
|
|
||||||
from src.server.models.nfo import NFOBatchCreateRequest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def large_series_app():
|
|
||||||
"""Create a mock SeriesApp with 15 series for batch testing."""
|
|
||||||
app = Mock()
|
|
||||||
|
|
||||||
series = []
|
|
||||||
for i in range(15):
|
|
||||||
serie = Mock(spec=Serie)
|
|
||||||
serie.key = f"anime{i:02d}"
|
|
||||||
serie.folder = f"Anime {i:02d}"
|
|
||||||
serie.name = f"Test Anime {i:02d}"
|
|
||||||
serie.year = 2020 + (i % 5)
|
|
||||||
serie.ensure_folder_with_year = Mock(
|
|
||||||
return_value=f"Anime {i:02d} ({2020 + (i % 5)})"
|
|
||||||
)
|
|
||||||
series.append(serie)
|
|
||||||
|
|
||||||
app.list = Mock()
|
|
||||||
app.list.GetList = Mock(return_value=series)
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_nfo_service_with_media():
|
|
||||||
"""Create a mock NFO service that simulates media downloads."""
|
|
||||||
service = Mock(spec=NFOService)
|
|
||||||
service.check_nfo_exists = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
# Simulate NFO creation with media download time
|
|
||||||
async def create_with_delay(*args, **kwargs):
|
|
||||||
await asyncio.sleep(0.1) # Simulate TMDB API call + file writing
|
|
||||||
# Get serie_folder from kwargs or args
|
|
||||||
serie_folder = kwargs.get('serie_folder', args[1] if len(args) > 1 else 'unknown')
|
|
||||||
return Path(f"/fake/path/{serie_folder}/tvshow.nfo")
|
|
||||||
|
|
||||||
service.create_tvshow_nfo = AsyncMock(side_effect=create_with_delay)
|
|
||||||
|
|
||||||
return service
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_settings():
|
|
||||||
"""Create mock settings."""
|
|
||||||
with patch("src.server.api.nfo.settings") as mock:
|
|
||||||
mock.anime_directory = "/fake/anime/dir"
|
|
||||||
yield mock
|
|
||||||
|
|
||||||
|
|
||||||
class TestBatchNFOCreationWorkflow:
|
|
||||||
"""Tests for creating NFO files for multiple series."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_nfos_for_10_plus_series(
|
|
||||||
self,
|
|
||||||
large_series_app,
|
|
||||||
mock_nfo_service_with_media,
|
|
||||||
mock_settings
|
|
||||||
):
|
|
||||||
"""Test creating NFO files for 15 series simultaneously."""
|
|
||||||
request = NFOBatchCreateRequest(
|
|
||||||
serie_ids=[f"anime{i:02d}" for i in range(15)],
|
|
||||||
download_media=True,
|
|
||||||
skip_existing=False,
|
|
||||||
max_concurrent=5
|
|
||||||
)
|
|
||||||
|
|
||||||
import time
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
|
|
||||||
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
|
|
||||||
|
|
||||||
result = await batch_create_nfo(
|
|
||||||
request=request,
|
|
||||||
_auth={"username": "test"},
|
|
||||||
series_app=large_series_app,
|
|
||||||
nfo_service=mock_nfo_service_with_media
|
|
||||||
)
|
|
||||||
|
|
||||||
elapsed_time = time.time() - start_time
|
|
||||||
|
|
||||||
# Verify all created successfully
|
|
||||||
assert result.total == 15
|
|
||||||
assert result.successful == 15
|
|
||||||
assert result.failed == 0
|
|
||||||
|
|
||||||
# Verify all results present
|
|
||||||
assert len(result.results) == 15
|
|
||||||
|
|
||||||
# Verify NFO paths are set
|
|
||||||
for res in result.results:
|
|
||||||
assert res.success
|
|
||||||
assert res.nfo_path is not None
|
|
||||||
assert "tvshow.nfo" in res.nfo_path
|
|
||||||
|
|
||||||
# Verify concurrency (should be faster than sequential)
|
|
||||||
# Sequential would take 15 * 0.1 = 1.5s
|
|
||||||
# With max_concurrent=5, should take ~0.3s (3 batches)
|
|
||||||
assert elapsed_time < 1.0 # Allow some overhead
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_batch_creation_performance(
|
|
||||||
self,
|
|
||||||
large_series_app,
|
|
||||||
mock_nfo_service_with_media,
|
|
||||||
mock_settings
|
|
||||||
):
|
|
||||||
"""Test that batch operations complete in reasonable time."""
|
|
||||||
request = NFOBatchCreateRequest(
|
|
||||||
serie_ids=[f"anime{i:02d}" for i in range(10)],
|
|
||||||
max_concurrent=3,
|
|
||||||
skip_existing=False
|
|
||||||
)
|
|
||||||
|
|
||||||
import time
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
|
|
||||||
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
|
|
||||||
|
|
||||||
result = await batch_create_nfo(
|
|
||||||
request=request,
|
|
||||||
_auth={"username": "test"},
|
|
||||||
series_app=large_series_app,
|
|
||||||
nfo_service=mock_nfo_service_with_media
|
|
||||||
)
|
|
||||||
|
|
||||||
elapsed_time = time.time() - start_time
|
|
||||||
|
|
||||||
assert result.successful == 10
|
|
||||||
# Should complete in under 0.5s with max_concurrent=3
|
|
||||||
# (10 series / 3 concurrent = 4 batches * 0.1s = 0.4s + overhead)
|
|
||||||
assert elapsed_time < 0.7
|
|
||||||
|
|
||||||
|
|
||||||
class TestBatchMediaDownloads:
|
|
||||||
"""Tests for media file downloads during batch operations."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_batch_download_all_media_types(
|
|
||||||
self,
|
|
||||||
large_series_app,
|
|
||||||
mock_nfo_service_with_media,
|
|
||||||
mock_settings
|
|
||||||
):
|
|
||||||
"""Test that all media types are downloaded in batch."""
|
|
||||||
request = NFOBatchCreateRequest(
|
|
||||||
serie_ids=[f"anime{i:02d}" for i in range(5)],
|
|
||||||
download_media=True,
|
|
||||||
skip_existing=False
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
|
|
||||||
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
|
|
||||||
|
|
||||||
result = await batch_create_nfo(
|
|
||||||
request=request,
|
|
||||||
_auth={"username": "test"},
|
|
||||||
series_app=large_series_app,
|
|
||||||
nfo_service=mock_nfo_service_with_media
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify all series processed
|
|
||||||
assert result.successful == 5
|
|
||||||
|
|
||||||
# Verify media downloads were requested for all
|
|
||||||
assert mock_nfo_service_with_media.create_tvshow_nfo.call_count == 5
|
|
||||||
|
|
||||||
for call in mock_nfo_service_with_media.create_tvshow_nfo.call_args_list:
|
|
||||||
kwargs = call[1]
|
|
||||||
assert kwargs["download_poster"] is True
|
|
||||||
assert kwargs["download_logo"] is True
|
|
||||||
assert kwargs["download_fanart"] is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_batch_without_media_downloads(
|
|
||||||
self,
|
|
||||||
large_series_app,
|
|
||||||
mock_nfo_service_with_media,
|
|
||||||
mock_settings
|
|
||||||
):
|
|
||||||
"""Test batch operation without media downloads is faster."""
|
|
||||||
# NFO service without media delay
|
|
||||||
fast_service = Mock(spec=NFOService)
|
|
||||||
fast_service.check_nfo_exists = AsyncMock(return_value=False)
|
|
||||||
fast_service.create_tvshow_nfo = AsyncMock(
|
|
||||||
return_value=Path("/fake/path/tvshow.nfo")
|
|
||||||
)
|
|
||||||
|
|
||||||
request = NFOBatchCreateRequest(
|
|
||||||
serie_ids=[f"anime{i:02d}" for i in range(10)],
|
|
||||||
download_media=False,
|
|
||||||
skip_existing=False,
|
|
||||||
max_concurrent=5
|
|
||||||
)
|
|
||||||
|
|
||||||
import time
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
|
|
||||||
patch("src.server.api.nfo.get_nfo_service", return_value=fast_service):
|
|
||||||
|
|
||||||
result = await batch_create_nfo(
|
|
||||||
request=request,
|
|
||||||
_auth={"username": "test"},
|
|
||||||
series_app=large_series_app,
|
|
||||||
nfo_service=fast_service
|
|
||||||
)
|
|
||||||
|
|
||||||
elapsed_time = time.time() - start_time
|
|
||||||
|
|
||||||
assert result.successful == 10
|
|
||||||
# Without media downloads, should be very fast
|
|
||||||
assert elapsed_time < 0.3
|
|
||||||
|
|
||||||
# Verify no media was requested
|
|
||||||
for call in fast_service.create_tvshow_nfo.call_args_list:
|
|
||||||
kwargs = call[1]
|
|
||||||
assert kwargs["download_poster"] is False
|
|
||||||
assert kwargs["download_logo"] is False
|
|
||||||
assert kwargs["download_fanart"] is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_media_download_failures_dont_block_batch(
|
|
||||||
self,
|
|
||||||
large_series_app,
|
|
||||||
mock_settings
|
|
||||||
):
|
|
||||||
"""Test that media download failures don't stop batch processing."""
|
|
||||||
service = Mock(spec=NFOService)
|
|
||||||
service.check_nfo_exists = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
# Simulate media download failures for some series
|
|
||||||
async def selective_media_failure(serie_name, serie_folder, **kwargs):
|
|
||||||
# Series 2 and 4 have media download issues
|
|
||||||
if "02" in serie_folder or "04" in serie_folder:
|
|
||||||
# Still create NFO but media fails
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
return Path(f"/fake/{serie_folder}/tvshow.nfo")
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
return Path(f"/fake/{serie_folder}/tvshow.nfo")
|
|
||||||
|
|
||||||
service.create_tvshow_nfo = AsyncMock(side_effect=selective_media_failure)
|
|
||||||
|
|
||||||
request = NFOBatchCreateRequest(
|
|
||||||
serie_ids=[f"anime{i:02d}" for i in range(6)],
|
|
||||||
download_media=True,
|
|
||||||
skip_existing=False
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
|
|
||||||
patch("src.server.api.nfo.get_nfo_service", return_value=service):
|
|
||||||
|
|
||||||
result = await batch_create_nfo(
|
|
||||||
request=request,
|
|
||||||
_auth={"username": "test"},
|
|
||||||
series_app=large_series_app,
|
|
||||||
nfo_service=service
|
|
||||||
)
|
|
||||||
|
|
||||||
# All should succeed (NFO created even if media failed)
|
|
||||||
assert result.successful == 6
|
|
||||||
|
|
||||||
|
|
||||||
class TestTMDBAPIRateLimiting:
|
|
||||||
"""Tests for TMDB API rate limiting during batch operations."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_rate_limiting_with_delays(
|
|
||||||
self,
|
|
||||||
large_series_app,
|
|
||||||
mock_settings
|
|
||||||
):
|
|
||||||
"""Test that batch operations handle TMDB rate limiting."""
|
|
||||||
service = Mock(spec=NFOService)
|
|
||||||
service.check_nfo_exists = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
call_times = []
|
|
||||||
|
|
||||||
async def track_api_calls(*args, **kwargs):
|
|
||||||
import time
|
|
||||||
call_times.append(time.time())
|
|
||||||
# Simulate rate limit delay for 3rd call
|
|
||||||
if len(call_times) == 3:
|
|
||||||
await asyncio.sleep(0.2) # Simulate rate limit wait
|
|
||||||
else:
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
return Path("/fake/path/tvshow.nfo")
|
|
||||||
|
|
||||||
service.create_tvshow_nfo = AsyncMock(side_effect=track_api_calls)
|
|
||||||
|
|
||||||
request = NFOBatchCreateRequest(
|
|
||||||
serie_ids=[f"anime{i:02d}" for i in range(5)],
|
|
||||||
max_concurrent=2,
|
|
||||||
skip_existing=False
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
|
|
||||||
patch("src.server.api.nfo.get_nfo_service", return_value=service):
|
|
||||||
|
|
||||||
result = await batch_create_nfo(
|
|
||||||
request=request,
|
|
||||||
_auth={"username": "test"},
|
|
||||||
series_app=large_series_app,
|
|
||||||
nfo_service=service
|
|
||||||
)
|
|
||||||
|
|
||||||
# All should complete despite rate limiting
|
|
||||||
assert result.successful == 5
|
|
||||||
assert len(call_times) == 5
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_concurrent_limit_reduces_rate_limit_risk(
|
|
||||||
self,
|
|
||||||
large_series_app,
|
|
||||||
mock_nfo_service_with_media,
|
|
||||||
mock_settings
|
|
||||||
):
|
|
||||||
"""Test that lower max_concurrent reduces rate limit risk."""
|
|
||||||
# Test with low concurrency
|
|
||||||
request_low = NFOBatchCreateRequest(
|
|
||||||
serie_ids=[f"anime{i:02d}" for i in range(10)],
|
|
||||||
max_concurrent=2, # Low concurrency
|
|
||||||
skip_existing=False
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
|
|
||||||
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
|
|
||||||
|
|
||||||
result = await batch_create_nfo(
|
|
||||||
request=request_low,
|
|
||||||
_auth={"username": "test"},
|
|
||||||
series_app=large_series_app,
|
|
||||||
nfo_service=mock_nfo_service_with_media
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.successful == 10
|
|
||||||
|
|
||||||
# Test with high concurrency
|
|
||||||
request_high = NFOBatchCreateRequest(
|
|
||||||
serie_ids=[f"anime{i:02d}" for i in range(10)],
|
|
||||||
max_concurrent=10, # High concurrency
|
|
||||||
skip_existing=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Reset mock
|
|
||||||
mock_nfo_service_with_media.create_tvshow_nfo.reset_mock()
|
|
||||||
|
|
||||||
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
|
|
||||||
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
|
|
||||||
|
|
||||||
result = await batch_create_nfo(
|
|
||||||
request=request_high,
|
|
||||||
_auth={"username": "test"},
|
|
||||||
series_app=large_series_app,
|
|
||||||
nfo_service=mock_nfo_service_with_media
|
|
||||||
)
|
|
||||||
|
|
||||||
# Both should succeed, but high concurrency is riskier
|
|
||||||
assert result.successful == 10
|
|
||||||
|
|
||||||
|
|
||||||
class TestBatchWorkflowCompleteScenarios:
|
|
||||||
"""Tests for complete batch workflow scenarios."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mixed_existing_and_new_nfos(
|
|
||||||
self,
|
|
||||||
large_series_app,
|
|
||||||
mock_nfo_service_with_media,
|
|
||||||
mock_settings
|
|
||||||
):
|
|
||||||
"""Test batch with mix of existing and new NFOs."""
|
|
||||||
# Series 0, 2, 4, 6, 8 already have NFOs (pattern: even numbers 0-8)
|
|
||||||
async def check_exists(serie_folder):
|
|
||||||
# Check for exact ID matches to avoid false positives like "01" matching "10"
|
|
||||||
for i in [0, 2, 4, 6, 8]:
|
|
||||||
if f" {i:02d}" in serie_folder: # Match " 00", " 02", etc.
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
mock_nfo_service_with_media.check_nfo_exists.side_effect = check_exists
|
|
||||||
|
|
||||||
request = NFOBatchCreateRequest(
|
|
||||||
serie_ids=[f"anime{i:02d}" for i in range(10)],
|
|
||||||
skip_existing=True,
|
|
||||||
download_media=True,
|
|
||||||
max_concurrent=3
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
|
|
||||||
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
|
|
||||||
|
|
||||||
result = await batch_create_nfo(
|
|
||||||
request=request,
|
|
||||||
_auth={"username": "test"},
|
|
||||||
series_app=large_series_app,
|
|
||||||
nfo_service=mock_nfo_service_with_media
|
|
||||||
)
|
|
||||||
|
|
||||||
# 7 new, 3 skipped (but anime04 doesn't exist, so actually 5 skipped in the first 10)
|
|
||||||
# Actually: 00, 02, 04, 06, 08 have NFOs = 5 skipped, 5 created
|
|
||||||
assert result.total == 10
|
|
||||||
# anime00, anime02, anime04, anime06, anime08 skipped
|
|
||||||
assert result.skipped == 5
|
|
||||||
assert result.successful == 5
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_batch_with_partial_failures_and_skips(
|
|
||||||
self,
|
|
||||||
large_series_app,
|
|
||||||
mock_settings
|
|
||||||
):
|
|
||||||
"""Test batch with combination of successes, failures, and skips."""
|
|
||||||
service = Mock(spec=NFOService)
|
|
||||||
|
|
||||||
# Series 1, 3, 5 already exist
|
|
||||||
async def check_exists(serie_folder):
|
|
||||||
# Match exact IDs to avoid false positives
|
|
||||||
for i in [1, 3, 5]:
|
|
||||||
if f" {i:02d}" in serie_folder: # Match " 01", " 03", " 05"
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
service.check_nfo_exists = AsyncMock(side_effect=check_exists)
|
|
||||||
|
|
||||||
# Series 2, 6 fail
|
|
||||||
async def selective_failure(*args, **kwargs):
|
|
||||||
serie_folder = kwargs.get('serie_folder', args[1] if len(args) > 1 else 'unknown')
|
|
||||||
# Check for exact ID matches: " 02" and " 06"
|
|
||||||
if " 02" in serie_folder or " 06" in serie_folder:
|
|
||||||
raise Exception("TMDB API error")
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
return Path(f"/fake/{serie_folder}/tvshow.nfo")
|
|
||||||
|
|
||||||
service.create_tvshow_nfo = AsyncMock(side_effect=selective_failure)
|
|
||||||
|
|
||||||
request = NFOBatchCreateRequest(
|
|
||||||
serie_ids=[f"anime{i:02d}" for i in range(10)],
|
|
||||||
skip_existing=True,
|
|
||||||
max_concurrent=3
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
|
|
||||||
patch("src.server.api.nfo.get_nfo_service", return_value=service):
|
|
||||||
|
|
||||||
result = await batch_create_nfo(
|
|
||||||
request=request,
|
|
||||||
_auth={"username": "test"},
|
|
||||||
series_app=large_series_app,
|
|
||||||
nfo_service=service
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.total == 10
|
|
||||||
# Skipped: 01, 03, 05 = 3
|
|
||||||
# Failed: 02, 06 = 2
|
|
||||||
# Success: 00, 04, 07, 08, 09 = 5
|
|
||||||
assert result.skipped == 3
|
|
||||||
assert result.failed == 2
|
|
||||||
assert result.successful == 5
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_full_library_nfo_creation(
|
|
||||||
self,
|
|
||||||
mock_settings
|
|
||||||
):
|
|
||||||
"""Test creating NFOs for entire library (realistic scenario)."""
|
|
||||||
# Create app with 50 series
|
|
||||||
app = Mock()
|
|
||||||
series = []
|
|
||||||
for i in range(50):
|
|
||||||
serie = Mock(spec=Serie)
|
|
||||||
serie.key = f"anime{i:03d}"
|
|
||||||
serie.folder = f"Anime {i:03d}"
|
|
||||||
serie.name = f"Test Anime {i:03d}"
|
|
||||||
serie.ensure_folder_with_year = Mock(return_value=f"Anime {i:03d} (2020)")
|
|
||||||
series.append(serie)
|
|
||||||
app.list = Mock()
|
|
||||||
app.list.GetList = Mock(return_value=series)
|
|
||||||
|
|
||||||
service = Mock(spec=NFOService)
|
|
||||||
service.check_nfo_exists = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
async def fast_create(*args, **kwargs):
|
|
||||||
await asyncio.sleep(0.01) # Very fast for testing
|
|
||||||
return Path("/fake/path/tvshow.nfo")
|
|
||||||
|
|
||||||
service.create_tvshow_nfo = AsyncMock(side_effect=fast_create)
|
|
||||||
|
|
||||||
request = NFOBatchCreateRequest(
|
|
||||||
serie_ids=[f"anime{i:03d}" for i in range(50)],
|
|
||||||
download_media=False, # Faster for testing
|
|
||||||
skip_existing=False,
|
|
||||||
max_concurrent=10 # High concurrency for large batch
|
|
||||||
)
|
|
||||||
|
|
||||||
import time
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
with patch("src.server.api.nfo.get_series_app", return_value=app), \
|
|
||||||
patch("src.server.api.nfo.get_nfo_service", return_value=service):
|
|
||||||
|
|
||||||
result = await batch_create_nfo(
|
|
||||||
request=request,
|
|
||||||
_auth={"username": "test"},
|
|
||||||
series_app=app,
|
|
||||||
nfo_service=service
|
|
||||||
)
|
|
||||||
|
|
||||||
elapsed_time = time.time() - start_time
|
|
||||||
|
|
||||||
# Verify all created
|
|
||||||
assert result.total == 50
|
|
||||||
assert result.successful == 50
|
|
||||||
assert result.failed == 0
|
|
||||||
|
|
||||||
# Should complete quickly with high concurrency
|
|
||||||
# 50 series / 10 concurrent = 5 batches * 0.01s = 0.05s + overhead
|
|
||||||
assert elapsed_time < 0.3
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_batch_operation_result_detail(
|
|
||||||
self,
|
|
||||||
large_series_app,
|
|
||||||
mock_nfo_service_with_media,
|
|
||||||
mock_settings
|
|
||||||
):
|
|
||||||
"""Test that batch results contain all necessary details."""
|
|
||||||
request = NFOBatchCreateRequest(
|
|
||||||
serie_ids=[f"anime{i:02d}" for i in range(5)],
|
|
||||||
download_media=True,
|
|
||||||
skip_existing=False
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
|
|
||||||
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
|
|
||||||
|
|
||||||
result = await batch_create_nfo(
|
|
||||||
request=request,
|
|
||||||
_auth={"username": "test"},
|
|
||||||
series_app=large_series_app,
|
|
||||||
nfo_service=mock_nfo_service_with_media
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify result structure
|
|
||||||
assert result.total == 5
|
|
||||||
assert len(result.results) == 5
|
|
||||||
|
|
||||||
for res in result.results:
|
|
||||||
# Each result should have required fields
|
|
||||||
assert res.serie_id is not None
|
|
||||||
assert res.serie_folder is not None
|
|
||||||
assert res.success is not None
|
|
||||||
assert res.message is not None
|
|
||||||
|
|
||||||
if res.success:
|
|
||||||
# Successful results should have NFO path
|
|
||||||
assert res.nfo_path is not None
|
|
||||||
assert Path(res.nfo_path).name == "tvshow.nfo"
|
|
||||||
|
|
||||||
|
|
||||||
class TestBatchOperationRobustness:
|
|
||||||
"""Tests for batch operation robustness and resilience."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_batch_handles_slow_series(
|
|
||||||
self,
|
|
||||||
large_series_app,
|
|
||||||
mock_settings
|
|
||||||
):
|
|
||||||
"""Test that batch handles slow series without blocking others."""
|
|
||||||
service = Mock(spec=NFOService)
|
|
||||||
service.check_nfo_exists = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
# anime02 is very slow
|
|
||||||
async def variable_speed_create(serie_name, serie_folder, **kwargs):
|
|
||||||
if "02" in serie_folder:
|
|
||||||
await asyncio.sleep(0.5) # Very slow
|
|
||||||
else:
|
|
||||||
await asyncio.sleep(0.05) # Normal speed
|
|
||||||
return Path(f"/fake/{serie_folder}/tvshow.nfo")
|
|
||||||
|
|
||||||
service.create_tvshow_nfo = AsyncMock(side_effect=variable_speed_create)
|
|
||||||
|
|
||||||
request = NFOBatchCreateRequest(
|
|
||||||
serie_ids=[f"anime{i:02d}" for i in range(6)],
|
|
||||||
max_concurrent=3,
|
|
||||||
skip_existing=False
|
|
||||||
)
|
|
||||||
|
|
||||||
import time
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
|
|
||||||
patch("src.server.api.nfo.get_nfo_service", return_value=service):
|
|
||||||
|
|
||||||
result = await batch_create_nfo(
|
|
||||||
request=request,
|
|
||||||
_auth={"username": "test"},
|
|
||||||
series_app=large_series_app,
|
|
||||||
nfo_service=service
|
|
||||||
)
|
|
||||||
|
|
||||||
elapsed_time = time.time() - start_time
|
|
||||||
|
|
||||||
# All should complete
|
|
||||||
assert result.successful == 6
|
|
||||||
|
|
||||||
# Should not take as long as sequential
|
|
||||||
# Sequential: 5*0.05 + 0.5 = 0.75s
|
|
||||||
# Concurrent: max(0.5, 5*0.05/3) ≈ 0.5s
|
|
||||||
# Allow some overhead for async scheduling
|
|
||||||
assert elapsed_time < 1.2
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_batch_operation_idempotency(
|
|
||||||
self,
|
|
||||||
large_series_app,
|
|
||||||
mock_nfo_service_with_media,
|
|
||||||
mock_settings
|
|
||||||
):
|
|
||||||
"""Test that running same batch twice is safe."""
|
|
||||||
request = NFOBatchCreateRequest(
|
|
||||||
serie_ids=[f"anime{i:02d}" for i in range(3)],
|
|
||||||
skip_existing=False, # Overwrite
|
|
||||||
download_media=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# First run
|
|
||||||
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
|
|
||||||
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
|
|
||||||
|
|
||||||
result1 = await batch_create_nfo(
|
|
||||||
request=request,
|
|
||||||
_auth={"username": "test"},
|
|
||||||
series_app=large_series_app,
|
|
||||||
nfo_service=mock_nfo_service_with_media
|
|
||||||
)
|
|
||||||
|
|
||||||
# Second run (idempotent)
|
|
||||||
mock_nfo_service_with_media.create_tvshow_nfo.reset_mock()
|
|
||||||
|
|
||||||
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
|
|
||||||
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
|
|
||||||
|
|
||||||
result2 = await batch_create_nfo(
|
|
||||||
request=request,
|
|
||||||
_auth={"username": "test"},
|
|
||||||
series_app=large_series_app,
|
|
||||||
nfo_service=mock_nfo_service_with_media
|
|
||||||
)
|
|
||||||
|
|
||||||
# Both should succeed with same results
|
|
||||||
assert result1.successful == result2.successful == 3
|
|
||||||
assert result1.total == result2.total == 3
|
|
||||||
@@ -1,500 +0,0 @@
|
|||||||
"""Integration tests for NFO creation during download flow.
|
|
||||||
|
|
||||||
Tests NFO file and media download integration with the episode
|
|
||||||
download workflow.
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.config.settings import Settings
|
|
||||||
from src.core.SeriesApp import DownloadStatusEventArgs, SeriesApp
|
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
from src.core.services.tmdb_client import TMDBAPIError
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def temp_anime_dir(tmp_path):
|
|
||||||
"""Create temporary anime directory."""
|
|
||||||
anime_dir = tmp_path / "anime"
|
|
||||||
anime_dir.mkdir()
|
|
||||||
return str(anime_dir)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_settings(temp_anime_dir):
|
|
||||||
"""Create mock settings with NFO configuration."""
|
|
||||||
settings = Settings()
|
|
||||||
settings.anime_directory = temp_anime_dir
|
|
||||||
settings.tmdb_api_key = "test_api_key_12345"
|
|
||||||
settings.nfo_auto_create = True
|
|
||||||
settings.nfo_download_poster = True
|
|
||||||
settings.nfo_download_logo = True
|
|
||||||
settings.nfo_download_fanart = True
|
|
||||||
settings.nfo_image_size = "original"
|
|
||||||
return settings
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_nfo_service():
|
|
||||||
"""Create mock NFO service."""
|
|
||||||
service = Mock(spec=NFOService)
|
|
||||||
service.check_nfo_exists = AsyncMock(return_value=False)
|
|
||||||
service.create_tvshow_nfo = AsyncMock()
|
|
||||||
return service
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_loader():
|
|
||||||
"""Create mock loader for downloads."""
|
|
||||||
loader = Mock()
|
|
||||||
loader.download = Mock(return_value=True)
|
|
||||||
loader.subscribe_download_progress = Mock()
|
|
||||||
loader.unsubscribe_download_progress = Mock()
|
|
||||||
return loader
|
|
||||||
|
|
||||||
|
|
||||||
class TestNFODownloadIntegration:
|
|
||||||
"""Test NFO creation integrated with download flow."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_creates_nfo_when_missing(
|
|
||||||
self,
|
|
||||||
temp_anime_dir,
|
|
||||||
mock_settings,
|
|
||||||
mock_nfo_service,
|
|
||||||
mock_loader
|
|
||||||
):
|
|
||||||
"""Test NFO is created when missing and auto-create is enabled."""
|
|
||||||
# Setup
|
|
||||||
with patch('src.core.SeriesApp.settings', mock_settings), \
|
|
||||||
patch('src.core.SeriesApp.Loaders') as mock_loaders_class:
|
|
||||||
|
|
||||||
# Configure mock loaders
|
|
||||||
mock_loaders = Mock()
|
|
||||||
mock_loaders.GetLoader.return_value = mock_loader
|
|
||||||
mock_loaders_class.return_value = mock_loaders
|
|
||||||
|
|
||||||
# Create SeriesApp
|
|
||||||
series_app = SeriesApp(directory_to_search=temp_anime_dir)
|
|
||||||
series_app.nfo_service = mock_nfo_service
|
|
||||||
|
|
||||||
# Track download events
|
|
||||||
events_received = []
|
|
||||||
|
|
||||||
def on_download_status(args: DownloadStatusEventArgs):
|
|
||||||
events_received.append({
|
|
||||||
"status": args.status,
|
|
||||||
"message": args.message,
|
|
||||||
"serie_folder": args.serie_folder
|
|
||||||
})
|
|
||||||
|
|
||||||
series_app._events.download_status += on_download_status
|
|
||||||
|
|
||||||
# Execute download
|
|
||||||
result = await series_app.download(
|
|
||||||
serie_folder="Test Anime (2024)",
|
|
||||||
season=1,
|
|
||||||
episode=1,
|
|
||||||
key="test-anime-key",
|
|
||||||
language="German Dub"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify NFO service was called
|
|
||||||
mock_nfo_service.check_nfo_exists.assert_called_once_with(
|
|
||||||
"Test Anime (2024)"
|
|
||||||
)
|
|
||||||
mock_nfo_service.create_tvshow_nfo.assert_called_once_with(
|
|
||||||
serie_name="Test Anime (2024)",
|
|
||||||
serie_folder="Test Anime (2024)",
|
|
||||||
download_poster=True,
|
|
||||||
download_logo=True,
|
|
||||||
download_fanart=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify download events
|
|
||||||
nfo_events = [
|
|
||||||
e for e in events_received
|
|
||||||
if e["status"] in ["nfo_creating", "nfo_completed"]
|
|
||||||
]
|
|
||||||
assert len(nfo_events) >= 2
|
|
||||||
assert nfo_events[0]["status"] == "nfo_creating"
|
|
||||||
assert nfo_events[1]["status"] == "nfo_completed"
|
|
||||||
|
|
||||||
# Verify download was successful
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_skips_nfo_when_exists(
|
|
||||||
self,
|
|
||||||
temp_anime_dir,
|
|
||||||
mock_settings,
|
|
||||||
mock_nfo_service,
|
|
||||||
mock_loader
|
|
||||||
):
|
|
||||||
"""Test NFO creation is skipped when file already exists."""
|
|
||||||
# Configure NFO service to report NFO exists
|
|
||||||
mock_nfo_service.check_nfo_exists = AsyncMock(return_value=True)
|
|
||||||
|
|
||||||
with patch('src.core.SeriesApp.settings', mock_settings), \
|
|
||||||
patch('src.core.SeriesApp.Loaders') as mock_loaders_class:
|
|
||||||
|
|
||||||
mock_loaders = Mock()
|
|
||||||
mock_loaders.GetLoader.return_value = mock_loader
|
|
||||||
mock_loaders_class.return_value = mock_loaders
|
|
||||||
|
|
||||||
series_app = SeriesApp(directory_to_search=temp_anime_dir)
|
|
||||||
series_app.nfo_service = mock_nfo_service
|
|
||||||
|
|
||||||
# Execute download
|
|
||||||
result = await series_app.download(
|
|
||||||
serie_folder="Existing Series",
|
|
||||||
season=1,
|
|
||||||
episode=1,
|
|
||||||
key="existing-key"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify NFO check was performed
|
|
||||||
mock_nfo_service.check_nfo_exists.assert_called_once_with(
|
|
||||||
"Existing Series"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify NFO was NOT created (already exists)
|
|
||||||
mock_nfo_service.create_tvshow_nfo.assert_not_called()
|
|
||||||
|
|
||||||
# Verify download still succeeded
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_continues_when_nfo_creation_fails(
|
|
||||||
self,
|
|
||||||
temp_anime_dir,
|
|
||||||
mock_settings,
|
|
||||||
mock_nfo_service,
|
|
||||||
mock_loader
|
|
||||||
):
|
|
||||||
"""Test download continues even if NFO creation fails."""
|
|
||||||
# Configure NFO service to fail
|
|
||||||
mock_nfo_service.create_tvshow_nfo = AsyncMock(
|
|
||||||
side_effect=TMDBAPIError("Series not found in TMDB")
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch('src.core.SeriesApp.settings', mock_settings), \
|
|
||||||
patch('src.core.SeriesApp.Loaders') as mock_loaders_class:
|
|
||||||
|
|
||||||
mock_loaders = Mock()
|
|
||||||
mock_loaders.GetLoader.return_value = mock_loader
|
|
||||||
mock_loaders_class.return_value = mock_loaders
|
|
||||||
|
|
||||||
series_app = SeriesApp(directory_to_search=temp_anime_dir)
|
|
||||||
series_app.nfo_service = mock_nfo_service
|
|
||||||
|
|
||||||
events_received = []
|
|
||||||
|
|
||||||
def on_download_status(args: DownloadStatusEventArgs):
|
|
||||||
events_received.append({
|
|
||||||
"status": args.status,
|
|
||||||
"message": args.message
|
|
||||||
})
|
|
||||||
|
|
||||||
series_app._events.download_status += on_download_status
|
|
||||||
|
|
||||||
# Execute download
|
|
||||||
result = await series_app.download(
|
|
||||||
serie_folder="Unknown Series",
|
|
||||||
season=1,
|
|
||||||
episode=1,
|
|
||||||
key="unknown-key"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify NFO creation was attempted
|
|
||||||
mock_nfo_service.create_tvshow_nfo.assert_called_once()
|
|
||||||
|
|
||||||
# Verify nfo_failed event was fired
|
|
||||||
nfo_failed_events = [
|
|
||||||
e for e in events_received if e["status"] == "nfo_failed"
|
|
||||||
]
|
|
||||||
assert len(nfo_failed_events) == 1
|
|
||||||
assert "NFO creation failed" in nfo_failed_events[0]["message"]
|
|
||||||
|
|
||||||
# Verify download still succeeded despite NFO failure
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_without_nfo_service(
|
|
||||||
self,
|
|
||||||
temp_anime_dir,
|
|
||||||
mock_loader
|
|
||||||
):
|
|
||||||
"""Test download works normally when NFO service is not configured."""
|
|
||||||
settings = Settings()
|
|
||||||
settings.anime_directory = temp_anime_dir
|
|
||||||
settings.tmdb_api_key = None # No TMDB API key
|
|
||||||
settings.nfo_auto_create = False
|
|
||||||
|
|
||||||
with patch('src.core.SeriesApp.settings', settings), \
|
|
||||||
patch('src.core.SeriesApp.Loaders') as mock_loaders_class:
|
|
||||||
|
|
||||||
mock_loaders = Mock()
|
|
||||||
mock_loaders.GetLoader.return_value = mock_loader
|
|
||||||
mock_loaders_class.return_value = mock_loaders
|
|
||||||
|
|
||||||
series_app = SeriesApp(directory_to_search=temp_anime_dir)
|
|
||||||
|
|
||||||
# NFO service should not be initialized
|
|
||||||
assert series_app.nfo_service is None
|
|
||||||
|
|
||||||
# Execute download
|
|
||||||
result = await series_app.download(
|
|
||||||
serie_folder="Regular Series",
|
|
||||||
season=1,
|
|
||||||
episode=1,
|
|
||||||
key="regular-key"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Download should succeed without NFO service
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_auto_create_disabled(
|
|
||||||
self,
|
|
||||||
temp_anime_dir,
|
|
||||||
mock_nfo_service,
|
|
||||||
mock_loader
|
|
||||||
):
|
|
||||||
"""Test NFO is not created when auto-create is disabled."""
|
|
||||||
settings = Settings()
|
|
||||||
settings.anime_directory = temp_anime_dir
|
|
||||||
settings.tmdb_api_key = "test_key"
|
|
||||||
settings.nfo_auto_create = False # Disabled
|
|
||||||
|
|
||||||
with patch('src.core.SeriesApp.settings', settings), \
|
|
||||||
patch('src.core.SeriesApp.Loaders') as mock_loaders_class:
|
|
||||||
|
|
||||||
mock_loaders = Mock()
|
|
||||||
mock_loaders.GetLoader.return_value = mock_loader
|
|
||||||
mock_loaders_class.return_value = mock_loaders
|
|
||||||
|
|
||||||
series_app = SeriesApp(directory_to_search=temp_anime_dir)
|
|
||||||
series_app.nfo_service = mock_nfo_service
|
|
||||||
|
|
||||||
# Execute download
|
|
||||||
result = await series_app.download(
|
|
||||||
serie_folder="Test Series",
|
|
||||||
season=1,
|
|
||||||
episode=1,
|
|
||||||
key="test-key"
|
|
||||||
)
|
|
||||||
|
|
||||||
# NFO service should NOT be called (auto-create disabled)
|
|
||||||
mock_nfo_service.check_nfo_exists.assert_not_called()
|
|
||||||
mock_nfo_service.create_tvshow_nfo.assert_not_called()
|
|
||||||
|
|
||||||
# Download should still succeed
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_progress_events(
|
|
||||||
self,
|
|
||||||
temp_anime_dir,
|
|
||||||
mock_settings,
|
|
||||||
mock_nfo_service,
|
|
||||||
mock_loader
|
|
||||||
):
|
|
||||||
"""Test NFO progress events are fired correctly."""
|
|
||||||
with patch('src.core.SeriesApp.settings', mock_settings), \
|
|
||||||
patch('src.core.SeriesApp.Loaders') as mock_loaders_class:
|
|
||||||
|
|
||||||
mock_loaders = Mock()
|
|
||||||
mock_loaders.GetLoader.return_value = mock_loader
|
|
||||||
mock_loaders_class.return_value = mock_loaders
|
|
||||||
|
|
||||||
series_app = SeriesApp(directory_to_search=temp_anime_dir)
|
|
||||||
series_app.nfo_service = mock_nfo_service
|
|
||||||
|
|
||||||
events_received = []
|
|
||||||
|
|
||||||
def on_download_status(args: DownloadStatusEventArgs):
|
|
||||||
events_received.append({
|
|
||||||
"status": args.status,
|
|
||||||
"message": args.message,
|
|
||||||
"serie_folder": args.serie_folder,
|
|
||||||
"key": args.key,
|
|
||||||
"season": args.season,
|
|
||||||
"episode": args.episode,
|
|
||||||
"item_id": args.item_id
|
|
||||||
})
|
|
||||||
|
|
||||||
series_app._events.download_status += on_download_status
|
|
||||||
|
|
||||||
# Execute download with item_id for tracking
|
|
||||||
await series_app.download(
|
|
||||||
serie_folder="Progress Test",
|
|
||||||
season=1,
|
|
||||||
episode=5,
|
|
||||||
key="progress-key",
|
|
||||||
item_id="test-item-123"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify NFO events sequence
|
|
||||||
nfo_creating = next(
|
|
||||||
(e for e in events_received if e["status"] == "nfo_creating"),
|
|
||||||
None
|
|
||||||
)
|
|
||||||
nfo_completed = next(
|
|
||||||
(e for e in events_received if e["status"] == "nfo_completed"),
|
|
||||||
None
|
|
||||||
)
|
|
||||||
|
|
||||||
assert nfo_creating is not None
|
|
||||||
assert nfo_creating["message"] == "Creating NFO metadata..."
|
|
||||||
assert nfo_creating["serie_folder"] == "Progress Test"
|
|
||||||
assert nfo_creating["key"] == "progress-key"
|
|
||||||
assert nfo_creating["season"] == 1
|
|
||||||
assert nfo_creating["episode"] == 5
|
|
||||||
assert nfo_creating["item_id"] == "test-item-123"
|
|
||||||
|
|
||||||
assert nfo_completed is not None
|
|
||||||
assert nfo_completed["message"] == "NFO metadata created"
|
|
||||||
assert nfo_completed["item_id"] == "test-item-123"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_media_download_settings_respected(
|
|
||||||
self,
|
|
||||||
temp_anime_dir,
|
|
||||||
mock_nfo_service,
|
|
||||||
mock_loader
|
|
||||||
):
|
|
||||||
"""Test NFO service respects media download settings."""
|
|
||||||
settings = Settings()
|
|
||||||
settings.anime_directory = temp_anime_dir
|
|
||||||
settings.tmdb_api_key = "test_key"
|
|
||||||
settings.nfo_auto_create = True
|
|
||||||
settings.nfo_download_poster = True
|
|
||||||
settings.nfo_download_logo = False # Disabled
|
|
||||||
settings.nfo_download_fanart = True
|
|
||||||
|
|
||||||
with patch('src.core.SeriesApp.settings', settings), \
|
|
||||||
patch('src.core.SeriesApp.Loaders') as mock_loaders_class:
|
|
||||||
|
|
||||||
mock_loaders = Mock()
|
|
||||||
mock_loaders.GetLoader.return_value = mock_loader
|
|
||||||
mock_loaders_class.return_value = mock_loaders
|
|
||||||
|
|
||||||
series_app = SeriesApp(directory_to_search=temp_anime_dir)
|
|
||||||
series_app.nfo_service = mock_nfo_service
|
|
||||||
|
|
||||||
# Execute download
|
|
||||||
await series_app.download(
|
|
||||||
serie_folder="Media Test",
|
|
||||||
season=1,
|
|
||||||
episode=1,
|
|
||||||
key="media-key"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify settings were passed correctly
|
|
||||||
mock_nfo_service.create_tvshow_nfo.assert_called_once_with(
|
|
||||||
serie_name="Media Test",
|
|
||||||
serie_folder="Media Test",
|
|
||||||
download_poster=True,
|
|
||||||
download_logo=False, # Disabled in settings
|
|
||||||
download_fanart=True
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_creation_with_folder_creation(
|
|
||||||
self,
|
|
||||||
temp_anime_dir,
|
|
||||||
mock_settings,
|
|
||||||
mock_nfo_service,
|
|
||||||
mock_loader
|
|
||||||
):
|
|
||||||
"""Test NFO is created even when series folder doesn't exist."""
|
|
||||||
with patch('src.core.SeriesApp.settings', mock_settings), \
|
|
||||||
patch('src.core.SeriesApp.Loaders') as mock_loaders_class:
|
|
||||||
|
|
||||||
mock_loaders = Mock()
|
|
||||||
mock_loaders.GetLoader.return_value = mock_loader
|
|
||||||
mock_loaders_class.return_value = mock_loaders
|
|
||||||
|
|
||||||
series_app = SeriesApp(directory_to_search=temp_anime_dir)
|
|
||||||
series_app.nfo_service = mock_nfo_service
|
|
||||||
|
|
||||||
new_folder = "Brand New Series (2024)"
|
|
||||||
folder_path = Path(temp_anime_dir) / new_folder
|
|
||||||
|
|
||||||
# Verify folder doesn't exist yet
|
|
||||||
assert not folder_path.exists()
|
|
||||||
|
|
||||||
# Execute download
|
|
||||||
result = await series_app.download(
|
|
||||||
serie_folder=new_folder,
|
|
||||||
season=1,
|
|
||||||
episode=1,
|
|
||||||
key="new-series-key"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify folder was created
|
|
||||||
assert folder_path.exists()
|
|
||||||
|
|
||||||
# Verify NFO creation was attempted
|
|
||||||
mock_nfo_service.check_nfo_exists.assert_called_once()
|
|
||||||
mock_nfo_service.create_tvshow_nfo.assert_called_once()
|
|
||||||
|
|
||||||
# Verify download succeeded
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestNFOServiceInitialization:
|
|
||||||
"""Test NFO service initialization in SeriesApp."""
|
|
||||||
|
|
||||||
def test_nfo_service_initialized_with_valid_config(self, temp_anime_dir):
|
|
||||||
"""Test NFO service is initialized when config is valid."""
|
|
||||||
settings = Settings()
|
|
||||||
settings.anime_directory = temp_anime_dir
|
|
||||||
settings.tmdb_api_key = "valid_api_key_123"
|
|
||||||
settings.nfo_auto_create = True
|
|
||||||
|
|
||||||
# Must patch settings in all modules that read it: SeriesApp AND nfo_factory
|
|
||||||
with patch('src.core.SeriesApp.settings', settings), \
|
|
||||||
patch('src.core.services.nfo_factory.settings', settings), \
|
|
||||||
patch('src.core.SeriesApp.Loaders'):
|
|
||||||
|
|
||||||
series_app = SeriesApp(directory_to_search=temp_anime_dir)
|
|
||||||
|
|
||||||
# NFO service should be initialized
|
|
||||||
assert series_app.nfo_service is not None
|
|
||||||
assert isinstance(series_app.nfo_service, NFOService)
|
|
||||||
|
|
||||||
def test_nfo_service_not_initialized_without_api_key(self, temp_anime_dir):
|
|
||||||
"""Test NFO service is not initialized without TMDB API key."""
|
|
||||||
settings = Settings()
|
|
||||||
settings.anime_directory = temp_anime_dir
|
|
||||||
settings.tmdb_api_key = None # No API key
|
|
||||||
|
|
||||||
with patch('src.core.SeriesApp.settings', settings), \
|
|
||||||
patch('src.core.SeriesApp.Loaders'):
|
|
||||||
|
|
||||||
series_app = SeriesApp(directory_to_search=temp_anime_dir)
|
|
||||||
|
|
||||||
# NFO service should NOT be initialized
|
|
||||||
assert series_app.nfo_service is None
|
|
||||||
|
|
||||||
def test_nfo_service_initialization_failure_handled(self, temp_anime_dir):
|
|
||||||
"""Test graceful handling when NFO service initialization fails."""
|
|
||||||
settings = Settings()
|
|
||||||
settings.anime_directory = temp_anime_dir
|
|
||||||
settings.tmdb_api_key = "test_key"
|
|
||||||
|
|
||||||
with patch('src.core.SeriesApp.settings', settings), \
|
|
||||||
patch('src.core.SeriesApp.Loaders'), \
|
|
||||||
patch('src.core.services.nfo_factory.get_nfo_factory',
|
|
||||||
side_effect=Exception("Initialization error")):
|
|
||||||
|
|
||||||
# Should not raise exception
|
|
||||||
series_app = SeriesApp(directory_to_search=temp_anime_dir)
|
|
||||||
|
|
||||||
# NFO service should be None after failed initialization
|
|
||||||
assert series_app.nfo_service is None
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
"""Integration test for NFO creation with missing folder.
|
|
||||||
|
|
||||||
Tests that NFO creation works when the series folder doesn't exist yet.
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
from src.core.services.tmdb_client import TMDBClient
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_creation_with_missing_folder_integration():
|
|
||||||
"""Integration test: NFO creation creates folder if missing."""
|
|
||||||
# Use actual temp directory for this test
|
|
||||||
import tempfile
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
anime_dir = Path(tmpdir)
|
|
||||||
serie_folder = "Test Anime Series"
|
|
||||||
folder_path = anime_dir / serie_folder
|
|
||||||
|
|
||||||
# Verify folder doesn't exist
|
|
||||||
assert not folder_path.exists()
|
|
||||||
|
|
||||||
# Create NFO service
|
|
||||||
nfo_service = NFOService(
|
|
||||||
tmdb_api_key="test_key",
|
|
||||||
anime_directory=str(anime_dir),
|
|
||||||
image_size="original"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock TMDB responses
|
|
||||||
mock_search = {
|
|
||||||
"results": [{
|
|
||||||
"id": 99999,
|
|
||||||
"name": "Test Anime Series",
|
|
||||||
"first_air_date": "2023-01-01",
|
|
||||||
"overview": "Test",
|
|
||||||
"vote_average": 8.0
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_details = {
|
|
||||||
"id": 99999,
|
|
||||||
"name": "Test Anime Series",
|
|
||||||
"first_air_date": "2023-01-01",
|
|
||||||
"overview": "Test description",
|
|
||||||
"vote_average": 8.0,
|
|
||||||
"genres": [],
|
|
||||||
"networks": [],
|
|
||||||
"status": "Returning Series",
|
|
||||||
"number_of_seasons": 1,
|
|
||||||
"number_of_episodes": 10,
|
|
||||||
"poster_path": None,
|
|
||||||
"backdrop_path": None
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_ratings = {"results": []}
|
|
||||||
|
|
||||||
# Patch TMDB client methods
|
|
||||||
with patch.object(
|
|
||||||
nfo_service.tmdb_client, 'search_tv_show',
|
|
||||||
new_callable=AsyncMock
|
|
||||||
) as mock_search_method, \
|
|
||||||
patch.object(
|
|
||||||
nfo_service.tmdb_client, 'get_tv_show_details',
|
|
||||||
new_callable=AsyncMock
|
|
||||||
) as mock_details_method, \
|
|
||||||
patch.object(
|
|
||||||
nfo_service.tmdb_client, 'get_tv_show_content_ratings',
|
|
||||||
new_callable=AsyncMock
|
|
||||||
) as mock_ratings_method, \
|
|
||||||
patch.object(
|
|
||||||
nfo_service, '_download_media_files',
|
|
||||||
new_callable=AsyncMock
|
|
||||||
) as mock_download:
|
|
||||||
|
|
||||||
mock_search_method.return_value = mock_search
|
|
||||||
mock_details_method.return_value = mock_details
|
|
||||||
mock_ratings_method.return_value = mock_ratings
|
|
||||||
mock_download.return_value = {
|
|
||||||
"poster": False,
|
|
||||||
"logo": False,
|
|
||||||
"fanart": False
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create NFO - this should create the folder
|
|
||||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
|
||||||
serie_name="Test Anime Series",
|
|
||||||
serie_folder=serie_folder,
|
|
||||||
year=2023,
|
|
||||||
download_poster=False,
|
|
||||||
download_logo=False,
|
|
||||||
download_fanart=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify folder was created
|
|
||||||
assert folder_path.exists(), "Series folder should have been created"
|
|
||||||
assert folder_path.is_dir(), "Series folder should be a directory"
|
|
||||||
|
|
||||||
# Verify NFO file exists
|
|
||||||
assert nfo_path.exists(), "NFO file should exist"
|
|
||||||
assert nfo_path.name == "tvshow.nfo", "NFO file should be named tvshow.nfo"
|
|
||||||
assert nfo_path.parent == folder_path, "NFO should be in series folder"
|
|
||||||
|
|
||||||
# Verify NFO file has content
|
|
||||||
nfo_content = nfo_path.read_text()
|
|
||||||
assert "<tvshow>" in nfo_content, "NFO should contain tvshow tag"
|
|
||||||
assert "<title>Test Anime Series</title>" in nfo_content, "NFO should contain title"
|
|
||||||
|
|
||||||
print(f"✓ Test passed: Folder created at {folder_path}")
|
|
||||||
print(f"✓ NFO file created at {nfo_path}")
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
"""Integration tests for NFO ID database storage."""
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy import create_engine, select
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from src.core.services.series_manager_service import SeriesManagerService
|
|
||||||
from src.server.database.base import Base
|
|
||||||
from src.server.database.models import AnimeSeries
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def db_engine():
|
|
||||||
"""Create in-memory SQLite database for testing."""
|
|
||||||
engine = create_engine("sqlite:///:memory:", echo=False)
|
|
||||||
Base.metadata.create_all(engine)
|
|
||||||
return engine
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def db_session(db_engine):
|
|
||||||
"""Create database session for testing."""
|
|
||||||
SessionLocal = sessionmaker(bind=db_engine)
|
|
||||||
session = SessionLocal()
|
|
||||||
yield session
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
class TestNFODatabaseIntegration:
|
|
||||||
"""Test NFO ID extraction and database storage."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def temp_anime_dir(self):
|
|
||||||
"""Create temporary anime directory."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
yield tmpdir
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_serie(self):
|
|
||||||
"""Create a mock Serie object."""
|
|
||||||
serie = Mock()
|
|
||||||
serie.key = "test_series_key"
|
|
||||||
serie.name = "Test Series"
|
|
||||||
serie.folder = "test_series"
|
|
||||||
serie.site = "test_site"
|
|
||||||
serie.year = 2020
|
|
||||||
return serie
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_nfo_content(self):
|
|
||||||
"""Sample NFO content with IDs."""
|
|
||||||
return """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
||||||
<tvshow>
|
|
||||||
<title>Test Series</title>
|
|
||||||
<uniqueid type="tmdb" default="true">12345</uniqueid>
|
|
||||||
<uniqueid type="tvdb">67890</uniqueid>
|
|
||||||
<plot>A test series for integration testing.</plot>
|
|
||||||
</tvshow>"""
|
|
||||||
|
|
||||||
async def test_nfo_ids_stored_in_database(
|
|
||||||
self, temp_anime_dir, mock_serie, sample_nfo_content, db_session
|
|
||||||
):
|
|
||||||
"""Test that IDs from NFO files are stored in database."""
|
|
||||||
# Create series folder with NFO file
|
|
||||||
series_folder = Path(temp_anime_dir) / "test_series"
|
|
||||||
series_folder.mkdir(parents=True)
|
|
||||||
nfo_path = series_folder / "tvshow.nfo"
|
|
||||||
nfo_path.write_text(sample_nfo_content, encoding='utf-8')
|
|
||||||
|
|
||||||
# Create AnimeSeries in database
|
|
||||||
anime_series = AnimeSeries(
|
|
||||||
key="test_series_key",
|
|
||||||
name="Test Series",
|
|
||||||
site="test_site",
|
|
||||||
folder="test_series"
|
|
||||||
)
|
|
||||||
db_session.add(anime_series)
|
|
||||||
db_session.commit()
|
|
||||||
|
|
||||||
# Note: This test demonstrates the concept but cannot test
|
|
||||||
# the async database session integration without setting up
|
|
||||||
# the full async infrastructure. The unit tests verify the
|
|
||||||
# parsing logic works correctly.
|
|
||||||
|
|
||||||
# Verify series was created
|
|
||||||
result = db_session.execute(
|
|
||||||
select(AnimeSeries).filter(
|
|
||||||
AnimeSeries.key == "test_series_key"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
series = result.scalars().first()
|
|
||||||
|
|
||||||
assert series is not None
|
|
||||||
assert series.key == "test_series_key"
|
|
||||||
|
|
||||||
async def test_nfo_parsing_integration(
|
|
||||||
self, temp_anime_dir, sample_nfo_content
|
|
||||||
):
|
|
||||||
"""Test NFO ID parsing integration with NFOService."""
|
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
|
|
||||||
# Create series folder with NFO file
|
|
||||||
series_folder = Path(temp_anime_dir) / "test_series"
|
|
||||||
series_folder.mkdir(parents=True)
|
|
||||||
nfo_path = series_folder / "tvshow.nfo"
|
|
||||||
nfo_path.write_text(sample_nfo_content, encoding='utf-8')
|
|
||||||
|
|
||||||
# Create NFO service
|
|
||||||
nfo_service = NFOService(
|
|
||||||
tmdb_api_key="test_key",
|
|
||||||
anime_directory=temp_anime_dir,
|
|
||||||
auto_create=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse IDs
|
|
||||||
ids = nfo_service.parse_nfo_ids(nfo_path)
|
|
||||||
|
|
||||||
assert ids["tmdb_id"] == 12345
|
|
||||||
assert ids["tvdb_id"] == 67890
|
|
||||||
|
|
||||||
@@ -1,510 +0,0 @@
|
|||||||
"""Integration tests for NFO creation and media download workflows."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
from src.core.services.tmdb_client import TMDBAPIError
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def anime_dir(tmp_path):
|
|
||||||
"""Create temporary anime directory."""
|
|
||||||
anime_dir = tmp_path / "anime"
|
|
||||||
anime_dir.mkdir()
|
|
||||||
return anime_dir
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def nfo_service(anime_dir):
|
|
||||||
"""Create NFO service with temp directory."""
|
|
||||||
return NFOService(
|
|
||||||
tmdb_api_key="test_api_key",
|
|
||||||
anime_directory=str(anime_dir),
|
|
||||||
image_size="w500",
|
|
||||||
auto_create=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_tmdb_complete():
|
|
||||||
"""Complete TMDB data with all fields."""
|
|
||||||
return {
|
|
||||||
"id": 1429,
|
|
||||||
"name": "Attack on Titan",
|
|
||||||
"original_name": "進撃の巨人",
|
|
||||||
"first_air_date": "2013-04-07",
|
|
||||||
"overview": "Humans fight against giant humanoid Titans.",
|
|
||||||
"vote_average": 8.6,
|
|
||||||
"vote_count": 5000,
|
|
||||||
"status": "Ended",
|
|
||||||
"episode_run_time": [24],
|
|
||||||
"genres": [{"id": 16, "name": "Animation"}],
|
|
||||||
"networks": [{"id": 1, "name": "MBS"}],
|
|
||||||
"production_countries": [{"name": "Japan"}],
|
|
||||||
"poster_path": "/poster.jpg",
|
|
||||||
"backdrop_path": "/backdrop.jpg",
|
|
||||||
"external_ids": {
|
|
||||||
"imdb_id": "tt2560140",
|
|
||||||
"tvdb_id": 267440
|
|
||||||
},
|
|
||||||
"credits": {
|
|
||||||
"cast": [
|
|
||||||
{"id": 1, "name": "Yuki Kaji", "character": "Eren", "profile_path": "/actor.jpg"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"images": {
|
|
||||||
"logos": [{"file_path": "/logo.png"}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_content_ratings():
|
|
||||||
"""Mock content ratings with German FSK."""
|
|
||||||
return {
|
|
||||||
"results": [
|
|
||||||
{"iso_3166_1": "DE", "rating": "16"},
|
|
||||||
{"iso_3166_1": "US", "rating": "TV-MA"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestNFOCreationFlow:
|
|
||||||
"""Test complete NFO creation workflow."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_complete_nfo_creation_workflow(
|
|
||||||
self,
|
|
||||||
nfo_service,
|
|
||||||
anime_dir,
|
|
||||||
mock_tmdb_complete,
|
|
||||||
mock_content_ratings
|
|
||||||
):
|
|
||||||
"""Test complete NFO creation with all media files."""
|
|
||||||
series_name = "Attack on Titan"
|
|
||||||
series_folder = anime_dir / series_name
|
|
||||||
series_folder.mkdir()
|
|
||||||
|
|
||||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
|
|
||||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
|
|
||||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
|
|
||||||
patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
||||||
|
|
||||||
mock_search.return_value = {
|
|
||||||
"results": [{"id": 1429, "name": series_name, "first_air_date": "2013-04-07"}]
|
|
||||||
}
|
|
||||||
mock_details.return_value = mock_tmdb_complete
|
|
||||||
mock_ratings.return_value = mock_content_ratings
|
|
||||||
mock_download.return_value = {
|
|
||||||
"poster": True,
|
|
||||||
"logo": True,
|
|
||||||
"fanart": True
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create NFO
|
|
||||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
|
||||||
series_name,
|
|
||||||
series_name,
|
|
||||||
year=2013,
|
|
||||||
download_poster=True,
|
|
||||||
download_logo=True,
|
|
||||||
download_fanart=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify NFO file exists
|
|
||||||
assert nfo_path.exists()
|
|
||||||
assert nfo_path.name == "tvshow.nfo"
|
|
||||||
assert nfo_path.parent == series_folder
|
|
||||||
|
|
||||||
# Verify NFO content
|
|
||||||
nfo_content = nfo_path.read_text(encoding="utf-8")
|
|
||||||
assert "<title>Attack on Titan</title>" in nfo_content
|
|
||||||
assert "<mpaa>FSK 16</mpaa>" in nfo_content
|
|
||||||
assert "<tmdbid>1429</tmdbid>" in nfo_content
|
|
||||||
|
|
||||||
# Verify media download was called
|
|
||||||
mock_download.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_creation_without_media(
|
|
||||||
self,
|
|
||||||
nfo_service,
|
|
||||||
anime_dir,
|
|
||||||
mock_tmdb_complete,
|
|
||||||
mock_content_ratings
|
|
||||||
):
|
|
||||||
"""Test NFO creation without downloading media files."""
|
|
||||||
series_name = "Test Series"
|
|
||||||
series_folder = anime_dir / series_name
|
|
||||||
series_folder.mkdir()
|
|
||||||
|
|
||||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
|
|
||||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
|
|
||||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
|
|
||||||
patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
||||||
|
|
||||||
mock_search.return_value = {
|
|
||||||
"results": [{"id": 1, "name": series_name, "first_air_date": "2020-01-01"}]
|
|
||||||
}
|
|
||||||
mock_details.return_value = mock_tmdb_complete
|
|
||||||
mock_ratings.return_value = mock_content_ratings
|
|
||||||
mock_download.return_value = {}
|
|
||||||
|
|
||||||
# Create NFO without media
|
|
||||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
|
||||||
series_name,
|
|
||||||
series_name,
|
|
||||||
download_poster=False,
|
|
||||||
download_logo=False,
|
|
||||||
download_fanart=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# NFO should exist
|
|
||||||
assert nfo_path.exists()
|
|
||||||
|
|
||||||
# Verify no media URLs were passed
|
|
||||||
call_args = mock_download.call_args
|
|
||||||
assert call_args.kwargs['poster_url'] is None
|
|
||||||
assert call_args.kwargs['logo_url'] is None
|
|
||||||
assert call_args.kwargs['fanart_url'] is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_folder_structure(
|
|
||||||
self,
|
|
||||||
nfo_service,
|
|
||||||
anime_dir,
|
|
||||||
mock_tmdb_complete,
|
|
||||||
mock_content_ratings
|
|
||||||
):
|
|
||||||
"""Test that NFO and media files are in correct folder structure."""
|
|
||||||
series_name = "Test Series"
|
|
||||||
series_folder = anime_dir / series_name
|
|
||||||
series_folder.mkdir()
|
|
||||||
|
|
||||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
|
|
||||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
|
|
||||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
|
|
||||||
patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
||||||
|
|
||||||
mock_search.return_value = {
|
|
||||||
"results": [{"id": 1, "name": series_name, "first_air_date": "2020-01-01"}]
|
|
||||||
}
|
|
||||||
mock_details.return_value = mock_tmdb_complete
|
|
||||||
mock_ratings.return_value = mock_content_ratings
|
|
||||||
mock_download.return_value = {"poster": True}
|
|
||||||
|
|
||||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
|
||||||
series_name,
|
|
||||||
series_name,
|
|
||||||
download_poster=True,
|
|
||||||
download_logo=False,
|
|
||||||
download_fanart=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify folder structure
|
|
||||||
assert nfo_path.parent.name == series_name
|
|
||||||
assert nfo_path.parent.parent == anime_dir
|
|
||||||
|
|
||||||
# Verify download was called with correct folder
|
|
||||||
call_args = mock_download.call_args
|
|
||||||
assert call_args.args[0] == series_folder
|
|
||||||
|
|
||||||
|
|
||||||
class TestNFOUpdateFlow:
|
|
||||||
"""Test NFO update workflow."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_update_refreshes_content(
|
|
||||||
self,
|
|
||||||
nfo_service,
|
|
||||||
anime_dir,
|
|
||||||
mock_tmdb_complete,
|
|
||||||
mock_content_ratings
|
|
||||||
):
|
|
||||||
"""Test that NFO update refreshes content from TMDB."""
|
|
||||||
series_name = "Test Series"
|
|
||||||
series_folder = anime_dir / series_name
|
|
||||||
series_folder.mkdir()
|
|
||||||
|
|
||||||
# Create initial NFO
|
|
||||||
nfo_path = series_folder / "tvshow.nfo"
|
|
||||||
nfo_path.write_text("""<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<tvshow>
|
|
||||||
<title>Old Title</title>
|
|
||||||
<plot>Old plot</plot>
|
|
||||||
<tmdbid>1429</tmdbid>
|
|
||||||
</tvshow>
|
|
||||||
""", encoding="utf-8")
|
|
||||||
|
|
||||||
with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
|
|
||||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
|
|
||||||
patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
||||||
|
|
||||||
mock_details.return_value = mock_tmdb_complete
|
|
||||||
mock_ratings.return_value = mock_content_ratings
|
|
||||||
mock_download.return_value = {}
|
|
||||||
|
|
||||||
# Update NFO
|
|
||||||
updated_path = await nfo_service.update_tvshow_nfo(
|
|
||||||
series_name,
|
|
||||||
download_media=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify content was updated
|
|
||||||
updated_content = updated_path.read_text(encoding="utf-8")
|
|
||||||
assert "<title>Attack on Titan</title>" in updated_content
|
|
||||||
assert "Old Title" not in updated_content
|
|
||||||
assert "進撃の巨人" in updated_content
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_update_with_media_redownload(
|
|
||||||
self,
|
|
||||||
nfo_service,
|
|
||||||
anime_dir,
|
|
||||||
mock_tmdb_complete,
|
|
||||||
mock_content_ratings
|
|
||||||
):
|
|
||||||
"""Test NFO update re-downloads media files."""
|
|
||||||
series_name = "Test Series"
|
|
||||||
series_folder = anime_dir / series_name
|
|
||||||
series_folder.mkdir()
|
|
||||||
|
|
||||||
nfo_path = series_folder / "tvshow.nfo"
|
|
||||||
nfo_path.write_text("""<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<tvshow>
|
|
||||||
<title>Test</title>
|
|
||||||
<tmdbid>1429</tmdbid>
|
|
||||||
</tvshow>
|
|
||||||
""", encoding="utf-8")
|
|
||||||
|
|
||||||
with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
|
|
||||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
|
|
||||||
patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
||||||
|
|
||||||
mock_details.return_value = mock_tmdb_complete
|
|
||||||
mock_ratings.return_value = mock_content_ratings
|
|
||||||
mock_download.return_value = {"poster": True, "logo": True, "fanart": True}
|
|
||||||
|
|
||||||
# Update with media
|
|
||||||
await nfo_service.update_tvshow_nfo(
|
|
||||||
series_name,
|
|
||||||
download_media=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify media download was called
|
|
||||||
mock_download.assert_called_once()
|
|
||||||
call_args = mock_download.call_args
|
|
||||||
assert call_args.kwargs['poster_url'] is not None
|
|
||||||
assert call_args.kwargs['logo_url'] is not None
|
|
||||||
assert call_args.kwargs['fanart_url'] is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestNFOErrorHandling:
|
|
||||||
"""Test NFO service error handling."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_creation_continues_despite_media_failure(
|
|
||||||
self,
|
|
||||||
nfo_service,
|
|
||||||
anime_dir,
|
|
||||||
mock_tmdb_complete,
|
|
||||||
mock_content_ratings
|
|
||||||
):
|
|
||||||
"""Test that NFO is created even if media download fails."""
|
|
||||||
series_name = "Test Series"
|
|
||||||
series_folder = anime_dir / series_name
|
|
||||||
series_folder.mkdir()
|
|
||||||
|
|
||||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
|
|
||||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
|
|
||||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
|
|
||||||
patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
||||||
|
|
||||||
mock_search.return_value = {
|
|
||||||
"results": [{"id": 1, "name": series_name, "first_air_date": "2020-01-01"}]
|
|
||||||
}
|
|
||||||
mock_details.return_value = mock_tmdb_complete
|
|
||||||
mock_ratings.return_value = mock_content_ratings
|
|
||||||
# Simulate media download failure
|
|
||||||
mock_download.return_value = {"poster": False, "logo": False, "fanart": False}
|
|
||||||
|
|
||||||
# NFO creation should succeed
|
|
||||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
|
||||||
series_name,
|
|
||||||
series_name,
|
|
||||||
download_poster=True,
|
|
||||||
download_logo=True,
|
|
||||||
download_fanart=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# NFO should exist despite media failure
|
|
||||||
assert nfo_path.exists()
|
|
||||||
nfo_content = nfo_path.read_text(encoding="utf-8")
|
|
||||||
assert "<tvshow>" in nfo_content
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_creation_fails_with_invalid_folder(
|
|
||||||
self,
|
|
||||||
nfo_service,
|
|
||||||
anime_dir
|
|
||||||
):
|
|
||||||
"""Test NFO creation fails gracefully with invalid search results."""
|
|
||||||
with patch.object(
|
|
||||||
nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock,
|
|
||||||
return_value={"results": []}
|
|
||||||
):
|
|
||||||
with pytest.raises(TMDBAPIError, match="No results found"):
|
|
||||||
await nfo_service.create_tvshow_nfo(
|
|
||||||
"Nonexistent",
|
|
||||||
"nonexistent_folder",
|
|
||||||
download_poster=False,
|
|
||||||
download_logo=False,
|
|
||||||
download_fanart=False
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestConcurrentNFOOperations:
|
|
||||||
"""Test concurrent NFO operations."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_concurrent_nfo_creation(
|
|
||||||
self,
|
|
||||||
anime_dir,
|
|
||||||
mock_tmdb_complete,
|
|
||||||
mock_content_ratings
|
|
||||||
):
|
|
||||||
"""Test creating NFOs for multiple series concurrently."""
|
|
||||||
nfo_service = NFOService(
|
|
||||||
tmdb_api_key="test_key",
|
|
||||||
anime_directory=str(anime_dir),
|
|
||||||
image_size="w500"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create multiple series folders
|
|
||||||
series_list = ["Series1", "Series2", "Series3"]
|
|
||||||
for series in series_list:
|
|
||||||
(anime_dir / series).mkdir()
|
|
||||||
|
|
||||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
|
|
||||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
|
|
||||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
|
|
||||||
patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
||||||
|
|
||||||
# Mock responses for all series
|
|
||||||
mock_search.return_value = {
|
|
||||||
"results": [{"id": 1, "name": "Test", "first_air_date": "2020-01-01"}]
|
|
||||||
}
|
|
||||||
mock_details.return_value = mock_tmdb_complete
|
|
||||||
mock_ratings.return_value = mock_content_ratings
|
|
||||||
mock_download.return_value = {"poster": True}
|
|
||||||
|
|
||||||
# Create NFOs concurrently
|
|
||||||
tasks = [
|
|
||||||
nfo_service.create_tvshow_nfo(
|
|
||||||
series,
|
|
||||||
series,
|
|
||||||
download_poster=True,
|
|
||||||
download_logo=False,
|
|
||||||
download_fanart=False
|
|
||||||
)
|
|
||||||
for series in series_list
|
|
||||||
]
|
|
||||||
|
|
||||||
nfo_paths = await asyncio.gather(*tasks)
|
|
||||||
|
|
||||||
# Verify all NFOs were created
|
|
||||||
assert len(nfo_paths) == 3
|
|
||||||
for nfo_path in nfo_paths:
|
|
||||||
assert nfo_path.exists()
|
|
||||||
assert nfo_path.name == "tvshow.nfo"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_concurrent_media_downloads(
|
|
||||||
self,
|
|
||||||
nfo_service,
|
|
||||||
anime_dir,
|
|
||||||
mock_tmdb_complete
|
|
||||||
):
|
|
||||||
"""Test concurrent media downloads for same series."""
|
|
||||||
series_folder = anime_dir / "Test"
|
|
||||||
series_folder.mkdir()
|
|
||||||
|
|
||||||
with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
||||||
mock_download.return_value = {"poster": True, "logo": True, "fanart": True}
|
|
||||||
|
|
||||||
# Attempt concurrent downloads (simulating multiple calls)
|
|
||||||
tasks = [
|
|
||||||
nfo_service._download_media_files(
|
|
||||||
mock_tmdb_complete,
|
|
||||||
series_folder,
|
|
||||||
download_poster=True,
|
|
||||||
download_logo=True,
|
|
||||||
download_fanart=True
|
|
||||||
)
|
|
||||||
for _ in range(3)
|
|
||||||
]
|
|
||||||
|
|
||||||
results = await asyncio.gather(*tasks)
|
|
||||||
|
|
||||||
# All should succeed
|
|
||||||
assert len(results) == 3
|
|
||||||
for result in results:
|
|
||||||
assert result["poster"] is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestNFODataIntegrity:
|
|
||||||
"""Test NFO data integrity throughout workflow."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_preserves_all_metadata(
|
|
||||||
self,
|
|
||||||
nfo_service,
|
|
||||||
anime_dir,
|
|
||||||
mock_tmdb_complete,
|
|
||||||
mock_content_ratings
|
|
||||||
):
|
|
||||||
"""Test that all TMDB metadata is preserved in NFO."""
|
|
||||||
series_name = "Complete Test"
|
|
||||||
series_folder = anime_dir / series_name
|
|
||||||
series_folder.mkdir()
|
|
||||||
|
|
||||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
|
|
||||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
|
|
||||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
|
|
||||||
patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock):
|
|
||||||
|
|
||||||
mock_search.return_value = {
|
|
||||||
"results": [{"id": 1429, "name": series_name, "first_air_date": "2013-04-07"}]
|
|
||||||
}
|
|
||||||
mock_details.return_value = mock_tmdb_complete
|
|
||||||
mock_ratings.return_value = mock_content_ratings
|
|
||||||
|
|
||||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
|
||||||
series_name,
|
|
||||||
series_name,
|
|
||||||
year=2013,
|
|
||||||
download_poster=False,
|
|
||||||
download_logo=False,
|
|
||||||
download_fanart=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify all key metadata is in NFO
|
|
||||||
nfo_content = nfo_path.read_text(encoding="utf-8")
|
|
||||||
assert "<title>Attack on Titan</title>" in nfo_content
|
|
||||||
assert "<originaltitle>進撃の巨人</originaltitle>" in nfo_content
|
|
||||||
assert "<year>2013</year>" in nfo_content
|
|
||||||
assert "<plot>Humans fight against giant humanoid Titans.</plot>" in nfo_content
|
|
||||||
assert "<status>Ended</status>" in nfo_content
|
|
||||||
assert "<genre>Animation</genre>" in nfo_content
|
|
||||||
assert "<studio>MBS</studio>" in nfo_content
|
|
||||||
assert "<country>Japan</country>" in nfo_content
|
|
||||||
assert "<mpaa>FSK 16</mpaa>" in nfo_content
|
|
||||||
assert "<tmdbid>1429</tmdbid>" in nfo_content
|
|
||||||
assert "<imdbid>tt2560140</imdbid>" in nfo_content
|
|
||||||
assert "<tvdbid>267440</tvdbid>" in nfo_content
|
|
||||||
assert "<name>Yuki Kaji</name>" in nfo_content
|
|
||||||
assert "<role>Eren</role>" in nfo_content
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
"""Live integration tests for NFO creation and update using real TMDB data.
|
|
||||||
|
|
||||||
These tests call the real TMDB API and verify the complete NFO pipeline for
|
|
||||||
86: Eighty Six (TMDB 100565 / IMDB tt13718450 / TVDB 378609).
|
|
||||||
|
|
||||||
Run with:
|
|
||||||
conda run -n AniWorld python -m pytest tests/integration/test_nfo_live_tmdb.py -v --tb=short
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from lxml import etree
|
|
||||||
|
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Show identity constants
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
TMDB_ID = 100565
|
|
||||||
IMDB_ID = "tt13718450"
|
|
||||||
TVDB_ID = 378609
|
|
||||||
SHOW_NAME = "86: Eighty Six"
|
|
||||||
|
|
||||||
# The API key is stored in data/config.json; import it via the settings system.
|
|
||||||
from src.config.settings import settings # noqa: E402
|
|
||||||
|
|
||||||
TMDB_API_KEY: str = settings.tmdb_api_key or "299ae8f630a31bda814263c551361448"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Required XML tags that must exist and be non-empty after creation/repair
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
REQUIRED_SINGLE_TAGS = [
|
|
||||||
"title",
|
|
||||||
"originaltitle",
|
|
||||||
"sorttitle",
|
|
||||||
"year",
|
|
||||||
"plot",
|
|
||||||
"outline",
|
|
||||||
"runtime",
|
|
||||||
"premiered",
|
|
||||||
"status",
|
|
||||||
"tmdbid",
|
|
||||||
"imdbid",
|
|
||||||
"tvdbid",
|
|
||||||
"dateadded",
|
|
||||||
"watched",
|
|
||||||
# mpaa may be "TV-MA" (US) or an FSK value depending on config
|
|
||||||
"mpaa",
|
|
||||||
]
|
|
||||||
|
|
||||||
REQUIRED_MULTI_TAGS = [
|
|
||||||
"genre",
|
|
||||||
"studio",
|
|
||||||
"country",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _parse_nfo(nfo_path: Path) -> etree._Element:
|
|
||||||
"""Parse NFO file and return root element."""
|
|
||||||
tree = etree.parse(str(nfo_path))
|
|
||||||
return tree.getroot()
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_required_tags(root: etree._Element, nfo_path: Path) -> None:
|
|
||||||
"""Assert every required tag is present and non-empty."""
|
|
||||||
missing = []
|
|
||||||
for tag in REQUIRED_SINGLE_TAGS:
|
|
||||||
elem = root.find(f".//{tag}")
|
|
||||||
if elem is None or not (elem.text or "").strip():
|
|
||||||
missing.append(tag)
|
|
||||||
|
|
||||||
for tag in REQUIRED_MULTI_TAGS:
|
|
||||||
elems = root.findall(f".//{tag}")
|
|
||||||
if not elems or not any((e.text or "").strip() for e in elems):
|
|
||||||
missing.append(tag)
|
|
||||||
|
|
||||||
# At least one actor must be present
|
|
||||||
actors = root.findall(".//actor/name")
|
|
||||||
if not actors or not any((a.text or "").strip() for a in actors):
|
|
||||||
missing.append("actor/name")
|
|
||||||
|
|
||||||
assert not missing, (
|
|
||||||
f"Missing or empty required tags in {nfo_path}:\n "
|
|
||||||
+ "\n ".join(missing)
|
|
||||||
+ f"\n\nFull NFO:\n{etree.tostring(root, pretty_print=True).decode()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_correct_ids(root: etree._Element) -> None:
|
|
||||||
"""Assert that all three IDs have the expected values."""
|
|
||||||
tmdbid = root.findtext(".//tmdbid")
|
|
||||||
imdbid = root.findtext(".//imdbid")
|
|
||||||
tvdbid = root.findtext(".//tvdbid")
|
|
||||||
|
|
||||||
assert tmdbid == str(TMDB_ID), f"tmdbid: expected {TMDB_ID}, got {tmdbid!r}"
|
|
||||||
assert imdbid == IMDB_ID, f"imdbid: expected {IMDB_ID!r}, got {imdbid!r}"
|
|
||||||
assert tvdbid == str(TVDB_ID), f"tvdbid: expected {TVDB_ID}, got {tvdbid!r}"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fixtures
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def anime_dir(tmp_path: Path) -> Path:
|
|
||||||
"""Temporary anime root directory."""
|
|
||||||
d = tmp_path / "anime"
|
|
||||||
d.mkdir()
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def nfo_service(anime_dir: Path) -> NFOService:
|
|
||||||
"""NFOService pointing at the temp directory with the real API key."""
|
|
||||||
return NFOService(
|
|
||||||
tmdb_api_key=TMDB_API_KEY,
|
|
||||||
anime_directory=str(anime_dir),
|
|
||||||
image_size="w500",
|
|
||||||
auto_create=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Test 1 – Create NFO and verify all required fields
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_nfo_has_all_required_fields(
|
|
||||||
nfo_service: NFOService,
|
|
||||||
anime_dir: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Create a real tvshow.nfo via TMDB and assert every required tag is present.
|
|
||||||
|
|
||||||
Uses 86: Eighty Six (TMDB 100565) as the reference show.
|
|
||||||
All checks are performed against the TMDB API using the configured key.
|
|
||||||
"""
|
|
||||||
series_folder = SHOW_NAME
|
|
||||||
series_dir = anime_dir / series_folder
|
|
||||||
series_dir.mkdir()
|
|
||||||
|
|
||||||
# Patch image downloads to avoid network hits for images
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
nfo_service.image_downloader,
|
|
||||||
"download_all_media",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"poster": False, "logo": False, "fanart": False},
|
|
||||||
):
|
|
||||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
|
||||||
serie_name=SHOW_NAME,
|
|
||||||
serie_folder=series_folder,
|
|
||||||
year=2021,
|
|
||||||
download_poster=False,
|
|
||||||
download_logo=False,
|
|
||||||
download_fanart=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert nfo_path.exists(), "NFO file was not created"
|
|
||||||
|
|
||||||
root = _parse_nfo(nfo_path)
|
|
||||||
|
|
||||||
# --- Structural checks ---
|
|
||||||
_assert_required_tags(root, nfo_path)
|
|
||||||
|
|
||||||
# --- Identity checks ---
|
|
||||||
_assert_correct_ids(root)
|
|
||||||
|
|
||||||
# --- Spot-check concrete values ---
|
|
||||||
assert root.findtext(".//year") == "2021"
|
|
||||||
assert root.findtext(".//premiered") == "2021-04-11"
|
|
||||||
assert root.findtext(".//runtime") == "24"
|
|
||||||
assert root.findtext(".//status") == "Ended"
|
|
||||||
assert root.findtext(".//watched") == "false"
|
|
||||||
|
|
||||||
# Plot must be non-trivial (at least 20 characters)
|
|
||||||
plot = root.findtext(".//plot") or ""
|
|
||||||
assert len(plot) >= 20, f"plot too short: {plot!r}"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Test 2 – Strip NFO to ID-only, update, verify all fields restored
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_stripped_nfo_restores_all_fields(
|
|
||||||
nfo_service: NFOService,
|
|
||||||
anime_dir: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Write a minimal NFO with only the TMDB ID, run update_tvshow_nfo, and
|
|
||||||
verify that all required tags are present with correct values afterwards.
|
|
||||||
|
|
||||||
This proves the repair pipeline works end-to-end with a real TMDB lookup.
|
|
||||||
"""
|
|
||||||
series_folder = SHOW_NAME
|
|
||||||
series_dir = anime_dir / series_folder
|
|
||||||
series_dir.mkdir()
|
|
||||||
|
|
||||||
# Write the stripped NFO – only the tmdbid element, nothing else
|
|
||||||
stripped_xml = (
|
|
||||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
|
|
||||||
"<tvshow>\n"
|
|
||||||
f" <tmdbid>{TMDB_ID}</tmdbid>\n"
|
|
||||||
"</tvshow>\n"
|
|
||||||
)
|
|
||||||
nfo_path = series_dir / "tvshow.nfo"
|
|
||||||
nfo_path.write_text(stripped_xml, encoding="utf-8")
|
|
||||||
|
|
||||||
# Confirm the file is truly incomplete before the update
|
|
||||||
root_before = _parse_nfo(nfo_path)
|
|
||||||
assert root_before.findtext(".//title") is None, "Precondition failed: title exists in stripped NFO"
|
|
||||||
assert root_before.findtext(".//plot") is None, "Precondition failed: plot exists in stripped NFO"
|
|
||||||
|
|
||||||
# Patch image downloads to avoid image network requests
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
nfo_service.image_downloader,
|
|
||||||
"download_all_media",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"poster": False, "logo": False, "fanart": False},
|
|
||||||
):
|
|
||||||
updated_path = await nfo_service.update_tvshow_nfo(
|
|
||||||
serie_folder=series_folder,
|
|
||||||
download_media=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert updated_path.exists(), "Updated NFO file not found"
|
|
||||||
|
|
||||||
root_after = _parse_nfo(updated_path)
|
|
||||||
|
|
||||||
# --- All required tags must now be present and non-empty ---
|
|
||||||
_assert_required_tags(root_after, updated_path)
|
|
||||||
|
|
||||||
# --- IDs must match ---
|
|
||||||
_assert_correct_ids(root_after)
|
|
||||||
|
|
||||||
# --- Concrete value checks ---
|
|
||||||
assert root_after.findtext(".//year") == "2021"
|
|
||||||
assert root_after.findtext(".//premiered") == "2021-04-11"
|
|
||||||
assert root_after.findtext(".//runtime") == "24"
|
|
||||||
assert root_after.findtext(".//status") == "Ended"
|
|
||||||
assert root_after.findtext(".//watched") == "false"
|
|
||||||
|
|
||||||
# Plot must be non-trivial
|
|
||||||
plot = root_after.findtext(".//plot") or ""
|
|
||||||
assert len(plot) >= 20, f"plot too short after update: {plot!r}"
|
|
||||||
|
|
||||||
# Original title must be the Japanese title
|
|
||||||
originaltitle = root_after.findtext(".//originaltitle") or ""
|
|
||||||
assert originaltitle, "originaltitle is empty after update"
|
|
||||||
# Should be the Japanese title (different from the English title)
|
|
||||||
title = root_after.findtext(".//title") or ""
|
|
||||||
assert originaltitle != "" and title != "", "title and originaltitle must both be set"
|
|
||||||
|
|
||||||
# At least one genre
|
|
||||||
genres = [e.text for e in root_after.findall(".//genre") if e.text]
|
|
||||||
assert genres, "No genres found after update"
|
|
||||||
|
|
||||||
# At least one studio
|
|
||||||
studios = [e.text for e in root_after.findall(".//studio") if e.text]
|
|
||||||
assert studios, "No studios found after update"
|
|
||||||
|
|
||||||
# At least one actor with a name
|
|
||||||
actor_names = [e.text for e in root_after.findall(".//actor/name") if e.text]
|
|
||||||
assert actor_names, "No actors found after update"
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"""Integration tests verifying perform_nfo_repair_scan is wired into folder scan
|
|
||||||
and NOT called during FastAPI lifespan startup.
|
|
||||||
|
|
||||||
These tests confirm that:
|
|
||||||
1. FolderScanService.run_folder_scan calls perform_nfo_repair_scan.
|
|
||||||
2. perform_nfo_repair_scan is NOT imported or called in fastapi_app.py lifespan.
|
|
||||||
3. Series with incomplete NFO files are queued via asyncio.create_task.
|
|
||||||
"""
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
class TestNfoRepairScanNotCalledOnStartup:
|
|
||||||
"""Verify perform_nfo_repair_scan is NOT invoked during FastAPI lifespan startup."""
|
|
||||||
|
|
||||||
def test_perform_nfo_repair_scan_not_imported_in_lifespan(self):
|
|
||||||
"""fastapi_app.py lifespan must not import or call perform_nfo_repair_scan."""
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
source = importlib.util.find_spec("src.server.fastapi_app").origin
|
|
||||||
with open(source, "r", encoding="utf-8") as fh:
|
|
||||||
content = fh.read()
|
|
||||||
|
|
||||||
assert "perform_nfo_repair_scan" not in content, (
|
|
||||||
"perform_nfo_repair_scan must NOT be imported or called in fastapi_app.py"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestNfoRepairScanCalledInFolderScan:
|
|
||||||
"""Verify perform_nfo_repair_scan is invoked from FolderScanService."""
|
|
||||||
|
|
||||||
def test_perform_nfo_repair_scan_imported_in_folder_scan_service(self):
|
|
||||||
"""folder_scan_service.py imports perform_nfo_repair_scan."""
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
source = importlib.util.find_spec("src.server.services.folder_scan_service").origin
|
|
||||||
with open(source, "r", encoding="utf-8") as fh:
|
|
||||||
content = fh.read()
|
|
||||||
|
|
||||||
assert "perform_nfo_repair_scan" in content, (
|
|
||||||
"perform_nfo_repair_scan must be imported in folder_scan_service.py"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_perform_nfo_repair_scan_called_in_run_folder_scan(self):
|
|
||||||
"""perform_nfo_repair_scan must be called inside run_folder_scan."""
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
source = importlib.util.find_spec("src.server.services.folder_scan_service").origin
|
|
||||||
with open(source, "r", encoding="utf-8") as fh:
|
|
||||||
content = fh.read()
|
|
||||||
|
|
||||||
run_folder_scan_pos = content.find("def run_folder_scan")
|
|
||||||
# Find the call inside the method body (after the import line)
|
|
||||||
repair_scan_call_pos = content.find("await perform_nfo_repair_scan(background_loader=None)")
|
|
||||||
|
|
||||||
assert run_folder_scan_pos != -1, "run_folder_scan method not found"
|
|
||||||
assert repair_scan_call_pos != -1, "perform_nfo_repair_scan call not found"
|
|
||||||
assert repair_scan_call_pos > run_folder_scan_pos, (
|
|
||||||
"perform_nfo_repair_scan must be called INSIDE run_folder_scan"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestNfoRepairScanIntegrationWithBackgroundLoader:
|
|
||||||
"""Integration test: incomplete NFO series are queued via background_loader."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_incomplete_nfo_series_scheduled_for_repair(self, tmp_path):
|
|
||||||
"""Series whose tvshow.nfo is missing required tags are scheduled via asyncio.create_task."""
|
|
||||||
from src.server.services.folder_scan_service import perform_nfo_repair_scan
|
|
||||||
|
|
||||||
series_dir = tmp_path / "IncompleteAnime"
|
|
||||||
series_dir.mkdir()
|
|
||||||
(series_dir / "tvshow.nfo").write_text(
|
|
||||||
"<tvshow><title>IncompleteAnime</title></tvshow>"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_settings = MagicMock()
|
|
||||||
mock_settings.tmdb_api_key = "test-key"
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
|
|
||||||
mock_repair_service = AsyncMock()
|
|
||||||
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.services.folder_scan_service._settings", mock_settings
|
|
||||||
), patch(
|
|
||||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
|
||||||
return_value=True,
|
|
||||||
), patch(
|
|
||||||
"src.core.services.nfo_factory.NFOServiceFactory"
|
|
||||||
) as mock_factory, patch(
|
|
||||||
"src.core.services.nfo_repair_service.NfoRepairService",
|
|
||||||
return_value=mock_repair_service,
|
|
||||||
), patch(
|
|
||||||
"asyncio.create_task"
|
|
||||||
) as mock_create_task:
|
|
||||||
mock_factory.return_value.create.return_value = MagicMock()
|
|
||||||
await perform_nfo_repair_scan(background_loader=AsyncMock())
|
|
||||||
|
|
||||||
mock_create_task.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_complete_nfo_series_not_scheduled(self, tmp_path):
|
|
||||||
"""Series whose tvshow.nfo has all required tags are not scheduled for repair."""
|
|
||||||
from src.server.services.folder_scan_service import perform_nfo_repair_scan
|
|
||||||
|
|
||||||
series_dir = tmp_path / "CompleteAnime"
|
|
||||||
series_dir.mkdir()
|
|
||||||
(series_dir / "tvshow.nfo").write_text(
|
|
||||||
"<tvshow><title>CompleteAnime</title></tvshow>"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_settings = MagicMock()
|
|
||||||
mock_settings.tmdb_api_key = "test-key"
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.services.folder_scan_service._settings", mock_settings
|
|
||||||
), patch(
|
|
||||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
|
||||||
return_value=False,
|
|
||||||
), patch(
|
|
||||||
"src.core.services.nfo_factory.NFOServiceFactory"
|
|
||||||
) as mock_factory, patch(
|
|
||||||
"asyncio.create_task"
|
|
||||||
) as mock_create_task:
|
|
||||||
mock_factory.return_value.create.return_value = MagicMock()
|
|
||||||
await perform_nfo_repair_scan(background_loader=AsyncMock())
|
|
||||||
|
|
||||||
mock_create_task.assert_not_called()
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
"""
|
|
||||||
Integration test for complete NFO workflow.
|
|
||||||
|
|
||||||
Tests the end-to-end NFO creation process including:
|
|
||||||
- TMDB metadata retrieval
|
|
||||||
- NFO file generation
|
|
||||||
- Image downloads
|
|
||||||
- Database updates
|
|
||||||
"""
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
class TestCompleteNFOWorkflow:
|
|
||||||
"""Test complete NFO creation workflow from start to finish."""
|
|
||||||
|
|
||||||
async def test_complete_nfo_workflow_with_all_features(self):
|
|
||||||
"""
|
|
||||||
Test complete NFO workflow:
|
|
||||||
1. Create NFO service with valid config
|
|
||||||
2. Fetch metadata from TMDB
|
|
||||||
3. Generate NFO files
|
|
||||||
4. Download images
|
|
||||||
5. Update database
|
|
||||||
"""
|
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
# Initialize database
|
|
||||||
|
|
||||||
# Create anime directory structure
|
|
||||||
anime_dir = Path(tmp_dir) / "Attack on Titan"
|
|
||||||
season1_dir = anime_dir / "Season 1"
|
|
||||||
season1_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
# Create dummy episode files
|
|
||||||
(season1_dir / "S01E01.mkv").touch()
|
|
||||||
(season1_dir / "S01E02.mkv").touch()
|
|
||||||
|
|
||||||
# Mock TMDB responses
|
|
||||||
mock_tmdb_show = {
|
|
||||||
"id": 1429,
|
|
||||||
"name": "Attack on Titan",
|
|
||||||
"original_name": "進撃の巨人",
|
|
||||||
"overview": "Humans are nearly exterminated...",
|
|
||||||
"first_air_date": "2013-04-07",
|
|
||||||
"vote_average": 8.5,
|
|
||||||
"vote_count": 5000,
|
|
||||||
"genres": [
|
|
||||||
{"id": 16, "name": "Animation"},
|
|
||||||
{"id": 10759, "name": "Action & Adventure"},
|
|
||||||
],
|
|
||||||
"origin_country": ["JP"],
|
|
||||||
"original_language": "ja",
|
|
||||||
"popularity": 250.0,
|
|
||||||
"status": "Ended",
|
|
||||||
"type": "Scripted",
|
|
||||||
"poster_path": "/poster.jpg",
|
|
||||||
"backdrop_path": "/fanart.jpg",
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_tmdb_season = {
|
|
||||||
"id": 59321,
|
|
||||||
"season_number": 1,
|
|
||||||
"episode_count": 25,
|
|
||||||
"episodes": [
|
|
||||||
{
|
|
||||||
"id": 63056,
|
|
||||||
"episode_number": 1,
|
|
||||||
"name": "To You, in 2000 Years",
|
|
||||||
"overview": "After a hundred years...",
|
|
||||||
"air_date": "2013-04-07",
|
|
||||||
"vote_average": 8.2,
|
|
||||||
"vote_count": 100,
|
|
||||||
"still_path": "/episode1.jpg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 63057,
|
|
||||||
"episode_number": 2,
|
|
||||||
"name": "That Day",
|
|
||||||
"overview": "Eren begins training...",
|
|
||||||
"air_date": "2013-04-14",
|
|
||||||
"vote_average": 8.1,
|
|
||||||
"vote_count": 95,
|
|
||||||
"still_path": "/episode2.jpg",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Mock TMDB client
|
|
||||||
mock_tmdb = Mock()
|
|
||||||
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
|
|
||||||
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
mock_tmdb._ensure_session = AsyncMock()
|
|
||||||
mock_tmdb.close = AsyncMock()
|
|
||||||
mock_tmdb.search_tv_show = AsyncMock(return_value={"results": [mock_tmdb_show]})
|
|
||||||
mock_tmdb.get_tv_show = AsyncMock(return_value=mock_tmdb_show)
|
|
||||||
mock_tmdb.get_tv_show_details = AsyncMock(return_value=mock_tmdb_show)
|
|
||||||
mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []})
|
|
||||||
mock_tmdb.get_image_url = Mock(return_value="https://image.tmdb.org/t/p/original/test.jpg")
|
|
||||||
|
|
||||||
# Create NFO service with mocked TMDB
|
|
||||||
with patch(
|
|
||||||
"src.core.services.nfo_service.TMDBClient",
|
|
||||||
return_value=mock_tmdb,
|
|
||||||
):
|
|
||||||
nfo_service = NFOService(
|
|
||||||
tmdb_api_key="test_key",
|
|
||||||
anime_directory=tmp_dir,
|
|
||||||
image_size="w500",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 1: Create tvshow.nfo
|
|
||||||
_ = await nfo_service.create_tvshow_nfo(
|
|
||||||
serie_name="Attack on Titan",
|
|
||||||
serie_folder="Attack on Titan",
|
|
||||||
year=2013,
|
|
||||||
download_poster=True,
|
|
||||||
download_fanart=True,
|
|
||||||
download_logo=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 2: Verify NFO file created
|
|
||||||
tvshow_nfo = anime_dir / "tvshow.nfo"
|
|
||||||
assert tvshow_nfo.exists()
|
|
||||||
assert tvshow_nfo.stat().st_size > 0
|
|
||||||
|
|
||||||
# Step 3: Verify NFO content
|
|
||||||
with open(tvshow_nfo, "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
assert "Attack on Titan" in content
|
|
||||||
assert "進撃の巨人" in content
|
|
||||||
assert "<tvshow>" in content
|
|
||||||
assert "</tvshow>" in content
|
|
||||||
assert "1429" in content # TMDB ID
|
|
||||||
assert "Animation" in content
|
|
||||||
|
|
||||||
# Step 4: Verify check_nfo_exists works
|
|
||||||
assert await nfo_service.check_nfo_exists("Attack on Titan")
|
|
||||||
|
|
||||||
async def test_nfo_workflow_handles_missing_episodes(self):
|
|
||||||
"""Test NFO creation with basic workflow."""
|
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
# Create anime directory with episodes
|
|
||||||
anime_dir = Path(tmp_dir) / "Test Anime"
|
|
||||||
season1_dir = anime_dir / "Season 1"
|
|
||||||
season1_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
# Create episode files
|
|
||||||
(season1_dir / "S01E01.mkv").touch()
|
|
||||||
(season1_dir / "S01E03.mkv").touch()
|
|
||||||
|
|
||||||
mock_tmdb = Mock()
|
|
||||||
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
|
|
||||||
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
mock_tmdb._ensure_session = AsyncMock()
|
|
||||||
mock_tmdb.close = AsyncMock()
|
|
||||||
mock_tmdb.search_tv_show = AsyncMock(
|
|
||||||
return_value={"results": [{
|
|
||||||
"id": 999,
|
|
||||||
"name": "Test Anime",
|
|
||||||
"first_air_date": "2020-01-01",
|
|
||||||
}]}
|
|
||||||
)
|
|
||||||
mock_tmdb.get_tv_show_details = AsyncMock(
|
|
||||||
return_value={
|
|
||||||
"id": 999,
|
|
||||||
"name": "Test Anime",
|
|
||||||
"first_air_date": "2020-01-01",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []})
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.core.services.nfo_service.TMDBClient",
|
|
||||||
return_value=mock_tmdb,
|
|
||||||
):
|
|
||||||
nfo_service = NFOService(
|
|
||||||
tmdb_api_key="test_key",
|
|
||||||
anime_directory=tmp_dir
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create tvshow.nfo
|
|
||||||
await nfo_service.create_tvshow_nfo(
|
|
||||||
serie_name="Test Anime",
|
|
||||||
serie_folder="Test Anime",
|
|
||||||
download_poster=False,
|
|
||||||
download_logo=False,
|
|
||||||
download_fanart=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should create tvshow.nfo
|
|
||||||
assert (anime_dir / "tvshow.nfo").exists()
|
|
||||||
|
|
||||||
async def test_nfo_workflow_error_recovery(self):
|
|
||||||
"""Test NFO workflow handles TMDB errors gracefully."""
|
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
from src.core.services.tmdb_client import TMDBAPIError
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
anime_dir = Path(tmp_dir) / "Test Anime"
|
|
||||||
anime_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
# Mock TMDB to fail
|
|
||||||
mock_tmdb = Mock()
|
|
||||||
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
|
|
||||||
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
mock_tmdb._ensure_session = AsyncMock()
|
|
||||||
mock_tmdb.close = AsyncMock()
|
|
||||||
mock_tmdb.search_tv_show = AsyncMock(
|
|
||||||
side_effect=TMDBAPIError("API error")
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.core.services.nfo_service.TMDBClient",
|
|
||||||
return_value=mock_tmdb,
|
|
||||||
):
|
|
||||||
nfo_service = NFOService(
|
|
||||||
tmdb_api_key="test_key",
|
|
||||||
anime_directory=tmp_dir
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should raise TMDBAPIError
|
|
||||||
with pytest.raises(TMDBAPIError):
|
|
||||||
await nfo_service.create_tvshow_nfo(
|
|
||||||
serie_name="Test Anime",
|
|
||||||
serie_folder="Test Anime",
|
|
||||||
download_poster=False,
|
|
||||||
download_logo=False,
|
|
||||||
download_fanart=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def test_nfo_update_workflow(self):
|
|
||||||
"""Test updating existing NFO files with new metadata."""
|
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
anime_dir = Path(tmp_dir) / "Test Anime"
|
|
||||||
anime_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
# Create initial NFO file
|
|
||||||
tvshow_nfo = anime_dir / "tvshow.nfo"
|
|
||||||
tvshow_nfo.write_text(
|
|
||||||
"""<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<tvshow>
|
|
||||||
<title>Test Anime</title>
|
|
||||||
<year>2020</year>
|
|
||||||
<uniqueid type="tmdb" default="true">999</uniqueid>
|
|
||||||
</tvshow>"""
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_tmdb = Mock()
|
|
||||||
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
|
|
||||||
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
mock_tmdb._ensure_session = AsyncMock()
|
|
||||||
mock_tmdb.close = AsyncMock()
|
|
||||||
mock_tmdb.search_tv_show = AsyncMock(
|
|
||||||
return_value={"results": [{
|
|
||||||
"id": 999,
|
|
||||||
"name": "Test Anime Updated",
|
|
||||||
"overview": "New description",
|
|
||||||
"first_air_date": "2020-01-01",
|
|
||||||
"vote_average": 9.0,
|
|
||||||
}]}
|
|
||||||
)
|
|
||||||
mock_tmdb.get_tv_show_details = AsyncMock(
|
|
||||||
return_value={
|
|
||||||
"id": 999,
|
|
||||||
"name": "Test Anime Updated",
|
|
||||||
"overview": "New description",
|
|
||||||
"first_air_date": "2020-01-01",
|
|
||||||
"vote_average": 9.0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []})
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.core.services.nfo_service.TMDBClient",
|
|
||||||
return_value=mock_tmdb,
|
|
||||||
):
|
|
||||||
nfo_service = NFOService(
|
|
||||||
tmdb_api_key="test_key",
|
|
||||||
anime_directory=tmp_dir
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update NFO
|
|
||||||
await nfo_service.update_tvshow_nfo(
|
|
||||||
serie_folder="Test Anime"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify NFO updated
|
|
||||||
content = tvshow_nfo.read_text()
|
|
||||||
assert "Test Anime Updated" in content
|
|
||||||
assert "New description" in content
|
|
||||||
|
|
||||||
async def test_nfo_batch_creation_workflow(self):
|
|
||||||
"""Test creating NFOs for multiple anime in batch."""
|
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
# Create multiple anime directories
|
|
||||||
anime1_dir = Path(tmp_dir) / "Anime 1"
|
|
||||||
anime1_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
anime2_dir = Path(tmp_dir) / "Anime 2"
|
|
||||||
anime2_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
mock_tmdb = Mock()
|
|
||||||
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
|
|
||||||
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
mock_tmdb._ensure_session = AsyncMock()
|
|
||||||
mock_tmdb.close = AsyncMock()
|
|
||||||
mock_tmdb.search_tv_show = AsyncMock(
|
|
||||||
side_effect=[
|
|
||||||
{"results": [{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"}]},
|
|
||||||
{"results": [{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"}]},
|
|
||||||
{"results": [{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"}]},
|
|
||||||
{"results": [{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"}]},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
mock_tmdb.get_tv_show_details = AsyncMock(
|
|
||||||
side_effect=[
|
|
||||||
{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"},
|
|
||||||
{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"},
|
|
||||||
{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"},
|
|
||||||
{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []})
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.core.services.nfo_service.TMDBClient",
|
|
||||||
return_value=mock_tmdb,
|
|
||||||
):
|
|
||||||
nfo_service = NFOService(
|
|
||||||
tmdb_api_key="test_key",
|
|
||||||
anime_directory=tmp_dir
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create NFOs for both
|
|
||||||
await nfo_service.create_tvshow_nfo(
|
|
||||||
serie_name="Anime 1",
|
|
||||||
serie_folder="Anime 1",
|
|
||||||
download_poster=False,
|
|
||||||
download_logo=False,
|
|
||||||
download_fanart=False,
|
|
||||||
)
|
|
||||||
await nfo_service.create_tvshow_nfo(
|
|
||||||
serie_name="Anime 2",
|
|
||||||
serie_folder="Anime 2",
|
|
||||||
download_poster=False,
|
|
||||||
download_logo=False,
|
|
||||||
download_fanart=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert (anime1_dir / "tvshow.nfo").exists()
|
|
||||||
assert (anime2_dir / "tvshow.nfo").exists()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
class TestNFOWorkflowWithDownloads:
|
|
||||||
"""Test NFO creation integrated with download workflow."""
|
|
||||||
|
|
||||||
async def test_nfo_created_during_download(self):
|
|
||||||
"""Test NFO creation works with the actual service."""
|
|
||||||
from src.core.services.nfo_service import NFOService
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
anime_dir = Path(tmp_dir) / "Test Anime"
|
|
||||||
anime_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
# Create NFO service
|
|
||||||
mock_tmdb = Mock()
|
|
||||||
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
|
|
||||||
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
mock_tmdb._ensure_session = AsyncMock()
|
|
||||||
mock_tmdb.close = AsyncMock()
|
|
||||||
mock_tmdb.search_tv_show = AsyncMock(
|
|
||||||
return_value={"results": [{
|
|
||||||
"id": 999,
|
|
||||||
"name": "Test Anime",
|
|
||||||
"first_air_date": "2020-01-01",
|
|
||||||
}]}
|
|
||||||
)
|
|
||||||
mock_tmdb.get_tv_show_details = AsyncMock(
|
|
||||||
return_value={
|
|
||||||
"id": 999,
|
|
||||||
"name": "Test Anime",
|
|
||||||
"first_air_date": "2020-01-01",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []})
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.core.services.nfo_service.TMDBClient",
|
|
||||||
return_value=mock_tmdb,
|
|
||||||
):
|
|
||||||
nfo_service = NFOService(
|
|
||||||
tmdb_api_key="test_key",
|
|
||||||
anime_directory=tmp_dir
|
|
||||||
)
|
|
||||||
|
|
||||||
# Simulate download completion - create episode file
|
|
||||||
season_dir = anime_dir / "Season 1"
|
|
||||||
season_dir.mkdir()
|
|
||||||
(season_dir / "S01E01.mkv").touch()
|
|
||||||
|
|
||||||
# Create tvshow.nfo
|
|
||||||
await nfo_service.create_tvshow_nfo(
|
|
||||||
serie_name="Test Anime",
|
|
||||||
serie_folder="Test Anime",
|
|
||||||
download_poster=False,
|
|
||||||
download_logo=False,
|
|
||||||
download_fanart=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify NFO created
|
|
||||||
tvshow_nfo = anime_dir / "tvshow.nfo"
|
|
||||||
assert tvshow_nfo.exists()
|
|
||||||
content = tvshow_nfo.read_text()
|
|
||||||
assert "Test Anime" in content
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
"""Integration tests for poster check service wiring.
|
|
||||||
|
|
||||||
These tests verify that:
|
|
||||||
1. FolderScanService.run_folder_scan calls check_and_download_missing_posters.
|
|
||||||
2. The poster check logic is properly integrated into the scheduled folder scan.
|
|
||||||
3. Missing posters are downloaded, valid posters are skipped, and errors are handled.
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
class TestPosterCheckScanCalledInFolderScan:
|
|
||||||
"""Verify check_and_download_missing_posters is invoked from FolderScanService."""
|
|
||||||
|
|
||||||
def test_check_and_download_missing_posters_imported_in_folder_scan_service(self):
|
|
||||||
"""folder_scan_service.py imports check_and_download_missing_posters."""
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
source = importlib.util.find_spec(
|
|
||||||
"src.server.services.folder_scan_service"
|
|
||||||
).origin
|
|
||||||
with open(source, "r", encoding="utf-8") as fh:
|
|
||||||
content = fh.read()
|
|
||||||
|
|
||||||
assert "check_and_download_missing_posters" in content, (
|
|
||||||
"check_and_download_missing_posters must be imported in folder_scan_service.py"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_check_and_download_missing_posters_called_in_run_folder_scan(self):
|
|
||||||
"""check_and_download_missing_posters must be called inside run_folder_scan."""
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
source = importlib.util.find_spec(
|
|
||||||
"src.server.services.folder_scan_service"
|
|
||||||
).origin
|
|
||||||
with open(source, "r", encoding="utf-8") as fh:
|
|
||||||
content = fh.read()
|
|
||||||
|
|
||||||
run_folder_scan_pos = content.find("def run_folder_scan")
|
|
||||||
poster_call_pos = content.find("check_and_download_missing_posters()")
|
|
||||||
|
|
||||||
assert run_folder_scan_pos != -1, "run_folder_scan method not found"
|
|
||||||
assert poster_call_pos != -1, "check_and_download_missing_posters call not found"
|
|
||||||
assert poster_call_pos > run_folder_scan_pos, (
|
|
||||||
"check_and_download_missing_posters must be called INSIDE run_folder_scan"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPosterCheckIntegration:
|
|
||||||
"""Integration test: poster check is triggered during folder scan."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_poster_check_downloads_missing_poster(self, tmp_path):
|
|
||||||
"""When poster.jpg is missing, the scan downloads it from the NFO thumb URL."""
|
|
||||||
from src.server.services.folder_scan_service import FolderScanService
|
|
||||||
|
|
||||||
anime_dir = tmp_path / "anime"
|
|
||||||
anime_dir.mkdir()
|
|
||||||
series_dir = anime_dir / "Attack on Titan (2013)"
|
|
||||||
series_dir.mkdir()
|
|
||||||
(series_dir / "tvshow.nfo").write_text(
|
|
||||||
"<?xml version='1.0' encoding='UTF-8'?>\n"
|
|
||||||
"<tvshow>\n"
|
|
||||||
" <title>Attack on Titan</title>\n"
|
|
||||||
" <year>2013</year>\n"
|
|
||||||
' <thumb aspect="poster">https://example.com/poster.jpg</thumb>\n'
|
|
||||||
"</tvshow>\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_settings = MagicMock()
|
|
||||||
mock_settings.tmdb_api_key = "test-key"
|
|
||||||
mock_settings.anime_directory = str(anime_dir)
|
|
||||||
|
|
||||||
call_log = []
|
|
||||||
|
|
||||||
class MockDownloader:
|
|
||||||
"""Fake ImageDownloader that records calls."""
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *args):
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def download_poster(self, url, folder, skip_existing=True):
|
|
||||||
call_log.append({"url": url, "folder": folder, "skip_existing": skip_existing})
|
|
||||||
return True
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings", mock_settings
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_scan_service.ImageDownloader",
|
|
||||||
new=MockDownloader,
|
|
||||||
):
|
|
||||||
service = FolderScanService()
|
|
||||||
await service.run_folder_scan()
|
|
||||||
|
|
||||||
assert len(call_log) == 1, f"Expected 1 download call, got {len(call_log)}"
|
|
||||||
assert call_log[0]["url"] == "https://example.com/poster.jpg"
|
|
||||||
assert call_log[0]["folder"] == series_dir
|
|
||||||
assert call_log[0]["skip_existing"] is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_poster_check_skips_valid_poster(self, tmp_path):
|
|
||||||
"""When poster.jpg exists and is large enough, the scan skips it."""
|
|
||||||
from src.server.services.folder_scan_service import FolderScanService
|
|
||||||
|
|
||||||
anime_dir = tmp_path / "anime"
|
|
||||||
anime_dir.mkdir()
|
|
||||||
series_dir = anime_dir / "Attack on Titan (2013)"
|
|
||||||
series_dir.mkdir()
|
|
||||||
(series_dir / "tvshow.nfo").write_text(
|
|
||||||
"<tvshow>"
|
|
||||||
"<title>Attack on Titan</title>"
|
|
||||||
"<year>2013</year>"
|
|
||||||
"<thumb aspect='poster'>https://example.com/poster.jpg</thumb>"
|
|
||||||
"</tvshow>"
|
|
||||||
)
|
|
||||||
# Create a valid poster.jpg (larger than 1 KB)
|
|
||||||
poster_path = series_dir / "poster.jpg"
|
|
||||||
poster_path.write_bytes(b"x" * 2048)
|
|
||||||
|
|
||||||
mock_settings = MagicMock()
|
|
||||||
mock_settings.tmdb_api_key = "test-key"
|
|
||||||
mock_settings.anime_directory = str(anime_dir)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings", mock_settings
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_scan_service.ImageDownloader"
|
|
||||||
) as mock_downloader_cls:
|
|
||||||
service = FolderScanService()
|
|
||||||
await service.run_folder_scan()
|
|
||||||
|
|
||||||
mock_downloader_cls.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_poster_check_skips_when_no_thumb_url(self, tmp_path):
|
|
||||||
"""When NFO has no thumb URL, the scan skips the folder."""
|
|
||||||
from src.server.services.folder_scan_service import FolderScanService
|
|
||||||
|
|
||||||
anime_dir = tmp_path / "anime"
|
|
||||||
anime_dir.mkdir()
|
|
||||||
series_dir = anime_dir / "Attack on Titan (2013)"
|
|
||||||
series_dir.mkdir()
|
|
||||||
(series_dir / "tvshow.nfo").write_text(
|
|
||||||
"<tvshow>"
|
|
||||||
"<title>Attack on Titan</title>"
|
|
||||||
"<year>2013</year>"
|
|
||||||
"</tvshow>"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_settings = MagicMock()
|
|
||||||
mock_settings.tmdb_api_key = "test-key"
|
|
||||||
mock_settings.anime_directory = str(anime_dir)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings", mock_settings
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_scan_service.ImageDownloader"
|
|
||||||
) as mock_downloader_cls:
|
|
||||||
service = FolderScanService()
|
|
||||||
await service.run_folder_scan()
|
|
||||||
|
|
||||||
mock_downloader_cls.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_poster_check_skipped_when_prerequisites_not_met(self, tmp_path):
|
|
||||||
"""If anime directory is missing, poster check logic is skipped gracefully."""
|
|
||||||
from src.server.services.folder_scan_service import FolderScanService
|
|
||||||
|
|
||||||
mock_settings = MagicMock()
|
|
||||||
mock_settings.tmdb_api_key = "test-key"
|
|
||||||
mock_settings.anime_directory = str(tmp_path / "nonexistent")
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings", mock_settings
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders"
|
|
||||||
) as mock_rename, patch(
|
|
||||||
"src.server.services.folder_scan_service.ImageDownloader"
|
|
||||||
) as mock_downloader_cls:
|
|
||||||
service = FolderScanService()
|
|
||||||
await service.run_folder_scan()
|
|
||||||
|
|
||||||
mock_downloader_cls.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestPosterCheckSemaphore:
|
|
||||||
"""Verify the poster download semaphore limits concurrency."""
|
|
||||||
|
|
||||||
def test_poster_download_semaphore_defined(self):
|
|
||||||
"""_POSTER_DOWNLOAD_SEMAPHORE must be defined in folder_scan_service.py."""
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
source = importlib.util.find_spec(
|
|
||||||
"src.server.services.folder_scan_service"
|
|
||||||
).origin
|
|
||||||
with open(source, "r", encoding="utf-8") as fh:
|
|
||||||
content = fh.read()
|
|
||||||
|
|
||||||
assert "_POSTER_DOWNLOAD_SEMAPHORE" in content, (
|
|
||||||
"_POSTER_DOWNLOAD_SEMAPHORE must be defined in folder_scan_service.py"
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_poster_download_uses_semaphore(self, tmp_path):
|
|
||||||
"""Poster downloads are gated by the semaphore."""
|
|
||||||
from src.server.services.folder_scan_service import (
|
|
||||||
_POSTER_DOWNLOAD_SEMAPHORE,
|
|
||||||
FolderScanService,
|
|
||||||
)
|
|
||||||
|
|
||||||
anime_dir = tmp_path / "anime"
|
|
||||||
anime_dir.mkdir()
|
|
||||||
|
|
||||||
# Create multiple series folders
|
|
||||||
for i in range(5):
|
|
||||||
series_dir = anime_dir / f"Series {i}"
|
|
||||||
series_dir.mkdir()
|
|
||||||
(series_dir / "tvshow.nfo").write_text(
|
|
||||||
f"<tvshow>"
|
|
||||||
f"<title>Series {i}</title>"
|
|
||||||
f"<year>202{i}</year>"
|
|
||||||
f"<thumb aspect='poster'>https://example.com/poster{i}.jpg</thumb>"
|
|
||||||
f"</tvshow>"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_settings = MagicMock()
|
|
||||||
mock_settings.tmdb_api_key = "test-key"
|
|
||||||
mock_settings.anime_directory = str(anime_dir)
|
|
||||||
|
|
||||||
active_count = 0
|
|
||||||
max_active = 0
|
|
||||||
|
|
||||||
async def tracked_download(*args, **kwargs):
|
|
||||||
nonlocal active_count, max_active
|
|
||||||
active_count += 1
|
|
||||||
max_active = max(max_active, active_count)
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
active_count -= 1
|
|
||||||
return True
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.config.settings.settings", mock_settings
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
|
||||||
), patch(
|
|
||||||
"src.server.services.folder_scan_service.ImageDownloader"
|
|
||||||
) as mock_downloader_cls:
|
|
||||||
mock_downloader = AsyncMock()
|
|
||||||
mock_downloader.download_poster = AsyncMock(side_effect=tracked_download)
|
|
||||||
mock_downloader_cls.return_value.__aenter__ = AsyncMock(
|
|
||||||
return_value=mock_downloader
|
|
||||||
)
|
|
||||||
mock_downloader_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
service = FolderScanService()
|
|
||||||
await service.run_folder_scan()
|
|
||||||
|
|
||||||
assert max_active <= 3, (
|
|
||||||
f"Expected max concurrent downloads <= 3, got {max_active}"
|
|
||||||
)
|
|
||||||
@@ -5,12 +5,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.providers.failover import (
|
from src.server.providers.failover import (
|
||||||
ProviderFailover,
|
ProviderFailover,
|
||||||
configure_failover,
|
configure_failover,
|
||||||
get_failover,
|
get_failover,
|
||||||
)
|
)
|
||||||
from src.core.providers.health_monitor import ProviderHealthMonitor
|
from src.server.providers.health_monitor import ProviderHealthMonitor
|
||||||
|
|
||||||
|
|
||||||
class TestProviderFailoverScenarios:
|
class TestProviderFailoverScenarios:
|
||||||
@@ -132,7 +132,7 @@ class TestProviderFailoverScenarios:
|
|||||||
assert "provider1" not in monitor.get_available_providers()
|
assert "provider1" not in monitor.get_available_providers()
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.failover.get_health_monitor",
|
"src.server.providers.failover.get_health_monitor",
|
||||||
return_value=monitor,
|
return_value=monitor,
|
||||||
):
|
):
|
||||||
failover = ProviderFailover(
|
failover = ProviderFailover(
|
||||||
@@ -236,7 +236,7 @@ class TestFailoverStats:
|
|||||||
monitor.record_request("p2", False, 200, error_message="fail")
|
monitor.record_request("p2", False, 200, error_message="fail")
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.failover.get_health_monitor",
|
"src.server.providers.failover.get_health_monitor",
|
||||||
return_value=monitor,
|
return_value=monitor,
|
||||||
):
|
):
|
||||||
failover = ProviderFailover(
|
failover = ProviderFailover(
|
||||||
@@ -253,7 +253,7 @@ class TestConfigureFailover:
|
|||||||
|
|
||||||
def test_configure_failover(self):
|
def test_configure_failover(self):
|
||||||
"""configure_failover should create a new global instance."""
|
"""configure_failover should create a new global instance."""
|
||||||
import src.core.providers.failover as fo
|
import src.server.providers.failover as fo
|
||||||
fo._failover = None
|
fo._failover = None
|
||||||
|
|
||||||
failover = configure_failover(
|
failover = configure_failover(
|
||||||
@@ -271,7 +271,7 @@ class TestConfigureFailover:
|
|||||||
|
|
||||||
def test_get_failover_singleton(self):
|
def test_get_failover_singleton(self):
|
||||||
"""get_failover should return same instance."""
|
"""get_failover should return same instance."""
|
||||||
import src.core.providers.failover as fo
|
import src.server.providers.failover as fo
|
||||||
fo._failover = None
|
fo._failover = None
|
||||||
|
|
||||||
first = get_failover()
|
first = get_failover()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user