Compare commits

...

28 Commits

Author SHA1 Message Date
1649a22418 chore: bump version 2026-06-02 20:39:18 +02:00
246752e2fc Add dynamic version from Docker/VERSION file
- Create version.py utility to read version from Docker/VERSION
- Replace hardcoded version '1.0.1' with APP_VERSION from version.py
- Add version logging on FastAPI startup
- Use APP_VERSION in health endpoints and template context
2026-06-02 20:38:42 +02:00
84b24ed79e chore: bump version 2026-06-02 20:33:01 +02:00
bf3954587a fix(folder_rename_service): use get_by_folder instead of get_by_key when looking up by folder name
Update_database_paths and duplicate folder cleanup were using get_by_key()
(provider key lookup) instead of get_by_folder() when operating on folder names.
This caused orphaned DB records when removing duplicate folders like 'Hells Paradise'
that mapped to an already-existing 'Hell\'s Paradise (2023)'.
2026-06-02 20:09:47 +02:00
ed8f5cae10 chore: bump version 2026-06-01 21:38:37 +02:00
a54c285994 fix(folder_scan): await NFO repair before folder rename
folder_rename_service depends on clean NFO files but repair tasks
were fire-and-forget. Now collect all repair tasks and await them
with asyncio.gather before validate_and_rename_series_folders runs.

Also update tests that mock asyncio.create_task to also mock
asyncio.gather since perform_nfo_repair_scan now awaits tasks.
2026-06-01 21:37:28 +02:00
c58b42dfa5 feat(services): add key resolution for orphaned anime folders
- Add key_resolution_service.py to resolve provider keys for folders without key/data files
- Search anime provider and match folder names (case-insensitive, exact match required)
- Only save to DB if exactly one match found; otherwise skip
- Add comprehensive unit tests (28 tests)
- Integrate into scheduler_service after nfo_repair scan
- Update ARCHITECTURE.md documentation
2026-06-01 20:43:13 +02:00
6dfb24de7e backup 2026-06-01 20:07:58 +02:00
6021cdef28 feat: add anime metadata editing and NFO diagnostics
- Add PUT /anime/{key} endpoint for updating anime key, tmdb_id, tvdb_id
- Add NFO diagnostics and repair endpoints (GET/POST /nfo/diagnostics)
- Add edit modal UI with context menu integration
- Add frontend JS modules for context-menu and edit-modal
- Add comprehensive tests for edit, rename, and NFO repair flows
2026-05-31 18:31:56 +02:00
5517ccbab0 style: reformat folder_rename_service import 2026-05-30 12:20:40 +02:00
94ed013172 Revert "feat: add manual TMDB/TVDB ID entry for failed lookups"
This reverts commit 30858f441c.
2026-05-30 12:17:48 +02:00
76b849fc91 chore: bump version 2026-05-30 12:02:48 +02:00
00b26c8cbc fix: validate generated keys before creating Serie objects
- Add is_valid_key check in SerieScanner._read_data_from_file() to prevent
  passing invalid keys to Serie constructor (caused ValueError)
- Improve error message for key generation failures
- Add warning log before removing duplicate source folders in rename service
2026-05-30 11:42:19 +02:00
a6f2399aca chore: bump version 2026-05-29 19:25:30 +02:00
cf001563b3 refactor: add folder rename configuration and service
Add configurable folder rename patterns via settings with anime_folder_rename_regex and custom_pattern options. Integrate into SerieScanner and SeriesApp for consistent episode organization.
2026-05-29 19:24:09 +02:00
38c12638a4 fix HLS stream warning by disabling native downloader and retrying with ffmpeg
- Set hls_prefer_native: False to skip yt-dlp's native HLS downloader which emits
  'Live HLS streams are not supported' warning
- Add retry logic that catches HLS-related exceptions and retries with
  downloader=ffmpeg and hls_use_mpegts=True
2026-05-29 18:53:47 +02:00
765e43c684 fix(key_utils): drop apostrophes in generate_key_from_folder 2026-05-29 18:20:20 +02:00
5190d32665 chore: bump version 2026-05-28 22:03:52 +02:00
4e6afa31b5 Remove legacy key file support after DB migration
- SerieScanner: Remove key file fallback, keep data file fallback
- SystemSettings: Add legacy_key_cleanup_completed flag
- initialization_service: Add cleanup task to remove key files from folders with DB entries
- Tests updated to reflect key file removal from legacy path

Key files caused duplicate key errors on folder rename. DB is now sole source of truth.
2026-05-28 22:01:37 +02:00
1ef59c5283 feat: add duplicate folder detection and /duplicate-folders API endpoint
- Add DuplicateFolderGroup and DuplicateFoldersResponse Pydantic models
- Add /duplicate-folders GET endpoint for listing pre-existing duplicates
- Add _scan_for_pre_existing_duplicates() function for NFO-based detection
- Add _try_merge_duplicate_group() for auto-merging empty/symlink-only duplicates
- Integrate duplicate detection into validate_and_rename_series_folders workflow
- Skip rename for flagged duplicates to prevent data loss during merge
2026-05-28 21:46:08 +02:00
239341629c Add orphaned folder cleanup after rename
- Add _cleanup_orphaned_folder() to delete/move old folder contents after rename
- Empty folders: delete directly via rmdir()
- Non-empty folders: move contents to new path, then delete old folder
- Handle PermissionError and OSError gracefully with logging
- Add dry_run parameter to preview changes without applying them
- Add --dry-run support to validate_and_rename_series_folders()
- Add unit tests for _cleanup_orphaned_folder and dry-run mode
- All 66 related tests pass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 21:24:13 +02:00
51b7f349f8 fix(scheduler): strip null legacy alias fields from config.json on save
SchedulerConfig.__init__ maps legacy auto_download/folder_scan keys to the
primary auto_download_after_rescan/folder_scan_enabled fields. However,
model_dump() was including auto_download=null and folder_scan=null in
serialised output. When this was written to config.json and reloaded,
those keys were present (albeit null), so the alias mapping was skipped
and the primary fields retained default False values instead of the
configured True values.

Fix:
- Override SchedulerConfig.model_dump() to drop None-valued alias fields
  before returning the serialised dict.
- ConfigService.save_config() re-serialises the scheduler field through
  its overridden model_dump() so the fix applies when writing to disk.

Tests added:
- test_roundtrip_excludes_none_alias_fields: verifies model_dump omits
  null auto_download/folder_scan keys.
- test_save_and_load_scheduler_flags_roundtrip: end-to-end roundtrip
  through ConfigService confirms raw JSON and loaded values match.

Pre-existing failure in test_core_error_handler.py is unrelated.
2026-05-28 21:18:16 +02:00
14b8ef7f06 Add Step 4 fallback: generate key from folder name
- SerieScanner: generate key from folder when no key/data files exist
- Handle edge cases: non-Latin characters, special symbols in folder names
- anime_service: expose loading_status and loading_error fields
- Update tests to match new fallback behavior
2026-05-28 18:48:43 +02:00
7abba0dae2 Fix download provider errors with exponential backoff and playmogo support
- Add exponential backoff retry logic to RecoveryStrategies (1s, 2s, 4s...)
- Add TimeoutError to network failure handling for HTTPS timeouts
- Add playmogo.com referer header for Doodstream provider
- Add Optional import to error_handler.py
- Add sanitize_url_for_logging utility function

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 18:47:05 +02:00
30858f441c feat: add manual TMDB/TVDB ID entry for failed lookups
- Add PATCH /api/anime/{key}/metadata-ids endpoint to update IDs
- Add POST /api/anime/{key}/refresh-nfo endpoint to force NFO regeneration
- Add Edit Metadata IDs modal in frontend
- Add showEditMetadataModal, saveMetadataIds, refreshSeriesNfo JS functions
- Add edit-metadata-btn to series cards with database icon
- IDs validated as positive integers or null

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 18:38:34 +02:00
33f63ca304 feat(SerieScanner): add folder ignore patterns for non-anime content
- Add NFO_FOLDER_IGNORE_PATTERNS setting to skip TV shows like
  The Last of Us, Loki, Chernobyl, Star Trek Discovery
- Update SerieScanner.__find_mp4_files() to skip ignored folders
- Update SerieList.load_series() to skip ignored folders
- Add should_ignore_folder() method for pattern matching
- Add folder_ignore_patterns property for pattern parsing
- Add comprehensive tests for ignore pattern functionality
- Update NFO_GUIDE.md with ignore patterns documentation
- Update CONFIGURATION.md with new setting

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 18:11:45 +02:00
fe9284b80e feat(SerieScanner): add warning event for duplicate series keys
- Add on_warning event system with subscribe/unsubscribe methods
- Change duplicate key handling from error to warning
- Fire on_warning event when duplicate series detected
- Include metadata: key, duplicate_folder, existing_folder
2026-05-28 18:05:07 +02:00
12e5526991 chore: remove obsolete migration script
Migration logic moved to serie_scanner. No longer needed.
2026-05-28 17:55:00 +02:00
51 changed files with 4630 additions and 275 deletions

View File

@@ -1 +1 @@
v1.1.18
v1.3.3

View File

@@ -293,7 +293,7 @@ The FastAPI lifespan function (`src/server/fastapi_app.py`) runs the following s
9. Scheduler service started
+-- Cron-based library rescans configured
+-- Optional: auto-download missing episodes after rescan
+-- Optional: folder maintenance (NFO repair, renaming, poster checks) during scheduled runs
+-- Optional: folder maintenance (NFO repair, key resolution, renaming, poster checks) during scheduled runs
```
### 12.2 Temp Folder Guarantee

0
docs/helper Normal file
View File

View File

@@ -1,6 +1,6 @@
{
"name": "aniworld-web",
"version": "1.1.18",
"version": "1.3.3",
"description": "Aniworld Anime Download Manager - Web Frontend",
"type": "module",
"scripts": {

View File

@@ -1,135 +0,0 @@
#!/usr/bin/env python3
"""Migration script to populate year for existing series from folder names.
This script:
1. Finds all series in the database with year=NULL
2. Extracts year from their folder names using the same pattern as SerieScanner
3. Updates the database records
Usage:
python scripts/migrate_populate_year_from_folder.py [--dry-run]
"""
import argparse
import re
import sys
from pathlib import Path
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy import select, update
from src.server.database.models import AnimeSeries
from src.server.database.service import DatabaseSession
def extract_year_from_folder_name(folder_name: str) -> int | None:
"""Extract year from folder name if present.
Same logic as SerieScanner._extract_year_from_folder_name.
Args:
folder_name: The folder name to check
Returns:
int or None: Year if found, None otherwise
"""
if not folder_name:
return None
# Look for year in format (YYYY) - typically at end of name
match = re.search(r'\((\d{4})\)', folder_name)
if match:
try:
year = int(match.group(1))
# Validate year is reasonable (between 1900 and 2100)
if 1900 <= year <= 2100:
return year
except ValueError:
pass
return None
async def migrate_year_from_folder(dry_run: bool = True) -> tuple[int, int]:
"""Migrate year field for existing series.
Args:
dry_run: If True, only report what would be changed
Returns:
Tuple of (updated_count, skipped_count)
"""
updated_count = 0
skipped_count = 0
async with DatabaseSession() as db:
# Find all series with NULL year
result = await db.execute(
select(AnimeSeries).where(AnimeSeries.year.is_(None))
)
series_list = result.scalars().all()
print(f"Found {len(series_list)} series with year=NULL")
for series in series_list:
year_from_folder = extract_year_from_folder_name(series.folder)
if year_from_folder:
print(f" {series.folder} -> {year_from_folder}")
if not dry_run:
await db.execute(
update(AnimeSeries)
.where(AnimeSeries.id == series.id)
.values(year=year_from_folder)
)
updated_count += 1
else:
print(f" {series.folder} -> (no year found)")
skipped_count += 1
return updated_count, skipped_count
def main():
parser = argparse.ArgumentParser(description="Migrate year from folder name")
parser.add_argument(
"--dry-run",
action="store_true",
default=True,
help="Show what would be changed without making changes"
)
parser.add_argument(
"--execute",
action="store_true",
help="Actually execute the migration (disabled by default)"
)
args = parser.parse_args()
dry_run = not args.execute
if dry_run:
print("=== DRY RUN MODE ===")
print("No changes will be made. Use --execute to apply changes.\n")
import asyncio
try:
updated, skipped = asyncio.run(migrate_year_from_folder(dry_run=dry_run))
print(f"\n{'Would update' if dry_run else 'Updated'}: {updated} series")
print(f"Skipped (no year in folder): {skipped} series")
if dry_run:
print("\nRun with --execute to apply these changes.")
return 0
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,3 +1,4 @@
import re
import secrets
from typing import Optional
@@ -114,6 +115,40 @@ class Settings(BaseSettings):
validation_alias="NFO_PREFER_FSK_RATING",
description="Prefer German FSK rating over MPAA rating in NFO files"
)
nfo_folder_ignore_patterns: str = Field(
default="The Last of Us|Loki|Chernobyl|Star Trek Discovery|Marvel|Matrix|Fast & Furious|Jurassic|James Bond|Mission: Impossible|Bourne|Hunger Games|Die Hard|John Wick|Pacific Rim|Guardians of the Galaxy|Avengers|Batman|Superman|Wonder Woman|Spider-Man|X-Men|Fantastic Four|Terminator|Predator|Rambo|Rocky|Expendables|Tomb Raider|Jumanji|Jurassic Park|Pirates of the Caribbean|Harry Potter|Lord of the Rings|Hobbit|Game of Thrones|Westworld|Stranger Things|Breaking Bad|Better Call Saul|Sherlock|Downton Abbey|The Crown|Bridgerton|Sex Education|Normal People|Emily in Paris|The Witcher|Servant|Lucifer|Dark|Shadow and Bone|Grimm|Fairytale",
validation_alias="NFO_FOLDER_IGNORE_PATTERNS",
description="Regex patterns for folder names to skip during scan (pipe-separated)"
)
@property
def folder_ignore_patterns(self) -> list[str]:
"""Parse ignore patterns from comma-separated string into list.
Returns:
List of regex patterns to skip during folder scanning.
"""
if not self.nfo_folder_ignore_patterns:
return []
return [
pattern.strip()
for pattern in self.nfo_folder_ignore_patterns.split("|")
if pattern.strip()
]
def should_ignore_folder(self, folder_name: str) -> bool:
"""Check if folder should be ignored based on ignore patterns.
Args:
folder_name: Name of folder to check.
Returns:
True if folder matches any ignore pattern, False otherwise.
"""
for pattern in self.folder_ignore_patterns:
if re.search(pattern, folder_name, re.IGNORECASE):
return True
return False
@property
def allowed_origins(self) -> list[str]:
@@ -134,5 +169,23 @@ class Settings(BaseSettings):
]
return [origin.strip() for origin in raw.split(",") if origin.strip()]
@property
def scan_key_overrides(self) -> dict[str, str]:
"""Return scan key overrides from config.json.
Maps folder names to provider keys for cases where auto-generated
keys from folder names are incorrect.
Returns:
Dict mapping folder names to provider keys.
"""
from src.server.services.config_service import ConfigService
try:
config_service = ConfigService()
config = config_service.load_config()
return config.scan_key_overrides or {}
except Exception:
return {}
settings = Settings()

View File

@@ -20,10 +20,11 @@ from typing import Callable, Iterable, Iterator, Optional
from events import Events
from src.config.settings import settings
from src.core.entities.series import Serie
from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
from src.core.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.service import AnimeSeriesService, EpisodeService
@@ -56,6 +57,11 @@ class SerieScanner:
# 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__(
@@ -63,6 +69,7 @@ class SerieScanner:
basePath: str,
loader: Loader,
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
scan_key_overrides: Optional[dict[str, str]] = None,
) -> None:
"""
Initialize the SerieScanner.
@@ -75,6 +82,10 @@ class SerieScanner:
``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:
ValueError: If basePath is invalid or doesn't exist
@@ -94,11 +105,13 @@ class SerieScanner:
self.keyDict: dict[str, Serie] = {}
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.events = Events()
self.events.on_progress = []
self.events.on_error = []
self.events.on_warning = []
self.events.on_completion = []
logger.info("Initialized SerieScanner with base path: %s", abs_path)
@@ -191,7 +204,25 @@ class SerieScanner:
"""
if handler in self.events.on_error:
self.events.on_error.remove(handler)
def subscribe_on_warning(self, handler):
"""
Subscribe a handler to an event.
Args:
handler: Callable to handle the event
"""
if handler not in self.events.on_warning:
self.events.on_warning.append(handler)
def unsubscribe_on_warning(self, handler):
"""
Unsubscribe a handler from an event.
Args:
handler: Callable to remove
"""
if handler in self.events.on_warning:
self.events.on_warning.remove(handler)
def subscribe_on_completion(self, handler):
"""
Subscribe a handler to an event.
@@ -454,11 +485,27 @@ class SerieScanner:
# Store by key (primary identifier), not folder
if serie.key in self.keyDict:
logger.error(
"Duplicate series found with key '%s' "
"(folder: '%s')",
existing = self.keyDict[serie.key]
logger.warning(
"Duplicate series found with key '%s': "
"folder '%s' maps to same key as existing folder '%s'. "
"Skipping duplicate folder.",
serie.key,
folder
folder,
existing.folder
)
self._safe_call_event(
self.events.on_warning,
{
"operation_id": self._current_operation_id,
"warning": "duplicate_key",
"message": f"Duplicate series skipped: '{folder}' maps to key '{serie.key}' already used by '{existing.folder}'",
"metadata": {
"key": serie.key,
"duplicate_folder": folder,
"existing_folder": existing.folder,
}
}
)
else:
self.keyDict[serie.key] = serie
@@ -562,6 +609,9 @@ class SerieScanner:
for anime_name in os.listdir(self.directory):
anime_path = os.path.join(self.directory, anime_name)
if os.path.isdir(anime_path):
if settings.should_ignore_folder(anime_name):
logger.debug("Skipping ignored folder: %s", anime_name)
continue
mp4_files: list[str] = []
has_files = False
for root, _, files in os.walk(anime_path):
@@ -578,7 +628,9 @@ class SerieScanner:
1. Query DB by folder name
2. If found, return cached Serie object
3. If not in DB, fall back to provider search via _db_lookup callback
4. (Legacy) If still not found, try reading 'key' file as last resort
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:
folder_name: Filesystem folder name
@@ -587,9 +639,8 @@ class SerieScanner:
Serie object with valid key if found, None otherwise
Note:
DB is the source of truth. File-based lookups (key/data files)
are temporary backward compatibility for deployments with old data.
Will be removed in v3.0.0.
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
try:
@@ -640,25 +691,8 @@ class SerieScanner:
exc
)
# Step 3: Legacy fallback - TEMPORARY (remove in v3.0.0)
# Step 3: Legacy data file fallback (CLI-only deployments)
folder_path = os.path.join(self.directory, folder_name)
key_file = os.path.join(folder_path, 'key')
if os.path.exists(key_file):
logger.warning(
"Using legacy 'key' file for '%s' - this fallback is deprecated "
"and will be removed in v3.0.0",
folder_name
)
with open(key_file, 'r', encoding='utf-8') as file:
key = file.read().strip()
logger.info(
"Key found for folder '%s': %s",
folder_name,
key
)
year_from_folder = self._extract_year_from_folder_name(folder_name)
return Serie(key, "", "aniworld.to", folder_name, dict(), year=year_from_folder)
serie_file = os.path.join(folder_path, 'data')
if os.path.exists(serie_file):
with open(serie_file, "rb") as file:
@@ -669,6 +703,49 @@ class SerieScanner:
)
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
def __get_episode_and_season(self, filename: str) -> tuple[int, int]:

View File

@@ -166,7 +166,10 @@ class SeriesApp:
self.loaders = Loaders()
self.loader = self.loaders.GetLoader(key="aniworld.to")
self.serie_scanner = SerieScanner(
directory_to_search, self.loader, db_lookup=db_lookup
directory_to_search,
self.loader,
db_lookup=db_lookup,
scan_key_overrides=settings.scan_key_overrides,
)
# Skip automatic loading from data files - series will be loaded
# from database by the service layer during application setup

View File

@@ -17,6 +17,7 @@ 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__)
@@ -214,6 +215,9 @@ class SerieList:
}
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)

View File

@@ -7,7 +7,7 @@ errors in provider operations with automatic retry mechanisms.
import functools
import logging
from typing import Any, Callable, TypeVar
from typing import Any, Callable, Optional, TypeVar
logger = logging.getLogger(__name__)
@@ -42,41 +42,85 @@ class DownloadError(Exception):
class RecoveryStrategies:
"""Strategies for handling errors and recovering from failures."""
@staticmethod
def handle_network_failure(
func: Callable, *args: Any, **kwargs: Any
) -> Any:
"""Handle network failures with basic retry logic."""
max_retries = 3
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except (NetworkError, ConnectionError):
if attempt == max_retries - 1:
raise
logger.warning(
"Network error on attempt %d, retrying...",
attempt + 1,
)
continue
def __init__(
self,
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 60.0,
exponential_base: float = 2.0,
) -> None:
"""Initialize recovery strategies.
@staticmethod
def handle_download_failure(
Args:
max_retries: Maximum number of retry attempts.
base_delay: Initial delay between retries in seconds.
max_delay: Maximum delay between retries in seconds.
exponential_base: Base for exponential backoff multiplier.
"""
self.max_retries = max_retries
self.base_delay = base_delay
self.max_delay = max_delay
self.exponential_base = exponential_base
def _calculate_delay(self, attempt: int) -> float:
"""Calculate delay for given retry attempt using exponential backoff.
Args:
attempt: Zero-based retry attempt number.
Returns:
Delay in seconds before next retry.
"""
delay = self.base_delay * (self.exponential_base ** attempt)
return min(delay, self.max_delay)
def handle_network_failure(
self,
func: Callable, *args: Any, **kwargs: Any
) -> Any:
"""Handle download failures with retry logic."""
max_retries = 2
for attempt in range(max_retries):
"""Handle network failures with exponential backoff retry logic."""
last_error: Optional[Exception] = None
for attempt in range(self.max_retries):
try:
return func(*args, **kwargs)
except DownloadError:
if attempt == max_retries - 1:
raise
logger.warning(
"Download error on attempt %d, retrying...",
attempt + 1,
)
continue
except (NetworkError, ConnectionError, TimeoutError) as exc:
last_error = exc
if attempt < self.max_retries - 1:
delay = self._calculate_delay(attempt)
logger.warning(
"Network error on attempt %d/%d, retrying in %.1fs: %s",
attempt + 1, self.max_retries, delay, exc
)
import time
time.sleep(delay)
continue
if last_error:
raise last_error
raise NetworkError("Network failure after retries")
def handle_download_failure(
self,
func: Callable, *args: Any, **kwargs: Any
) -> Any:
"""Handle download failures with exponential backoff retry logic."""
last_error: Optional[Exception] = None
for attempt in range(self.max_retries):
try:
return func(*args, **kwargs)
except DownloadError as exc:
last_error = exc
if attempt < self.max_retries - 1:
delay = self._calculate_delay(attempt)
logger.warning(
"Download error on attempt %d/%d, retrying in %.1fs: %s",
attempt + 1, self.max_retries, delay, exc
)
import time
time.sleep(delay)
continue
if last_error:
raise last_error
raise DownloadError("Download failed after retries")
class FileCorruptionDetector:

View File

@@ -122,7 +122,10 @@ class AniworldLoader(Loader):
self.LULUVDO_USER_AGENT = LULUVDO_USER_AGENT
self.PROVIDER_HEADERS = {
ProviderType.VIDMOLY.value: ['Referer: "https://vidmoly.to"'],
ProviderType.DOODSTREAM.value: ['Referer: "https://dood.li/"'],
ProviderType.DOODSTREAM.value: [
'Referer: "https://dood.li/"',
'Referer: "https://playmogo.com/"',
],
ProviderType.VOE.value: [f"User-Agent: {self.RANDOM_USER_AGENT}"],
ProviderType.LULUVDO.value: [
f"User-Agent: {self.LULUVDO_USER_AGENT}",
@@ -547,8 +550,10 @@ class AniworldLoader(Loader):
'nocheckcertificate': True,
'logger': logger,
'progress_hooks': [events_progress_hook],
'downloader': 'ffmpeg',
'hls_use_mpegts': True,
# yt-dlp defaults to native HLS downloader which warns about
# "Live HLS streams are not supported" - disable to go
# straight to ffmpeg, avoiding the warning
'hls_prefer_native': False,
}
if header:
@@ -594,6 +599,40 @@ class AniworldLoader(Loader):
_cleanup_temp_file(temp_path)
continue
except Exception as exc:
# Check if this is an HLS-related failure that might succeed
# with additional ffmpeg options
exc_str = str(exc).lower()
is_hls_related = (
'hls' in exc_str or
'live' in exc_str or
'native downloader' in exc_str
)
if is_hls_related and 'ffmpeg' not in str(ydl_opts.get('downloader', '')):
logger.info(
"HLS stream detected, retrying with ffmpeg options: %s",
output_file
)
# Retry with ffmpeg explicitly set
retry_opts = ydl_opts.copy()
retry_opts['downloader'] = 'ffmpeg'
retry_opts['hls_use_mpegts'] = True
try:
with YoutubeDL(retry_opts) as ydl:
info = ydl.extract_info(link, download=True)
if os.path.exists(temp_path):
shutil.copyfile(temp_path, output_path)
os.remove(temp_path)
logger.info(
"Download completed successfully (retry): %s",
output_file
)
self.clear_cache()
return True
except Exception:
_cleanup_temp_file(temp_path)
# Continue to next provider if retry also fails
continue
logger.error(
"YoutubeDL download failed with provider %s: %s: %s",
provider_name, type(exc).__name__, exc

View File

@@ -88,7 +88,10 @@ class EnhancedAniWorldLoader(Loader):
self.PROVIDER_HEADERS = {
ProviderType.VIDMOLY.value: ['Referer: "https://vidmoly.to"'],
ProviderType.DOODSTREAM.value: ['Referer: "https://dood.li/"'],
ProviderType.DOODSTREAM.value: [
'Referer: "https://dood.li/"',
'Referer: "https://playmogo.com/"',
],
ProviderType.VOE.value: [f'User-Agent: {self.RANDOM_USER_AGENT}'],
ProviderType.LULUVDO.value: [
f'User-Agent: {self.LULUVDO_USER_AGENT}',

248
src/core/utils/key_utils.py Normal file
View File

@@ -0,0 +1,248 @@
"""Utility functions for generating URL-safe keys from folder names.
This module provides key generation and normalization for anime series,
handling edge cases like non-Latin characters and special symbols.
"""
from __future__ import annotations
import re
import unicodedata
import uuid
from typing import Optional
# Valid key pattern: alphanumeric, hyphens, underscores
# Must be at least 1 char, URL-safe
VALID_KEY_PATTERN = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$')
def normalize_key(key: str) -> str:
"""Normalize a key to a URL-safe format.
Args:
key: The key to normalize
Returns:
Normalized lowercase key with spaces replaced by hyphens
"""
if not key:
return ""
# Convert to lowercase
normalized = key.lower()
# Replace spaces and underscores with hyphens
normalized = re.sub(r'[\s_]+', '-', normalized)
# Remove any characters that aren't alphanumeric or hyphens
normalized = re.sub(r'[^a-z0-9-]', '', normalized)
# Collapse multiple consecutive hyphens
normalized = re.sub(r'-+', '-', normalized)
# Remove leading/trailing hyphens
normalized = normalized.strip('-')
return normalized
def is_valid_key(key: str) -> bool:
"""Check if a key is valid for URL-safe use.
Args:
key: The key to validate
Returns:
True if key is valid (non-empty, URL-safe, alphanumeric start/end, min 2 chars)
"""
if not key or not key.strip():
return False
if len(key) < 2:
return False
return bool(VALID_KEY_PATTERN.match(key))
def generate_key_from_folder(folder_name: str) -> str:
"""Generate a URL-safe key from a folder name.
Handles edge cases:
- Non-Latin characters (Japanese, Chinese, etc.)
- Special characters
- All-invalid names that normalize to empty
Args:
folder_name: The folder name to convert to a key
Returns:
A URL-safe key string. Never returns empty string.
Examples:
>>> generate_key_from_folder("Attack on Titan (2013)")
'attack-on-titan-2013'
>>> generate_key_from_folder("A Time Called You (2023)")
'a-time-called-you-2023'
>>> generate_key_from_folder("25-sai no Joshikousei (2018)")
'25-sai-no-joshikousei-2018'
"""
if not folder_name or not folder_name.strip():
raise ValueError("Folder name cannot be empty")
# Step 1: Unicode NFC normalization (preserves international chars)
normalized = unicodedata.normalize('NFC', folder_name.strip())
# Step 2: Extract alphanumeric parts, preserving international chars
# This keeps Japanese/Chinese characters but removes special symbols
parts = []
for char in normalized:
# Keep Unicode alphanumeric characters (letters/numbers from any script)
if char.isalnum():
parts.append(char)
elif char.isspace():
parts.append(' ')
# Handle apostrophes - treat as part of word (remove, don't replace with space)
# This normalizes e.g., "Hell's" -> "Hells"
# Includes: ' (0x27), ' (0x2018), ' (0x2019), ' (0x02BC), ` (0x0060)
elif char in ("'", "'", "'", "'", "`", """, """):
pass # Skip - drop the apostrophe
else:
parts.append(' ')
working = ''.join(parts)
# Step 3: Split into words and normalize each
words = working.split()
# Step 4: Convert to lowercase and create hyphenated key
key = '-'.join(word.lower() for word in words if word)
# Step 5: If we got a valid key, return it
if key and is_valid_key(key):
return key
# Step 6: Try just alphanumeric characters
alphanumeric_only = re.sub(r'[^a-zA-Z0-9\s]', '', working)
words = alphanumeric_only.split()
key = '-'.join(word.lower() for word in words if word)
if key and is_valid_key(key):
return key
# Step 7: Last resort - use folder name directly with transliteration
# Try to convert non-ASCII to ASCII equivalents
try:
# Use NFD normalization and strip combining characters
# This effectively Latinizes some characters
nfd_form = unicodedata.normalize('NFD', folder_name)
latinized = ''.join(
char for char in nfd_form
if unicodedata.category(char) != 'Mn' # Strip combining marks
)
# Remove non-ASCII letters
latinized = re.sub(r'[^a-zA-Z0-9\s]', ' ', latinized)
words = latinized.split()
key = '-'.join(word.lower() for word in words if word)
if key and is_valid_key(key):
return key
except Exception:
pass
# Step 8: Absolute fallback - generate UUID-based key
# Use first 8 chars of UUID for brevity
uuid_key = uuid.uuid4().hex[:8]
# Try to extract any meaningful words from the original name
meaningful_parts = []
for char in folder_name:
if char.isalnum():
meaningful_parts.append(char.lower())
elif len(meaningful_parts) > 0:
meaningful_parts.append('-')
fallback_base = ''.join(meaningful_parts).strip('-')
if fallback_base and len(fallback_base) >= 2:
# Combine meaningful parts with UUID for uniqueness
# Truncate meaningful parts if too long
if len(fallback_base) > 20:
fallback_base = fallback_base[:20]
return f"{fallback_base}-{uuid_key}"
return f"series-{uuid_key}"
def validate_key_uniqueness(
key: str,
existing_keys: set[str],
) -> tuple[bool, str]:
"""Validate that a key is unique among existing keys.
Args:
key: The key to validate
existing_keys: Set of keys that already exist
Returns:
Tuple of (is_valid, error_message)
"""
if not key or not key.strip():
return False, "Key cannot be empty"
stripped = key.strip()
if len(stripped) < 2:
return False, "Key must be at least 2 characters"
if not is_valid_key(stripped):
return False, "Key must be URL-safe (alphanumeric, hyphens, underscores only)"
if stripped in existing_keys:
return False, f"Key '{stripped}' is already in use"
return True, ""
def sanitize_key_for_url(key: str) -> str:
"""Sanitize a key for safe URL usage.
Args:
key: The key to sanitize
Returns:
URL-safe version of the key
"""
if not key:
return ""
# Replace spaces with hyphens first
sanitized = key.replace(' ', '-')
# Remove any characters that could cause URL issues (keep alphanumerics, hyphens, underscores)
sanitized = re.sub(r'[^\w\-]', '', sanitized)
# Collapse multiple hyphens
sanitized = re.sub(r'-+', '-', sanitized)
return sanitized.strip('-')
def sanitize_url_for_logging(url: str, max_length: int = 100) -> str:
"""Sanitize a URL for safe logging by removing sensitive query parameters.
Removes or truncates query parameters that may contain tokens, keys,
or other sensitive data while preserving enough structure for debugging.
Args:
url: The URL to sanitize
max_length: Maximum length of the returned URL string
Returns:
Sanitized URL safe for logging
"""
if not url:
return ""
# Truncate if too long
if len(url) > max_length:
return url[:max_length] + "..."
return url

View File

@@ -1,12 +1,15 @@
import logging
import warnings
from pathlib import Path
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.config.settings import settings
from src.core.entities.series import Serie
from src.core.utils.key_utils import generate_key_from_folder, is_valid_key
from src.server.database.service import AnimeSeriesService
from src.server.exceptions import (
BadRequestError,
@@ -14,11 +17,14 @@ from src.server.exceptions import (
ServerError,
ValidationError,
)
from src.server.models.anime import AnimeMetadataUpdate
from src.server.services.anime_service import AnimeService, AnimeServiceError
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 (
get_anime_service,
get_background_loader_service,
get_database_session,
get_optional_database_session,
get_series_app,
require_auth,
@@ -70,6 +76,100 @@ async def get_anime_status(
) from exc
class DuplicateFolderGroup(BaseModel):
"""A group of duplicate folders for the same series.
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)")
folders: List[str] = Field(..., description="List of duplicate folder names")
folder_count: int = Field(..., description="Number of duplicate folders")
class DuplicateFoldersResponse(BaseModel):
"""Response model for duplicate folders listing.
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")
duplicate_groups: List[DuplicateFolderGroup] = Field(
..., description="List of duplicate folder groups"
)
message: str = Field(..., description="Human-readable summary")
@router.get("/duplicate-folders", response_model=DuplicateFoldersResponse)
async def get_duplicate_folders(
_auth: dict = Depends(require_auth),
) -> DuplicateFoldersResponse:
"""List all pre-existing duplicate folder groups.
Scans the anime directory for folders with tvshow.nfo files that
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:
if not settings.anime_directory:
return DuplicateFoldersResponse(
total_groups=0,
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):
"""Summary of an anime series with missing episodes.
@@ -1088,3 +1188,75 @@ async def get_anime(
# Maximum allowed input size for security
MAX_INPUT_LENGTH = 100000 # 100KB
@router.put("/{anime_key}")
async def update_anime_metadata(
anime_key: str,
body: AnimeMetadataUpdate,
_auth: dict = Depends(require_auth),
db: AsyncSession = Depends(get_database_session),
) -> dict:
"""Update anime metadata (key, tmdb_id, tvdb_id).
Args:
anime_key: Current series key to update
body: Fields to update (all optional)
_auth: Authentication dependency
db: Database session
Returns:
Updated series metadata
Raises:
HTTPException 404: Series not found
HTTPException 409: Key conflict (new key already exists)
HTTPException 422: Validation error
"""
series = await AnimeSeriesService.get_by_key(db, anime_key)
if not series:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series with key '{anime_key}' not found",
)
updates = {}
if body.key is not None and body.key != anime_key:
existing = await AnimeSeriesService.get_by_key(db, body.key)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"A series with key '{body.key}' already exists",
)
updates["key"] = body.key
if body.tmdb_id is not None:
updates["tmdb_id"] = body.tmdb_id
if body.tvdb_id is not None:
updates["tvdb_id"] = body.tvdb_id
if not updates:
return {
"key": series.key,
"tmdb_id": series.tmdb_id,
"tvdb_id": series.tvdb_id,
"message": "No changes",
}
updated = await AnimeSeriesService.update(db, series.id, **updates)
await db.commit()
logger.info(
"Updated metadata for '%s': %s",
anime_key,
updates,
)
return {
"key": updated.key,
"tmdb_id": updated.tmdb_id,
"tvdb_id": updated.tvdb_id,
"message": "Metadata updated successfully",
}

View File

@@ -17,12 +17,15 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/health", tags=["health"])
from src.server.utils.version import APP_VERSION
class HealthStatus(BaseModel):
"""Basic health status response."""
status: str
timestamp: str
version: str = "1.0.1"
version: str = APP_VERSION
service: str = "aniworld-api"
series_app_initialized: bool = False
anime_directory_configured: bool = False
@@ -63,7 +66,7 @@ class DetailedHealthStatus(BaseModel):
status: str
timestamp: str
version: str = "1.0.1"
version: str = APP_VERSION
dependencies: DependencyHealth
startup_time: datetime

View File

@@ -16,6 +16,11 @@ from src.core.entities.series import Serie
from src.core.SeriesApp import SeriesApp
from src.core.services.nfo_factory import get_nfo_factory
from src.core.services.nfo_service import NFOService
from src.core.services.nfo_repair_service import (
REQUIRED_TAGS,
NfoRepairService,
find_missing_tags,
)
from src.core.services.tmdb_client import TMDBAPIError
from src.server.models.nfo import (
MediaDownloadRequest,
@@ -27,8 +32,10 @@ from src.server.models.nfo import (
NFOContentResponse,
NFOCreateRequest,
NFOCreateResponse,
NfoDiagnosticsResponse,
NFOMissingResponse,
NFOMissingSeries,
NfoRepairResponse,
)
from src.server.utils.dependencies import get_series_app, require_auth
from src.server.utils.media import check_media_files, get_media_file_paths
@@ -808,3 +815,142 @@ async def download_media(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to download media: {str(e)}"
) from e
@router.get("/{serie_key}/diagnostics", response_model=NfoDiagnosticsResponse)
async def get_nfo_diagnostics(
serie_key: str,
_auth: dict = Depends(require_auth),
series_app: SeriesApp = Depends(get_series_app),
) -> NfoDiagnosticsResponse:
"""Get NFO diagnostics showing missing required tags.
Args:
serie_key: Series key identifier
_auth: Authentication dependency
series_app: SeriesApp instance
Returns:
NfoDiagnosticsResponse with has_nfo, missing_tags, required_tags
Raises:
HTTPException 404: Series not found
"""
serie = None
for s in series_app.list.GetList():
if getattr(s, "key", None) == serie_key:
serie = s
break
if not serie:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series with key '{serie_key}' not found",
)
serie_folder = serie.ensure_folder_with_year()
folder_path = Path(settings.anime_directory) / serie_folder
nfo_path = folder_path / "tvshow.nfo"
required_tag_names = list(REQUIRED_TAGS.values())
if not nfo_path.exists():
return NfoDiagnosticsResponse(
has_nfo=False,
nfo_path=None,
missing_tags=required_tag_names,
required_tags=required_tag_names,
)
missing = find_missing_tags(nfo_path)
return NfoDiagnosticsResponse(
has_nfo=True,
nfo_path=str(nfo_path),
missing_tags=missing,
required_tags=required_tag_names,
)
@router.post("/{serie_key}/repair", response_model=NfoRepairResponse)
async def repair_nfo(
serie_key: str,
_auth: dict = Depends(require_auth),
series_app: SeriesApp = Depends(get_series_app),
nfo_service: NFOService = Depends(get_nfo_service),
) -> NfoRepairResponse:
"""Repair or recreate NFO file for a series.
Detects missing required tags and re-fetches metadata from TMDB.
Args:
serie_key: Series key identifier
_auth: Authentication dependency
series_app: SeriesApp instance
nfo_service: NFO service for TMDB operations
Returns:
NfoRepairResponse with success status and details
Raises:
HTTPException 404: Series not found
HTTPException 400: Cannot repair (e.g., no TMDB data available)
"""
serie = None
for s in series_app.list.GetList():
if getattr(s, "key", None) == serie_key:
serie = s
break
if not serie:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series with key '{serie_key}' not found",
)
serie_folder = serie.ensure_folder_with_year()
folder_path = Path(settings.anime_directory) / serie_folder
nfo_path = folder_path / "tvshow.nfo"
# Get missing tags before repair for reporting
missing_before = find_missing_tags(nfo_path) if nfo_path.exists() else list(REQUIRED_TAGS.values())
try:
repair_service = NfoRepairService(nfo_service)
if nfo_path.exists():
repaired = await repair_service.repair_series(folder_path, serie_folder)
if not repaired:
return NfoRepairResponse(
success=True,
message="NFO is already complete, no repair needed",
repaired_tags=[],
)
else:
# No NFO exists — create new one
await nfo_service.create_tvshow_nfo(
serie_name=serie.name,
serie_folder=serie_folder,
download_poster=True,
download_logo=True,
download_fanart=True,
)
return NfoRepairResponse(
success=True,
message=f"NFO repaired successfully. Fixed {len(missing_before)} missing tags.",
repaired_tags=missing_before,
)
except TMDBAPIError as e:
logger.warning("NFO repair failed for '%s': %s", serie_key, e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot repair NFO: {str(e)}. Ensure TMDB ID is set.",
) from e
except Exception as e:
logger.error("NFO repair error for '%s': %s", serie_key, e, exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to repair NFO: {str(e)}",
) from e

View File

@@ -617,6 +617,10 @@ class SystemSettings(Base, TimestampMixin):
Boolean, nullable=False, default=False, server_default="0",
doc="Whether legacy key/data file migration has been completed"
)
legacy_key_cleanup_completed: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0",
doc="Whether legacy key file cleanup has been completed"
)
last_scan_timestamp: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
doc="Timestamp of the last completed scan"

View File

@@ -155,6 +155,36 @@ class SystemSettingsService:
await db.commit()
logger.info("Marked legacy files migration as completed")
@staticmethod
async def is_legacy_key_cleanup_completed(db: AsyncSession) -> bool:
"""Check if legacy key file cleanup has been completed.
Args:
db: Database session
Returns:
True if cleanup is completed, False otherwise
"""
settings = await SystemSettingsService.get_or_create(db)
return settings.legacy_key_cleanup_completed
@staticmethod
async def mark_legacy_key_cleanup_completed(
db: AsyncSession,
timestamp: Optional[datetime] = None
) -> None:
"""Mark the legacy key file cleanup as completed.
Args:
db: Database session
timestamp: Optional timestamp to set, defaults to current time
"""
settings = await SystemSettingsService.get_or_create(db)
settings.legacy_key_cleanup_completed = True
settings.last_scan_timestamp = timestamp or datetime.now(timezone.utc)
await db.commit()
logger.info("Marked legacy key file cleanup as completed")
@staticmethod
async def mark_initial_media_scan_completed(
db: AsyncSession,
@@ -184,6 +214,8 @@ class SystemSettingsService:
settings.initial_scan_completed = False
settings.initial_nfo_scan_completed = False
settings.initial_media_scan_completed = False
settings.migration_legacy_files_completed = False
settings.legacy_key_cleanup_completed = False
settings.last_scan_timestamp = None
await db.commit()
logger.info("Reset all scan completion flags")

View File

@@ -214,6 +214,7 @@ async def lifespan(_application: FastAPI):
"""
# Setup logging first with INFO level
logger = setup_logging(log_level="INFO")
logger.info("Starting FastAPI application v%s", APP_VERSION)
# Track successful initialization steps
initialized = {
@@ -410,9 +411,7 @@ async def lifespan(_application: FastAPI):
# anime_directory may be configured there even if the env var is empty.
try:
logger.info("Initializing scheduler service...")
from src.server.services.scheduler_service import (
get_scheduler_service,
)
from src.server.services.scheduler_service import get_scheduler_service
scheduler_service = get_scheduler_service()
logger.info("Scheduler service instance obtained, starting...")
await scheduler_service.start()
@@ -542,8 +541,8 @@ async def lifespan(_application: FastAPI):
# 4. Shutdown download service and persist active downloads
try:
from src.server.services.download_service import ( # noqa: E501
_download_service_instance,
from src.server.services.download_service import (
_download_service_instance, # noqa: E501
)
if _download_service_instance is not None:
logger.info("Stopping download service...")
@@ -600,11 +599,13 @@ async def lifespan(_application: FastAPI):
raise startup_error
from src.server.utils.version import APP_VERSION
# Initialize FastAPI app with lifespan
app = FastAPI(
title="Aniworld Download Manager",
description="Modern web interface for Aniworld anime download management",
version="1.0.1",
version=APP_VERSION,
docs_url="/api/docs",
redoc_url="/api/redoc",
lifespan=lifespan

View File

@@ -83,6 +83,30 @@ class AnimeSeriesResponse(BaseModel):
return v
class AnimeMetadataUpdate(BaseModel):
"""Request model for updating anime metadata (key, tmdb_id, tvdb_id)."""
key: Optional[str] = Field(None, description="New series key (URL-safe, lowercase)")
tmdb_id: Optional[int] = Field(None, ge=1, description="TMDB ID (positive integer)")
tvdb_id: Optional[int] = Field(None, ge=1, description="TVDB ID (positive integer)")
@field_validator('key', mode='before')
@classmethod
def validate_key_format(cls, v: Optional[str]) -> Optional[str]:
"""Validate key is URL-safe lowercase with hyphens only."""
if v is None:
return v
v = v.strip().lower()
if not v:
raise ValueError("Key cannot be empty")
if not KEY_PATTERN.match(v):
raise ValueError(
"Key must contain only lowercase letters, numbers, and hyphens. "
"Cannot start or end with a hyphen."
)
return v
class SearchRequest(BaseModel):
"""Request payload for searching series."""

View File

@@ -44,6 +44,18 @@ class SchedulerConfig(BaseModel):
description="Run folder maintenance (NFO repair, folder renaming, "
"poster checks) during the scheduled run.",
)
# Legacy alias fields — read via Pydantic alias
auto_download: Optional[bool] = Field(default=None, alias="auto_download")
folder_scan: Optional[bool] = Field(default=None, alias="folder_scan")
def __init__(self, **data):
super().__init__(**data)
# Map legacy keys to primary fields only when primary key absent from data.
# "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:
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")
@classmethod
@@ -69,6 +81,22 @@ class SchedulerConfig(BaseModel):
)
return v
def model_dump(self, **kwargs) -> Dict[str, object]:
"""Serialize, excluding legacy alias fields when they are None.
The alias fields (auto_download, folder_scan) must not be written to
config.json as null entries, otherwise a roundtrip load sees the key
present (哪怕 value is None) and skips the alias-to-primary mapping.
"""
data = super().model_dump(**kwargs)
# Drop None alias fields so they don't pollute config.json.
# They are still settable via the constructor for backward compatibility.
if data.get("auto_download") is None:
data.pop("auto_download", None)
if data.get("folder_scan") is None:
data.pop("folder_scan", None)
return data
class BackupConfig(BaseModel):
"""Configuration for automatic backups of application data."""
@@ -171,6 +199,12 @@ class AppConfig(BaseModel):
logging: LoggingConfig = Field(default_factory=LoggingConfig)
backup: BackupConfig = Field(default_factory=BackupConfig)
nfo: NFOConfig = Field(default_factory=NFOConfig)
scan_key_overrides: Dict[str, str] = Field(
default_factory=dict,
description="Map of folder names to provider keys for scan overrides. "
"Used when auto-generated keys from folder names are incorrect. "
"Format: {\"Folder Name\": \"actual-provider-key\"}"
)
other: Dict[str, object] = Field(
default_factory=dict, description="Arbitrary other settings"
)
@@ -209,6 +243,7 @@ class ConfigUpdate(BaseModel):
logging: Optional[LoggingConfig] = None
backup: Optional[BackupConfig] = None
nfo: Optional[NFOConfig] = None
scan_key_overrides: Optional[Dict[str, str]] = None
other: Optional[Dict[str, object]] = None
def apply_to(self, current: AppConfig) -> AppConfig:
@@ -225,6 +260,8 @@ class ConfigUpdate(BaseModel):
data["backup"] = self.backup.model_dump()
if self.nfo is not None:
data["nfo"] = self.nfo.model_dump()
if self.scan_key_overrides is not None:
data["scan_key_overrides"] = self.scan_key_overrides
if self.other is not None:
merged = dict(current.other or {})
merged.update(self.other)

View File

@@ -355,3 +355,29 @@ class NFOMissingResponse(BaseModel):
...,
description="List of series missing NFO"
)
class NfoDiagnosticsResponse(BaseModel):
"""Response for NFO diagnostics showing missing required tags."""
has_nfo: bool = Field(..., description="Whether tvshow.nfo exists")
nfo_path: Optional[str] = Field(None, description="Path to NFO file if exists")
missing_tags: List[str] = Field(
default_factory=list,
description="List of missing required tag names"
)
required_tags: List[str] = Field(
default_factory=list,
description="All required tag names for reference"
)
class NfoRepairResponse(BaseModel):
"""Response after NFO repair attempt."""
success: bool = Field(..., description="Whether repair succeeded")
message: str = Field(..., description="Human-readable result message")
repaired_tags: List[str] = Field(
default_factory=list,
description="Tags that were missing before repair"
)

View File

@@ -528,6 +528,8 @@ class AnimeService:
"tmdb_id": db_series.tmdb_id,
"tvdb_id": db_series.tvdb_id,
"series_id": db_series.id,
"loading_status": db_series.loading_status,
"loading_error": db_series.loading_error,
}
# Build episodeDict from DB, skipping is_downloaded=True
@@ -596,6 +598,8 @@ class AnimeService:
"tmdb_id": nfo_data.get("tmdb_id"),
"tvdb_id": nfo_data.get("tvdb_id"),
"series_id": nfo_data.get("series_id"),
"loading_status": nfo_data.get("loading_status"),
"loading_error": nfo_data.get("loading_error"),
}
result_list.append(series_dict)

View File

@@ -144,7 +144,13 @@ class ConfigService:
# Save configuration with version
data = config.model_dump()
data["version"] = self.CONFIG_VERSION
# Re-serialize SchedulerConfig through its overridden model_dump so
# that None legacy alias fields are stripped before writing to disk.
# Pydantic converts nested models to plain dicts in model_dump() output,
# so we call the override explicitly on the scheduler field.
data["scheduler"] = config.scheduler.model_dump()
# Write to temporary file first for atomic operation
temp_path = self.config_path.with_suffix(".tmp")
try:

View File

@@ -13,8 +13,9 @@ reflect the new paths.
from __future__ import annotations
import logging
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional, Set, Tuple
from lxml import etree
@@ -34,6 +35,141 @@ logger = logging.getLogger(__name__)
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.
@@ -115,6 +251,136 @@ def _is_series_being_downloaded(series_folder: str) -> bool:
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,
@@ -137,7 +403,7 @@ async def _update_database_paths(
async with get_db_session() as db:
# 1. Update AnimeSeries.folder
series = await AnimeSeriesService.get_by_key(db, old_folder)
series = await AnimeSeriesService.get_by_folder(db, old_folder)
if series is None:
# Fallback: try to find by folder name
all_series = await AnimeSeriesService.get_all(db)
@@ -211,7 +477,7 @@ async def _update_database_paths(
)
async def validate_and_rename_series_folders() -> Dict[str, int]:
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
@@ -226,6 +492,10 @@ async def validate_and_rename_series_folders() -> Dict[str, int]:
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
@@ -244,8 +514,33 @@ async def validate_and_rename_series_folders() -> Dict[str, int]:
)
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
@@ -285,6 +580,15 @@ async def validate_and_rename_series_folders() -> Dict[str, int]:
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(
@@ -292,7 +596,55 @@ async def validate_and_rename_series_folders() -> Dict[str, int]:
current_name,
expected_name,
)
stats["errors"] += 1
# Target folder exists — remove source folder and delete its DB record
# (target folder's DB record survives, source folder's record must be removed
# to avoid orphaning episodes/downloads)
try:
import shutil
logger.warning(
"Removing source duplicate folder '%s' — target '%s' already exists",
current_name,
expected_name,
)
shutil.rmtree(series_dir)
logger.info(
"Removed source folder '%s' — series already exists at target",
current_name,
)
# Delete source DB record (cascades to episodes and download items)
async with get_db_session() as db:
source_series = await AnimeSeriesService.get_by_folder(db, current_name)
if source_series is None:
# Fallback: find by folder name
all_series = await AnimeSeriesService.get_all(db)
for s in all_series:
if s.folder == current_name:
source_series = s
break
if source_series is not None:
await AnimeSeriesService.delete(db, source_series.id)
logger.info(
"Deleted source DB record for '%s' (id=%s) — target folder '%s' retains DB record",
current_name,
source_series.id,
expected_name,
)
else:
logger.info(
"No DB record found for source folder '%s' — folder removed only",
current_name,
)
stats["renamed"] += 1
except OSError as exc:
logger.error(
"Failed to remove source folder '%s': %s",
current_name,
exc,
)
stats["errors"] += 1
continue
# Check path length limits
@@ -305,7 +657,17 @@ async def validate_and_rename_series_folders() -> Dict[str, int]:
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
@@ -315,6 +677,12 @@ async def validate_and_rename_series_folders() -> Dict[str, int]:
# 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",

View File

@@ -129,6 +129,7 @@ async def perform_nfo_repair_scan(background_loader=None) -> None:
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
@@ -137,19 +138,31 @@ async def perform_nfo_repair_scan(background_loader=None) -> None:
if not nfo_path.exists():
# Create minimal NFO for series without one
missing_nfo_count += 1
asyncio.create_task(
_create_missing_nfo(series_dir, series_name),
name=f"nfo_create:{series_name}",
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
asyncio.create_task(
_repair_one_series(series_dir, series_name),
name=f"nfo_repair:{series_name}",
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,
@@ -182,10 +195,10 @@ class FolderScanService:
if not self._prerequisites_met():
return
# 1.3 — Repair incomplete NFO files in the background.
# 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 queued; repairs will continue in background")
logger.info("NFO repair scan complete")
# 1.4 — Validate and rename series folders after NFO repair.
logger.info("Starting folder rename validation")

View File

@@ -1,5 +1,6 @@
"""Centralized initialization service for application startup and setup."""
import asyncio
import os
from pathlib import Path
from typing import Callable, Optional
@@ -122,6 +123,28 @@ async def _mark_legacy_migration_completed() -> None:
)
async def _check_legacy_key_cleanup_status() -> bool:
"""Check if legacy key file cleanup has been completed.
Returns:
bool: True if cleanup was completed, False otherwise
"""
return await _check_scan_status(
check_method=lambda svc, db: svc.is_legacy_key_cleanup_completed(db),
scan_type="legacy_key_cleanup",
log_completed_msg="Legacy key file cleanup already completed, skipping",
log_not_completed_msg="Legacy key file cleanup not yet run, will clean up key files"
)
async def _mark_legacy_key_cleanup_completed() -> None:
"""Mark the legacy key file cleanup as completed in system settings."""
await _mark_scan_completed(
mark_method=lambda svc, db: svc.mark_legacy_key_cleanup_completed(db),
scan_type="legacy_key_cleanup"
)
async def _migrate_legacy_files() -> int:
"""Migrate series from legacy key/data files to database.
@@ -151,6 +174,78 @@ async def _migrate_legacy_files() -> int:
return 0
async def _cleanup_legacy_key_files() -> int:
"""Remove legacy key files from folders that already have DB entries.
This is a one-time cleanup task that runs at startup after legacy migration.
It removes deprecated 'key' files that cause duplicate key errors when
folders are renamed, since the DB is now the source of truth.
Returns:
int: Number of key files deleted
"""
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
logger.info("Checking for legacy key files to clean up...")
if not settings.anime_directory or not os.path.isdir(settings.anime_directory):
logger.warning(
"Anime directory not configured or does not exist, skipping legacy key cleanup"
)
return 0
deleted_count = 0
scanned_count = 0
try:
async with get_db_session() as db:
# Get all series from DB to know which folders should have key files removed
all_series = await AnimeSeriesService.get_all(db)
# Build a set of known folder names from DB
db_folders: set[str] = {series.folder for series in all_series if series.folder}
for folder_name in db_folders:
folder_path = settings.anime_directory / folder_name
key_file = folder_path / "key"
if not key_file.exists():
continue
scanned_count += 1
try:
key_file.unlink()
deleted_count += 1
logger.info(
"Removed legacy key file",
folder=folder_name,
key_file=str(key_file)
)
except OSError as exc:
logger.warning(
"Could not remove legacy key file",
folder=folder_name,
key_file=str(key_file),
error=str(exc)
)
except Exception as e:
logger.error(
"Legacy key file cleanup failed",
error=str(e),
exc_info=True
)
return deleted_count
logger.info(
"Legacy key file cleanup complete",
scanned=scanned_count,
deleted=deleted_count
)
return deleted_count
async def _sync_anime_folders(progress_service=None) -> int:
"""Scan anime folders and sync series to database.
@@ -287,6 +382,13 @@ async def perform_initial_setup(progress_service=None):
# Sync series from anime folders to database
await _sync_anime_folders(progress_service)
# Clean up legacy key files from folders that now have DB entries
# This runs after migration/sync to ensure DB entries exist before deletion
is_key_cleanup_done = await _check_legacy_key_cleanup_status()
if not is_key_cleanup_done:
await _cleanup_legacy_key_files()
await _mark_legacy_key_cleanup_completed()
# Mark the initial scan as completed
await _mark_initial_scan_completed()

View File

@@ -0,0 +1,317 @@
"""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,
)

View File

@@ -316,11 +316,9 @@ class SchedulerService:
return
try:
from src.server.database.connection import ( # noqa: PLC0415
get_db_session,
)
from src.server.database.system_settings_service import ( # noqa: PLC0415
SystemSettingsService,
from src.server.database.connection import get_db_session # noqa: PLC0415
from src.server.database.system_settings_service import (
SystemSettingsService, # noqa: PLC0415
)
async with get_db_session() as db:
@@ -367,8 +365,8 @@ class SchedulerService:
async def _broadcast(self, event_type: str, data: dict) -> None:
"""Broadcast a WebSocket event to all connected clients."""
try:
from src.server.services.websocket_service import ( # noqa: PLC0415
get_websocket_service,
from src.server.services.websocket_service import (
get_websocket_service, # noqa: PLC0415
)
ws_service = get_websocket_service()
@@ -503,8 +501,8 @@ class SchedulerService:
if self._config and self._config.folder_scan_enabled:
logger.info("Folder scan is enabled — starting")
try:
from src.server.services.folder_scan_service import ( # noqa: PLC0415
FolderScanService,
from src.server.services.folder_scan_service import (
FolderScanService, # noqa: PLC0415
)
folder_scan_service = FolderScanService()
@@ -519,6 +517,26 @@ class SchedulerService:
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")

View File

@@ -21,6 +21,8 @@ from typing import Any, Dict, List, Optional
from fastapi import Request
from fastapi.templating import Jinja2Templates
from src.server.utils.version import APP_VERSION
logger = logging.getLogger(__name__)
# Configure templates directory
@@ -48,7 +50,7 @@ def get_base_context(
"request": request,
"title": title,
"app_name": "Aniworld Download Manager",
"version": "1.0.1",
"version": APP_VERSION,
"static_v": STATIC_VERSION,
}

View 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()

View File

@@ -268,3 +268,205 @@
gap: 4px;
}
}
/* ============================================================================
Context Menu
============================================================================ */
.context-menu {
position: fixed;
z-index: 1500;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
box-shadow: var(--shadow-elevated);
min-width: 180px;
padding: var(--spacing-xs) 0;
animation: contextMenuFadeIn 0.12s ease-out;
}
@keyframes contextMenuFadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.context-menu-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
color: var(--color-text-primary);
font-size: var(--font-size-body);
transition: background-color 0.1s ease;
}
.context-menu-item:hover {
background-color: var(--color-hover);
}
.context-menu-item i {
width: 16px;
text-align: center;
color: var(--color-text-secondary);
}
/* ============================================================================
Edit Metadata Modal
============================================================================ */
.edit-modal-content {
max-width: 520px;
}
.edit-section {
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--color-divider);
}
.edit-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.edit-section h4 {
margin: 0 0 var(--spacing-md) 0;
font-size: var(--font-size-body);
font-weight: 600;
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.edit-section h4 i {
color: var(--color-accent);
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-group label {
display: block;
margin-bottom: var(--spacing-xs);
font-size: var(--font-size-caption);
font-weight: 500;
color: var(--color-text-secondary);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-md);
}
.field-error {
display: block;
margin-top: var(--spacing-xs);
font-size: var(--font-size-caption);
color: var(--color-error, #e74c3c);
}
.input-error {
border-color: var(--color-error, #e74c3c) !important;
}
.key-warning {
background: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: var(--border-radius);
padding: var(--spacing-sm) var(--spacing-md);
margin-top: var(--spacing-sm);
font-size: var(--font-size-caption);
color: var(--color-warning, #f39c12);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
/* NFO Diagnostics */
.nfo-diagnostics {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.nfo-status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: var(--font-size-caption);
font-weight: 600;
width: fit-content;
}
.nfo-status-badge.nfo-complete {
background: rgba(46, 204, 113, 0.15);
color: var(--color-success, #2ecc71);
}
.nfo-status-badge.nfo-incomplete {
background: rgba(243, 156, 18, 0.15);
color: var(--color-warning, #f39c12);
}
.nfo-status-badge.nfo-missing {
background: rgba(231, 76, 60, 0.15);
color: var(--color-error, #e74c3c);
}
.missing-tags-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
margin-top: var(--spacing-xs);
}
.missing-tag-chip {
display: inline-block;
padding: 2px 8px;
background: var(--color-background-subtle);
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: var(--font-size-caption);
color: var(--color-text-secondary);
}
.nfo-all-good {
color: var(--color-success, #2ecc71);
font-size: var(--font-size-caption);
margin: 0;
}
.nfo-error {
color: var(--color-error, #e74c3c);
font-size: var(--font-size-caption);
margin: 0;
}
.repair-hint {
font-size: var(--font-size-caption);
color: var(--color-text-secondary);
margin: 0;
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.btn-repair {
align-self: flex-start;
margin-top: var(--spacing-sm);
}
.modal-footer {
display: flex;
justify-content: flex-end;
padding: var(--spacing-md) var(--spacing-lg);
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}

View File

@@ -45,6 +45,7 @@ AniWorld.IndexApp = (function() {
AniWorld.Search.init();
AniWorld.ScanManager.init();
AniWorld.ConfigManager.init();
AniWorld.ContextMenu.init();
// Bind global events
bindGlobalEvents();

View File

@@ -0,0 +1,123 @@
/**
* AniWorld - Context Menu Component
*
* Right-click context menu for anime series cards.
* Provides quick access to edit metadata.
*
* Dependencies: ui-utils.js, edit-modal.js
*/
var AniWorld = window.AniWorld || {};
AniWorld.ContextMenu = (function() {
'use strict';
let menuElement = null;
let currentSeriesKey = null;
/**
* Initialize the context menu system.
* Attaches global dismissal listeners.
*/
function init() {
// Dismiss on click outside
document.addEventListener('click', function(e) {
if (menuElement && !menuElement.contains(e.target)) {
hide();
}
});
// Dismiss on Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
hide();
}
});
// Dismiss on scroll or resize
window.addEventListener('scroll', hide, true);
window.addEventListener('resize', hide);
// Attach context menu via event delegation on the series grid
const grid = document.getElementById('series-grid');
if (grid) {
grid.addEventListener('contextmenu', function(e) {
const card = e.target.closest('.series-card');
if (card) {
e.preventDefault();
const key = card.getAttribute('data-key');
if (key) {
show(e, key);
}
}
});
}
}
/**
* Show context menu at cursor position.
* @param {MouseEvent} event - The contextmenu event
* @param {string} seriesKey - The series key to operate on
*/
function show(event, seriesKey) {
hide(); // Remove any existing menu first
currentSeriesKey = seriesKey;
menuElement = document.createElement('div');
menuElement.className = 'context-menu';
menuElement.innerHTML = `
<div class="context-menu-item" data-action="edit">
<i class="fa-solid fa-pen-to-square"></i>
<span>Edit Metadata</span>
</div>
`;
document.body.appendChild(menuElement);
// Position within viewport bounds
const x = event.clientX;
const y = event.clientY;
const menuRect = menuElement.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let posX = x;
let posY = y;
if (x + menuRect.width > viewportWidth) {
posX = viewportWidth - menuRect.width - 8;
}
if (y + menuRect.height > viewportHeight) {
posY = viewportHeight - menuRect.height - 8;
}
menuElement.style.left = posX + 'px';
menuElement.style.top = posY + 'px';
// Attach action handlers
menuElement.querySelector('[data-action="edit"]').addEventListener('click', function() {
hide();
if (AniWorld.EditModal) {
AniWorld.EditModal.open(currentSeriesKey);
}
});
}
/**
* Hide and remove the context menu from DOM.
*/
function hide() {
if (menuElement) {
menuElement.remove();
menuElement = null;
currentSeriesKey = null;
}
}
return {
init: init,
show: show,
hide: hide
};
})();

View File

@@ -0,0 +1,450 @@
/**
* AniWorld - Edit Modal Component
*
* Modal dialog for viewing/editing anime metadata (key, tmdb_id, tvdb_id)
* and NFO diagnostics with repair functionality.
*
* Dependencies: api-client.js, ui-utils.js
*/
var AniWorld = window.AniWorld || {};
AniWorld.EditModal = (function() {
'use strict';
let modalElement = null;
let originalData = null;
let currentKey = null;
/**
* Open the edit modal for a specific anime series.
* @param {string} seriesKey - The series key to edit
*/
async function open(seriesKey) {
currentKey = seriesKey;
modalElement = document.getElementById('edit-metadata-modal');
if (!modalElement) return;
// Show modal
modalElement.classList.remove('hidden');
// Reset form state
setLoading(true);
clearErrors();
hideKeyWarning();
try {
// Find series data from the local series list
const seriesData = findSeriesData(seriesKey);
originalData = {
key: seriesKey,
tmdb_id: seriesData ? seriesData.tmdb_id : null,
tvdb_id: seriesData ? seriesData.tvdb_id : null,
};
// Populate form fields
setFieldValue('edit-key', originalData.key);
setFieldValue('edit-tmdb-id', originalData.tmdb_id || '');
setFieldValue('edit-tvdb-id', originalData.tvdb_id || '');
// Load NFO diagnostics
await loadDiagnostics(seriesKey);
} catch (err) {
AniWorld.UI.showToast('Failed to load series data', 'error');
console.error('Edit modal load error:', err);
} finally {
setLoading(false);
}
// Attach event listeners
attachListeners();
}
/**
* Close the edit modal and reset state.
*/
function close() {
if (modalElement) {
modalElement.classList.add('hidden');
}
originalData = null;
currentKey = null;
detachListeners();
}
/**
* Save changed metadata to the backend.
*/
async function save() {
clearErrors();
const newKey = getFieldValue('edit-key').trim().toLowerCase();
const tmdbIdStr = getFieldValue('edit-tmdb-id').trim();
const tvdbIdStr = getFieldValue('edit-tvdb-id').trim();
// Validate key
if (!newKey) {
showFieldError('edit-key', 'Key cannot be empty');
return;
}
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(newKey)) {
showFieldError('edit-key', 'Key must contain only lowercase letters, numbers, and hyphens');
return;
}
// Validate IDs
const tmdbId = tmdbIdStr ? parseInt(tmdbIdStr, 10) : null;
const tvdbId = tvdbIdStr ? parseInt(tvdbIdStr, 10) : null;
if (tmdbIdStr && (isNaN(tmdbId) || tmdbId < 1)) {
showFieldError('edit-tmdb-id', 'TMDB ID must be a positive number');
return;
}
if (tvdbIdStr && (isNaN(tvdbId) || tvdbId < 1)) {
showFieldError('edit-tvdb-id', 'TVDB ID must be a positive number');
return;
}
// Check if key changed — show confirmation
if (newKey !== originalData.key) {
const confirmed = await AniWorld.UI.showConfirmModal(
'Rename Series Key',
`Changing the key from "${originalData.key}" to "${newKey}" will update the primary identifier. ` +
'This may affect provider linkage. Are you sure?'
);
if (!confirmed) return;
}
// Build update payload (only changed fields)
const payload = {};
if (newKey !== originalData.key) payload.key = newKey;
if (tmdbId !== originalData.tmdb_id) payload.tmdb_id = tmdbId;
if (tvdbId !== originalData.tvdb_id) payload.tvdb_id = tvdbId;
if (Object.keys(payload).length === 0) {
AniWorld.UI.showToast('No changes to save', 'info');
return;
}
// Send update
setSaveLoading(true);
try {
const response = await AniWorld.ApiClient.put(
'/api/anime/' + encodeURIComponent(currentKey),
payload
);
if (!response) return;
if (response.ok) {
const result = await response.json();
AniWorld.UI.showToast('Metadata updated successfully', 'success');
// Update local state
const oldKey = currentKey;
currentKey = result.key;
originalData = {
key: result.key,
tmdb_id: result.tmdb_id,
tvdb_id: result.tvdb_id,
};
// Update the card in the DOM
updateCardAfterSave(oldKey, result);
// Update repair button state
updateRepairButtonState();
} else if (response.status === 409) {
showFieldError('edit-key', 'A series with this key already exists');
} else if (response.status === 422) {
const err = await response.json();
AniWorld.UI.showToast('Validation error: ' + (err.detail || 'Invalid input'), 'error');
} else {
AniWorld.UI.showToast('Failed to update metadata', 'error');
}
} catch (err) {
AniWorld.UI.showToast('Connection error. Check your network.', 'error');
console.error('Save error:', err);
} finally {
setSaveLoading(false);
}
}
/**
* Trigger NFO repair for the current series.
*/
async function repairNfo() {
setRepairLoading(true);
try {
const response = await AniWorld.ApiClient.post(
'/api/nfo/' + encodeURIComponent(currentKey) + '/repair',
{}
);
if (!response) return;
if (response.ok) {
const result = await response.json();
AniWorld.UI.showToast(result.message, 'success');
// Refresh diagnostics
await loadDiagnostics(currentKey);
} else if (response.status === 400) {
const err = await response.json();
AniWorld.UI.showToast(err.detail || 'Cannot repair NFO', 'error');
} else {
AniWorld.UI.showToast('Failed to repair NFO', 'error');
}
} catch (err) {
AniWorld.UI.showToast('Connection error during repair', 'error');
console.error('Repair error:', err);
} finally {
setRepairLoading(false);
}
}
/**
* Load NFO diagnostics for the current series.
* @param {string} key - Series key
*/
async function loadDiagnostics(key) {
const container = document.getElementById('nfo-diagnostics-container');
if (!container) return;
try {
const response = await AniWorld.ApiClient.get(
'/api/nfo/' + encodeURIComponent(key) + '/diagnostics'
);
if (!response || !response.ok) {
container.innerHTML = '<p class="nfo-error">Failed to load NFO diagnostics</p>';
return;
}
const data = await response.json();
renderDiagnostics(data);
updateRepairButtonState();
} catch (err) {
container.innerHTML = '<p class="nfo-error">Error loading diagnostics</p>';
console.error('Diagnostics error:', err);
}
}
/**
* Render NFO diagnostics data into the modal.
* @param {Object} data - NfoDiagnosticsResponse
*/
function renderDiagnostics(data) {
const badge = document.getElementById('nfo-status-badge');
const tagsList = document.getElementById('nfo-missing-tags');
if (badge) {
if (!data.has_nfo) {
badge.className = 'nfo-status-badge nfo-missing';
badge.textContent = 'No NFO File';
} else if (data.missing_tags.length === 0) {
badge.className = 'nfo-status-badge nfo-complete';
badge.textContent = 'Complete';
} else {
badge.className = 'nfo-status-badge nfo-incomplete';
badge.textContent = data.missing_tags.length + ' Missing';
}
}
if (tagsList) {
if (data.missing_tags.length === 0) {
tagsList.innerHTML = '<p class="nfo-all-good">All required tags present</p>';
} else {
tagsList.innerHTML = data.missing_tags.map(function(tag) {
return '<span class="missing-tag-chip">' + escapeHtml(tag) + '</span>';
}).join('');
}
}
}
/**
* Update repair button disabled state based on tmdb_id field.
*/
function updateRepairButtonState() {
const btn = document.getElementById('btn-repair-nfo');
const hint = document.getElementById('repair-hint');
const tmdbValue = getFieldValue('edit-tmdb-id').trim();
if (btn) {
// Enable repair even without tmdb_id — the service can search by name
btn.disabled = false;
}
if (hint) {
hint.style.display = tmdbValue ? 'none' : 'block';
}
}
// ---- Helpers ----
function findSeriesData(key) {
// Access the series data from the series manager if available
if (AniWorld.SeriesManager && AniWorld.SeriesManager.getSeriesData) {
const allSeries = AniWorld.SeriesManager.getSeriesData();
if (allSeries) {
return allSeries.find(function(s) { return s.key === key; });
}
}
return null;
}
function updateCardAfterSave(oldKey, result) {
const card = document.querySelector('[data-series-id="' + oldKey + '"]');
if (card) {
card.setAttribute('data-key', result.key);
card.setAttribute('data-series-id', result.key);
// Update checkbox data-key
const checkbox = card.querySelector('.series-checkbox');
if (checkbox) {
checkbox.setAttribute('data-key', result.key);
}
}
// Update local series data array
if (AniWorld.SeriesManager && AniWorld.SeriesManager.updateSeriesKey) {
AniWorld.SeriesManager.updateSeriesKey(oldKey, result.key);
}
}
function setFieldValue(id, value) {
const el = document.getElementById(id);
if (el) el.value = value !== null && value !== undefined ? value : '';
}
function getFieldValue(id) {
const el = document.getElementById(id);
return el ? el.value : '';
}
function showFieldError(fieldId, message) {
const el = document.getElementById(fieldId);
if (el) {
const errorEl = el.parentElement.querySelector('.field-error');
if (errorEl) {
errorEl.textContent = message;
errorEl.style.display = 'block';
}
el.classList.add('input-error');
}
}
function clearErrors() {
if (!modalElement) return;
modalElement.querySelectorAll('.field-error').forEach(function(el) {
el.style.display = 'none';
el.textContent = '';
});
modalElement.querySelectorAll('.input-error').forEach(function(el) {
el.classList.remove('input-error');
});
}
function hideKeyWarning() {
const warning = document.getElementById('key-change-warning');
if (warning) warning.style.display = 'none';
}
function setLoading(loading) {
const form = document.getElementById('edit-metadata-form');
if (form) {
form.style.opacity = loading ? '0.5' : '1';
form.style.pointerEvents = loading ? 'none' : 'auto';
}
}
function setSaveLoading(loading) {
const btn = document.getElementById('btn-save-metadata');
if (btn) {
btn.disabled = loading;
btn.innerHTML = loading
? '<i class="fa-solid fa-spinner fa-spin"></i> Saving...'
: '<i class="fa-solid fa-floppy-disk"></i> Save';
}
}
function setRepairLoading(loading) {
const btn = document.getElementById('btn-repair-nfo');
if (btn) {
btn.disabled = loading;
btn.innerHTML = loading
? '<i class="fa-solid fa-spinner fa-spin"></i> Repairing...'
: '<i class="fa-solid fa-wrench"></i> Repair NFO';
}
}
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Event listener management
let listeners = [];
function attachListeners() {
detachListeners();
const saveBtn = document.getElementById('btn-save-metadata');
const cancelBtn = document.getElementById('btn-cancel-metadata');
const repairBtn = document.getElementById('btn-repair-nfo');
const overlay = modalElement ? modalElement.querySelector('.modal-overlay') : null;
const keyInput = document.getElementById('edit-key');
if (saveBtn) {
var saveFn = function() { save(); };
saveBtn.addEventListener('click', saveFn);
listeners.push({ el: saveBtn, event: 'click', fn: saveFn });
}
if (cancelBtn) {
var cancelFn = function() { close(); };
cancelBtn.addEventListener('click', cancelFn);
listeners.push({ el: cancelBtn, event: 'click', fn: cancelFn });
}
if (repairBtn) {
var repairFn = function() { repairNfo(); };
repairBtn.addEventListener('click', repairFn);
listeners.push({ el: repairBtn, event: 'click', fn: repairFn });
}
if (overlay) {
var overlayFn = function() { close(); };
overlay.addEventListener('click', overlayFn);
listeners.push({ el: overlay, event: 'click', fn: overlayFn });
}
if (keyInput) {
var keyFn = function() {
var warning = document.getElementById('key-change-warning');
if (warning) {
warning.style.display = keyInput.value !== originalData.key ? 'block' : 'none';
}
};
keyInput.addEventListener('input', keyFn);
listeners.push({ el: keyInput, event: 'input', fn: keyFn });
}
}
function detachListeners() {
listeners.forEach(function(l) {
l.el.removeEventListener(l.event, l.fn);
});
listeners = [];
}
return {
open: open,
close: close,
save: save,
repairNfo: repairNfo
};
})();

View File

@@ -392,6 +392,22 @@ AniWorld.SeriesManager = (function() {
return seriesData;
}
/**
* Update a series key in the local data arrays after rename.
* @param {string} oldKey - The previous key
* @param {string} newKey - The new key
*/
function updateSeriesKey(oldKey, newKey) {
if (seriesData) {
var s = seriesData.find(function(item) { return item.key === oldKey; });
if (s) s.key = newKey;
}
if (filteredSeriesData) {
var fs = filteredSeriesData.find(function(item) { return item.key === oldKey; });
if (fs) fs.key = newKey;
}
}
/**
* Get filtered series data
* @returns {Array} Filtered series data array
@@ -543,6 +559,7 @@ AniWorld.SeriesManager = (function() {
getFilteredSeriesData: getFilteredSeriesData,
findByKey: findByKey,
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
updateSingleSeries: updateSingleSeries
updateSingleSeries: updateSingleSeries,
updateSeriesKey: updateSeriesKey
};
})();

View File

@@ -640,6 +640,80 @@
</div>
</div>
<!-- Edit Metadata Modal -->
<div id="edit-metadata-modal" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-content edit-modal-content">
<div class="modal-header">
<h3>Edit Metadata</h3>
<button id="btn-cancel-metadata" class="btn btn-icon">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<form id="edit-metadata-form" onsubmit="return false;">
<!-- Identity Section -->
<div class="edit-section">
<h4><i class="fa-solid fa-key"></i> Identity</h4>
<div class="form-group">
<label for="edit-key">Series Key</label>
<input type="text" id="edit-key" class="input-field"
placeholder="e.g. attack-on-titan"
pattern="[a-z0-9][a-z0-9-]*[a-z0-9]">
<span class="field-error" style="display:none;"></span>
</div>
<div id="key-change-warning" class="key-warning" style="display:none;">
<i class="fa-solid fa-triangle-exclamation"></i>
Changing the key will update the primary identifier. This may affect provider linkage.
</div>
</div>
<!-- External IDs Section -->
<div class="edit-section">
<h4><i class="fa-solid fa-database"></i> External IDs</h4>
<div class="form-row">
<div class="form-group">
<label for="edit-tmdb-id">TMDB ID</label>
<input type="number" id="edit-tmdb-id" class="input-field"
placeholder="e.g. 1429" min="1">
<span class="field-error" style="display:none;"></span>
</div>
<div class="form-group">
<label for="edit-tvdb-id">TVDB ID</label>
<input type="number" id="edit-tvdb-id" class="input-field"
placeholder="e.g. 267440" min="1">
<span class="field-error" style="display:none;"></span>
</div>
</div>
</div>
<!-- NFO Status Section -->
<div class="edit-section">
<h4><i class="fa-solid fa-file-lines"></i> NFO Status</h4>
<div class="nfo-diagnostics">
<div id="nfo-status-badge" class="nfo-status-badge">Loading...</div>
<div id="nfo-diagnostics-container">
<div id="nfo-missing-tags" class="missing-tags-list"></div>
</div>
<p id="repair-hint" class="repair-hint" style="display:none;">
<i class="fa-solid fa-circle-info"></i>
No TMDB ID set. Repair will search TMDB by series name.
</p>
<button type="button" id="btn-repair-nfo" class="btn btn-secondary btn-repair">
<i class="fa-solid fa-wrench"></i> Repair NFO
</button>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" id="btn-save-metadata" class="btn btn-primary">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
</div>
</div>
</div>
<!-- Toast notifications -->
<div id="toast-container" class="toast-container"></div>
</div>
@@ -665,6 +739,8 @@
<script src="/static/js/user_preferences.js?v={{ static_v }}"></script>
<!-- Index Page Modules -->
<script src="/static/js/index/context-menu.js?v={{ static_v }}"></script>
<script src="/static/js/index/edit-modal.js?v={{ static_v }}"></script>
<script src="/static/js/index/series-manager.js?v={{ static_v }}"></script>
<script src="/static/js/index/selection-manager.js?v={{ static_v }}"></script>
<script src="/static/js/index/search.js?v={{ static_v }}"></script>

View File

@@ -0,0 +1,255 @@
"""Tests for anime metadata edit (PUT /api/anime/{anime_key}) endpoint."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from src.server.fastapi_app import app
from src.server.services.auth_service import auth_service
@pytest.fixture
def reset_auth():
"""Reset auth state before each test."""
auth_service._hash = None
auth_service._failed = {}
@pytest.fixture
async def client():
"""Create async test client."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
async def authenticated_client(client):
"""Get authenticated client with Bearer token."""
# Setup auth
await client.post("/api/auth/setup", json={"master_password": "TestPass123!"})
response = await client.post(
"/api/auth/login", json={"password": "TestPass123!"}
)
token = response.json()["access_token"]
client.headers["Authorization"] = f"Bearer {token}"
return client
@pytest.fixture
def mock_db_session():
"""Create a mock async database session."""
session = AsyncMock()
session.commit = AsyncMock()
session.flush = AsyncMock()
session.refresh = AsyncMock()
return session
@pytest.fixture
def mock_series_in_db():
"""Create a mock AnimeSeries DB record."""
series = MagicMock()
series.id = 1
series.key = "test-anime"
series.name = "Test Anime"
series.tmdb_id = 1234
series.tvdb_id = 5678
series.folder = "Test Anime (2023)"
return series
@pytest.fixture
def override_db_dependency(mock_db_session):
"""Override database session dependency."""
from src.server.utils.dependencies import get_database_session
app.dependency_overrides[get_database_session] = lambda: mock_db_session
yield mock_db_session
app.dependency_overrides.pop(get_database_session, None)
class TestUpdateAnimeMetadata:
"""Tests for PUT /api/anime/{anime_key}."""
@pytest.mark.asyncio
async def test_update_tmdb_id_success(
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
):
"""Test successful tmdb_id update."""
with patch(
"src.server.api.anime.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series_in_db,
), patch(
"src.server.api.anime.AnimeSeriesService.update",
new_callable=AsyncMock,
) as mock_update:
mock_series_in_db.tmdb_id = 9999
mock_update.return_value = mock_series_in_db
response = await authenticated_client.put(
"/api/anime/test-anime",
json={"tmdb_id": 9999},
)
assert response.status_code == 200
data = response.json()
assert data["tmdb_id"] == 9999
assert data["message"] == "Metadata updated successfully"
@pytest.mark.asyncio
async def test_update_tvdb_id_success(
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
):
"""Test successful tvdb_id update."""
with patch(
"src.server.api.anime.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series_in_db,
), patch(
"src.server.api.anime.AnimeSeriesService.update",
new_callable=AsyncMock,
) as mock_update:
mock_series_in_db.tvdb_id = 7777
mock_update.return_value = mock_series_in_db
response = await authenticated_client.put(
"/api/anime/test-anime",
json={"tvdb_id": 7777},
)
assert response.status_code == 200
data = response.json()
assert data["tvdb_id"] == 7777
@pytest.mark.asyncio
async def test_update_key_success(
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
):
"""Test successful key rename."""
with patch(
"src.server.api.anime.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
) as mock_get:
# First call finds the series, second call checks uniqueness (returns None)
mock_get.side_effect = [mock_series_in_db, None]
mock_series_in_db.key = "new-anime-key"
with patch(
"src.server.api.anime.AnimeSeriesService.update",
new_callable=AsyncMock,
return_value=mock_series_in_db,
):
response = await authenticated_client.put(
"/api/anime/test-anime",
json={"key": "new-anime-key"},
)
assert response.status_code == 200
data = response.json()
assert data["key"] == "new-anime-key"
@pytest.mark.asyncio
async def test_update_key_conflict_409(
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
):
"""Test key rename conflict returns 409."""
existing_series = MagicMock()
existing_series.key = "existing-key"
with patch(
"src.server.api.anime.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
) as mock_get:
# First call finds original series, second call finds conflict
mock_get.side_effect = [mock_series_in_db, existing_series]
response = await authenticated_client.put(
"/api/anime/test-anime",
json={"key": "existing-key"},
)
assert response.status_code == 409
assert "already exists" in response.json()["detail"]
@pytest.mark.asyncio
async def test_update_key_invalid_chars_422(
self, reset_auth, authenticated_client, override_db_dependency
):
"""Test key with invalid characters returns 422."""
response = await authenticated_client.put(
"/api/anime/test-anime",
json={"key": "Invalid Key With Spaces!"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_update_key_empty_422(
self, reset_auth, authenticated_client, override_db_dependency
):
"""Test empty key returns 422."""
response = await authenticated_client.put(
"/api/anime/test-anime",
json={"key": ""},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_update_unauthenticated_401(self, reset_auth, client):
"""Test unauthenticated access returns 401."""
response = await client.put(
"/api/anime/test-anime",
json={"tmdb_id": 1234},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_update_nonexistent_anime_404(
self, reset_auth, authenticated_client, override_db_dependency
):
"""Test update of non-existent anime returns 404."""
with patch(
"src.server.api.anime.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=None,
):
response = await authenticated_client.put(
"/api/anime/nonexistent-key",
json={"tmdb_id": 1234},
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_update_no_changes(
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
):
"""Test sending empty body returns no-op response."""
with patch(
"src.server.api.anime.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series_in_db,
):
response = await authenticated_client.put(
"/api/anime/test-anime",
json={},
)
assert response.status_code == 200
assert response.json()["message"] == "No changes"
@pytest.mark.asyncio
async def test_update_negative_tmdb_id_422(
self, reset_auth, authenticated_client, override_db_dependency
):
"""Test negative TMDB ID returns 422."""
response = await authenticated_client.put(
"/api/anime/test-anime",
json={"tmdb_id": -5},
)
assert response.status_code == 422

View File

@@ -0,0 +1,317 @@
"""Tests for NFO diagnostics and repair API endpoints."""
from unittest.mock import AsyncMock, Mock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from src.server.fastapi_app import app
from src.server.services.auth_service import auth_service
@pytest.fixture(autouse=True)
def reset_auth():
"""Reset authentication state before each test."""
original_hash = auth_service._hash
auth_service._hash = None
auth_service._failed.clear()
yield
auth_service._hash = original_hash
auth_service._failed.clear()
@pytest.fixture
async def client():
"""Create an async test client."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
async def authenticated_client(client):
"""Create an authenticated test client with token."""
await client.post(
"/api/auth/setup",
json={"master_password": "TestPassword123!"}
)
response = await client.post(
"/api/auth/login",
json={"password": "TestPassword123!"}
)
token = response.json()["access_token"]
client.headers.update({"Authorization": f"Bearer {token}"})
yield client
@pytest.fixture
def mock_series_app():
"""Create mock series app with one test series."""
app_mock = Mock()
serie = Mock()
serie.key = "test-anime"
serie.folder = "Test Anime (2024)"
serie.name = "Test Anime"
serie.ensure_folder_with_year = Mock(return_value="Test Anime (2024)")
list_manager = Mock()
list_manager.GetList = Mock(return_value=[serie])
app_mock.list = list_manager
return app_mock
@pytest.fixture
def mock_nfo_service():
"""Create mock NFO service."""
service = Mock()
service.check_nfo_exists = AsyncMock(return_value=False)
service.create_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
service.update_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
return service
@pytest.fixture
def override_dependencies(mock_series_app, mock_nfo_service):
"""Override dependencies for NFO tests."""
from src.server.api.nfo import get_nfo_service
from src.server.utils.dependencies import get_series_app
app.dependency_overrides[get_series_app] = lambda: mock_series_app
app.dependency_overrides[get_nfo_service] = lambda: mock_nfo_service
yield
if get_series_app in app.dependency_overrides:
del app.dependency_overrides[get_series_app]
if get_nfo_service in app.dependency_overrides:
del app.dependency_overrides[get_nfo_service]
class TestNfoDiagnostics:
"""Tests for GET /api/nfo/{serie_key}/diagnostics."""
@pytest.mark.asyncio
async def test_diagnostics_complete_nfo(
self, authenticated_client, override_dependencies
):
"""Test diagnostics with complete NFO returns no missing tags."""
with patch(
"src.server.api.nfo.Path.exists", return_value=True
), patch(
"src.server.api.nfo.find_missing_tags", return_value=[]
):
response = await authenticated_client.get(
"/api/nfo/test-anime/diagnostics"
)
assert response.status_code == 200
data = response.json()
assert data["has_nfo"] is True
assert data["missing_tags"] == []
assert len(data["required_tags"]) > 0
@pytest.mark.asyncio
async def test_diagnostics_missing_tags(
self, authenticated_client, override_dependencies
):
"""Test diagnostics with missing tags returns them."""
with patch(
"src.server.api.nfo.Path.exists", return_value=True
), patch(
"src.server.api.nfo.find_missing_tags",
return_value=["plot", "genre", "actor/name"],
):
response = await authenticated_client.get(
"/api/nfo/test-anime/diagnostics"
)
assert response.status_code == 200
data = response.json()
assert data["has_nfo"] is True
assert "plot" in data["missing_tags"]
assert "genre" in data["missing_tags"]
assert len(data["missing_tags"]) == 3
@pytest.mark.asyncio
async def test_diagnostics_no_nfo_file(
self, authenticated_client, override_dependencies
):
"""Test diagnostics when no NFO exists returns all tags as missing."""
with patch("src.server.api.nfo.Path") as MockPath:
# Make nfo_path.exists() return False
mock_path_instance = Mock()
mock_path_instance.exists.return_value = False
mock_path_instance.__truediv__ = Mock(return_value=mock_path_instance)
MockPath.return_value = mock_path_instance
response = await authenticated_client.get(
"/api/nfo/test-anime/diagnostics"
)
assert response.status_code == 200
data = response.json()
assert data["has_nfo"] is False
assert len(data["missing_tags"]) > 0
# All required tags should be listed as missing
assert data["missing_tags"] == data["required_tags"]
@pytest.mark.asyncio
async def test_diagnostics_nonexistent_series_404(
self, authenticated_client, override_dependencies, mock_series_app
):
"""Test diagnostics for non-existent series returns 404."""
# Override to return empty list
mock_series_app.list.GetList.return_value = []
response = await authenticated_client.get(
"/api/nfo/nonexistent-key/diagnostics"
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_diagnostics_unauthenticated_401(self, client):
"""Test diagnostics requires authentication."""
response = await client.get("/api/nfo/test-anime/diagnostics")
# May return 401 or 503 depending on NFO service availability
assert response.status_code in (401, 503)
class TestNfoRepair:
"""Tests for POST /api/nfo/{serie_key}/repair."""
@pytest.mark.asyncio
async def test_repair_success(
self, authenticated_client, override_dependencies
):
"""Test successful NFO repair."""
with patch("src.server.api.nfo.Path") as MockPath:
mock_path = Mock()
mock_path.exists.return_value = True
mock_path.__truediv__ = Mock(return_value=mock_path)
MockPath.return_value = mock_path
with patch(
"src.server.api.nfo.find_missing_tags",
return_value=["plot", "genre"],
), patch(
"src.server.api.nfo.NfoRepairService"
) as MockRepairService:
mock_instance = Mock()
mock_instance.repair_series = AsyncMock(return_value=True)
MockRepairService.return_value = mock_instance
response = await authenticated_client.post(
"/api/nfo/test-anime/repair", json={}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "2" in data["message"] # "Fixed 2 missing tags"
assert "plot" in data["repaired_tags"]
assert "genre" in data["repaired_tags"]
@pytest.mark.asyncio
async def test_repair_already_complete(
self, authenticated_client, override_dependencies
):
"""Test repair when NFO is already complete."""
with patch("src.server.api.nfo.Path") as MockPath:
mock_path = Mock()
mock_path.exists.return_value = True
mock_path.__truediv__ = Mock(return_value=mock_path)
MockPath.return_value = mock_path
with patch(
"src.server.api.nfo.find_missing_tags", return_value=[]
), patch(
"src.server.api.nfo.NfoRepairService"
) as MockRepairService:
mock_instance = Mock()
mock_instance.repair_series = AsyncMock(return_value=False)
MockRepairService.return_value = mock_instance
response = await authenticated_client.post(
"/api/nfo/test-anime/repair", json={}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "already complete" in data["message"]
@pytest.mark.asyncio
async def test_repair_creates_new_nfo(
self, authenticated_client, override_dependencies, mock_nfo_service
):
"""Test repair when no NFO exists creates a new one."""
with patch("src.server.api.nfo.Path") as MockPath:
mock_path = Mock()
mock_path.exists.return_value = False
mock_path.__truediv__ = Mock(return_value=mock_path)
MockPath.return_value = mock_path
with patch(
"src.server.api.nfo.REQUIRED_TAGS",
{"./title": "title", "./plot": "plot"},
):
response = await authenticated_client.post(
"/api/nfo/test-anime/repair", json={}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
mock_nfo_service.create_tvshow_nfo.assert_awaited_once()
@pytest.mark.asyncio
async def test_repair_nonexistent_series_404(
self, authenticated_client, override_dependencies, mock_series_app
):
"""Test repair for non-existent series returns 404."""
mock_series_app.list.GetList.return_value = []
response = await authenticated_client.post(
"/api/nfo/nonexistent-key/repair", json={}
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_repair_unauthenticated_401(self, client):
"""Test repair requires authentication."""
response = await client.post("/api/nfo/test-anime/repair", json={})
assert response.status_code in (401, 503)
@pytest.mark.asyncio
async def test_repair_tmdb_api_failure(
self, authenticated_client, override_dependencies
):
"""Test repair handles TMDB API failure gracefully."""
from src.core.services.tmdb_client import TMDBAPIError
with patch("src.server.api.nfo.Path") as MockPath:
mock_path = Mock()
mock_path.exists.return_value = True
mock_path.__truediv__ = Mock(return_value=mock_path)
MockPath.return_value = mock_path
with patch(
"src.server.api.nfo.find_missing_tags",
return_value=["plot"],
), patch(
"src.server.api.nfo.NfoRepairService"
) as MockRepairService:
mock_instance = Mock()
mock_instance.repair_series = AsyncMock(
side_effect=TMDBAPIError("No TMDB ID found")
)
MockRepairService.return_value = mock_instance
response = await authenticated_client.post(
"/api/nfo/test-anime/repair", json={}
)
assert response.status_code == 400
assert "Cannot repair NFO" in response.json()["detail"]

View File

@@ -0,0 +1,115 @@
"""Frontend tests for the edit metadata modal HTML structure."""
from unittest.mock import AsyncMock, Mock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from src.server.fastapi_app import app
from src.server.services.auth_service import auth_service
@pytest.fixture(autouse=True)
def reset_auth():
"""Reset authentication state before each test."""
original_hash = auth_service._hash
auth_service._hash = None
auth_service._failed.clear()
yield
auth_service._hash = original_hash
auth_service._failed.clear()
@pytest.fixture
async def client():
"""Create an async test client."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
async def authenticated_client(client):
"""Create authenticated client to access index page."""
await client.post(
"/api/auth/setup",
json={"master_password": "TestPassword123!"}
)
response = await client.post(
"/api/auth/login",
json={"password": "TestPassword123!"}
)
token = response.json()["access_token"]
client.headers.update({"Authorization": f"Bearer {token}"})
# Set cookie for page access
client.cookies.set("access_token", token)
yield client
class TestEditModalHtmlPresence:
"""Tests verifying edit modal HTML elements exist in index page."""
@pytest.mark.asyncio
async def test_index_page_contains_edit_modal(self, authenticated_client):
"""Verify #edit-metadata-modal exists in rendered index page."""
response = await authenticated_client.get("/")
# Page may redirect or require different auth for HTML pages
if response.status_code == 200:
html = response.text
assert 'id="edit-metadata-modal"' in html
@pytest.mark.asyncio
async def test_index_page_loads_context_menu_script(self, authenticated_client):
"""Verify context-menu.js script tag is present."""
response = await authenticated_client.get("/")
if response.status_code == 200:
html = response.text
assert "context-menu.js" in html
@pytest.mark.asyncio
async def test_index_page_loads_edit_modal_script(self, authenticated_client):
"""Verify edit-modal.js script tag is present."""
response = await authenticated_client.get("/")
if response.status_code == 200:
html = response.text
assert "edit-modal.js" in html
@pytest.mark.asyncio
async def test_modal_form_fields_present(self, authenticated_client):
"""Verify key, tmdb_id, tvdb_id input fields exist in modal."""
response = await authenticated_client.get("/")
if response.status_code == 200:
html = response.text
assert 'id="edit-key"' in html
assert 'id="edit-tmdb-id"' in html
assert 'id="edit-tvdb-id"' in html
@pytest.mark.asyncio
async def test_nfo_repair_button_present(self, authenticated_client):
"""Verify repair NFO button exists in modal."""
response = await authenticated_client.get("/")
if response.status_code == 200:
html = response.text
assert 'id="btn-repair-nfo"' in html
@pytest.mark.asyncio
async def test_save_button_present(self, authenticated_client):
"""Verify save button exists in modal."""
response = await authenticated_client.get("/")
if response.status_code == 200:
html = response.text
assert 'id="btn-save-metadata"' in html
@pytest.mark.asyncio
async def test_modal_starts_hidden(self, authenticated_client):
"""Verify modal has hidden class by default."""
response = await authenticated_client.get("/")
if response.status_code == 200:
html = response.text
assert 'id="edit-metadata-modal" class="modal hidden"' in html

View File

@@ -0,0 +1,161 @@
"""Unit tests for anime key rename logic and validation."""
import pytest
from pydantic import ValidationError
from src.server.models.anime import AnimeMetadataUpdate, KEY_PATTERN
class TestKeyValidation:
"""Tests for AnimeMetadataUpdate key validation."""
def test_valid_key_simple(self):
"""Test simple valid key."""
model = AnimeMetadataUpdate(key="attack-on-titan")
assert model.key == "attack-on-titan"
def test_valid_key_single_char(self):
"""Test single character key is valid."""
model = AnimeMetadataUpdate(key="a")
assert model.key == "a"
def test_valid_key_numbers(self):
"""Test key with numbers."""
model = AnimeMetadataUpdate(key="86-eighty-six")
assert model.key == "86-eighty-six"
def test_valid_key_allows_hyphens(self):
"""Test hyphens in key are allowed."""
model = AnimeMetadataUpdate(key="my-anime-key")
assert model.key == "my-anime-key"
def test_valid_key_normalizes_to_lowercase(self):
"""Test key is normalized to lowercase."""
model = AnimeMetadataUpdate(key="Attack-On-Titan")
assert model.key == "attack-on-titan"
def test_valid_key_strips_whitespace(self):
"""Test key strips leading/trailing whitespace."""
model = AnimeMetadataUpdate(key=" my-key ")
assert model.key == "my-key"
def test_invalid_key_spaces(self):
"""Test key with spaces is rejected."""
with pytest.raises(ValidationError) as exc_info:
AnimeMetadataUpdate(key="my anime key")
assert "Key must contain only" in str(exc_info.value)
def test_invalid_key_uppercase_special(self):
"""Test key with special characters is rejected."""
with pytest.raises(ValidationError) as exc_info:
AnimeMetadataUpdate(key="anime!@#key")
assert "Key must contain only" in str(exc_info.value)
def test_invalid_key_empty(self):
"""Test empty key is rejected."""
with pytest.raises(ValidationError) as exc_info:
AnimeMetadataUpdate(key="")
assert "cannot be empty" in str(exc_info.value)
def test_invalid_key_only_whitespace(self):
"""Test whitespace-only key is rejected."""
with pytest.raises(ValidationError) as exc_info:
AnimeMetadataUpdate(key=" ")
assert "cannot be empty" in str(exc_info.value)
def test_invalid_key_starts_with_hyphen(self):
"""Test key starting with hyphen is rejected."""
with pytest.raises(ValidationError):
AnimeMetadataUpdate(key="-my-key")
def test_invalid_key_ends_with_hyphen(self):
"""Test key ending with hyphen is rejected."""
with pytest.raises(ValidationError):
AnimeMetadataUpdate(key="my-key-")
def test_key_none_is_allowed(self):
"""Test None key (no change requested) is allowed."""
model = AnimeMetadataUpdate(key=None)
assert model.key is None
def test_key_omitted_is_allowed(self):
"""Test omitting key entirely is allowed."""
model = AnimeMetadataUpdate(tmdb_id=1234)
assert model.key is None
class TestTmdbIdValidation:
"""Tests for tmdb_id validation."""
def test_valid_tmdb_id(self):
"""Test valid positive TMDB ID."""
model = AnimeMetadataUpdate(tmdb_id=1429)
assert model.tmdb_id == 1429
def test_tmdb_id_none(self):
"""Test None tmdb_id is allowed."""
model = AnimeMetadataUpdate(tmdb_id=None)
assert model.tmdb_id is None
def test_tmdb_id_negative_rejected(self):
"""Test negative tmdb_id is rejected."""
with pytest.raises(ValidationError):
AnimeMetadataUpdate(tmdb_id=-1)
def test_tmdb_id_zero_rejected(self):
"""Test zero tmdb_id is rejected."""
with pytest.raises(ValidationError):
AnimeMetadataUpdate(tmdb_id=0)
class TestTvdbIdValidation:
"""Tests for tvdb_id validation."""
def test_valid_tvdb_id(self):
"""Test valid positive TVDB ID."""
model = AnimeMetadataUpdate(tvdb_id=267440)
assert model.tvdb_id == 267440
def test_tvdb_id_none(self):
"""Test None tvdb_id is allowed."""
model = AnimeMetadataUpdate(tvdb_id=None)
assert model.tvdb_id is None
def test_tvdb_id_negative_rejected(self):
"""Test negative tvdb_id is rejected."""
with pytest.raises(ValidationError):
AnimeMetadataUpdate(tvdb_id=-5)
def test_tvdb_id_zero_rejected(self):
"""Test zero tvdb_id is rejected."""
with pytest.raises(ValidationError):
AnimeMetadataUpdate(tvdb_id=0)
class TestKeyPattern:
"""Tests for the KEY_PATTERN regex directly."""
@pytest.mark.parametrize("key", [
"a",
"abc",
"attack-on-titan",
"86-eighty-six",
"a1b2c3",
"x",
"1",
])
def test_valid_patterns(self, key):
"""Test keys that should match the pattern."""
assert KEY_PATTERN.match(key) is not None
@pytest.mark.parametrize("key", [
"-start",
"end-",
"has space",
"UPPER",
"special!char",
"under_score",
"",
])
def test_invalid_patterns(self, key):
"""Test keys that should not match the pattern."""
assert KEY_PATTERN.match(key) is None

View File

@@ -95,6 +95,37 @@ class TestConfigServiceLoadSave:
assert loaded_config.logging.level == sample_config.logging.level
assert loaded_config.other == sample_config.other
def test_save_and_load_scheduler_flags_roundtrip(self, config_service):
"""Scheduler auto_download_after_rescan and folder_scan_enabled must
survive a full save/load roundtrip through ConfigService.
Regression test for a bug where null legacy alias fields
(auto_download=None, folder_scan=None) were written to config.json
on save. On reload the alias mapping was skipped (because the keys
were present), causing the primary boolean fields to reset to False.
"""
original = AppConfig(
scheduler=SchedulerConfig(
enabled=True,
auto_download_after_rescan=True,
folder_scan_enabled=True,
)
)
config_service.save_config(original, create_backup=False)
# Verify raw JSON does not contain legacy alias keys
with open(config_service.config_path, "r", encoding="utf-8") as f:
raw = json.load(f)
assert "auto_download" not in raw["scheduler"]
assert "folder_scan" not in raw["scheduler"]
assert raw["scheduler"]["auto_download_after_rescan"] is True
assert raw["scheduler"]["folder_scan_enabled"] is True
# Verify loaded config preserves values
loaded = config_service.load_config()
assert loaded.scheduler.auto_download_after_rescan is True
assert loaded.scheduler.folder_scan_enabled is True
def test_save_includes_version(self, config_service, sample_config):
"""Test that saved config includes version field."""
config_service.save_config(sample_config, create_backup=False)

View File

@@ -0,0 +1,222 @@
"""Tests for folder ignore patterns feature."""
import os
import tempfile
import warnings
from unittest.mock import patch
import pytest
from src.config.settings import Settings
class TestShouldIgnoreFolder:
"""Test should_ignore_folder method."""
def test_ignore_pattern_matches_exact(self):
"""Test exact folder name match."""
settings = Settings()
assert settings.should_ignore_folder("The Last of Us") is True
def test_ignore_pattern_matches_case_insensitive(self):
"""Test case-insensitive matching."""
settings = Settings()
assert settings.should_ignore_folder("the last of us") is True
assert settings.should_ignore_folder("THE LAST OF US") is True
def test_ignore_pattern_partial_match(self):
"""Test partial folder name match."""
settings = Settings()
assert settings.should_ignore_folder("Loki Season 2") is True
assert settings.should_ignore_folder("Chernobyl Complete") is True
def test_non_matching_folder_returns_false(self):
"""Test non-matching folder passes through."""
settings = Settings()
assert settings.should_ignore_folder("Attack on Titan") is False
assert settings.should_ignore_folder("Naruto") is False
def test_empty_folder_returns_false(self):
"""Test empty folder name."""
settings = Settings()
assert settings.should_ignore_folder("") is False
def test_custom_patterns_via_env_var(self, monkeypatch):
"""Test custom ignore patterns via environment variable."""
monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "MyShow|AnotherShow")
settings = Settings()
assert settings.should_ignore_folder("MyShow") is True
assert settings.should_ignore_folder("AnotherShow") is True
assert settings.should_ignore_folder("OtherShow") is False
def test_custom_patterns_case_insensitive_via_env_var(self, monkeypatch):
"""Test custom patterns respect case-insensitivity via env var."""
monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "myshow")
settings = Settings()
assert settings.should_ignore_folder("MyShow") is True
assert settings.should_ignore_folder("MYSHOW") is True
class TestFolderIgnorePatternsProperty:
"""Test folder_ignore_patterns property."""
def test_default_patterns_parsed(self):
"""Test default patterns are parsed correctly."""
settings = Settings()
patterns = settings.folder_ignore_patterns
assert len(patterns) > 0
assert "The Last of Us" in patterns
assert "Loki" in patterns
def test_empty_string_via_env_var_returns_empty_list(self, monkeypatch):
"""Test empty patterns string via env var."""
monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "")
settings = Settings()
patterns = settings.folder_ignore_patterns
assert patterns == []
def test_single_pattern_via_env_var(self, monkeypatch):
"""Test single pattern via env var."""
monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "TestShow")
settings = Settings()
patterns = settings.folder_ignore_patterns
# Single pattern in pipe-separated string
assert "TestShow" in patterns
def test_pipe_separated_patterns_via_env_var(self, monkeypatch):
"""Test pipe-separated patterns via env var."""
monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "Show1|Show2|Show3")
settings = Settings()
patterns = settings.folder_ignore_patterns
assert len(patterns) == 3
assert "Show1" in patterns
assert "Show2" in patterns
assert "Show3" in patterns
def test_pattern_with_spaces_trimmed_via_env_var(self, monkeypatch):
"""Test patterns with spaces are trimmed."""
monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "Show1 | Show2 | Show3 ")
settings = Settings()
patterns = settings.folder_ignore_patterns
# All patterns should be trimmed of whitespace
for p in patterns:
assert p == p.strip()
class TestSerieScannerIgnorePatterns:
"""Test SerieScanner respects ignore patterns."""
def test_scanner_skips_ignored_folders(self, tmp_path):
"""Test scanner skips folders matching ignore patterns."""
from src.core.SerieScanner import SerieScanner
from src.core.providers.aniworld_provider import AniworldLoader
# Create test folders
ignored_folder = tmp_path / "The Last of Us"
ignored_folder.mkdir()
(ignored_folder / "S01E01.mp4").touch()
normal_folder = tmp_path / "Attack on Titan"
normal_folder.mkdir()
(normal_folder / "S01E01.mp4").touch()
loader = AniworldLoader()
scanner = SerieScanner(str(tmp_path), loader)
# Get MP4 files - should only find Attack on Titan
mp4_files = list(scanner._SerieScanner__find_mp4_files())
folder_names = [name for name, _ in mp4_files]
assert "Attack on Titan" in folder_names
assert "The Last of Us" not in folder_names
def test_scanner_normal_folders_not_ignored(self, tmp_path):
"""Test normal folders are not skipped."""
from src.core.SerieScanner import SerieScanner
from src.core.providers.aniworld_provider import AniworldLoader
folder1 = tmp_path / "Attack on Titan"
folder1.mkdir()
(folder1 / "S01E01.mp4").touch()
folder2 = tmp_path / "Naruto"
folder2.mkdir()
(folder2 / "S01E01.mp4").touch()
loader = AniworldLoader()
scanner = SerieScanner(str(tmp_path), loader)
mp4_files = list(scanner._SerieScanner__find_mp4_files())
folder_names = [name for name, _ in mp4_files]
assert "Attack on Titan" in folder_names
assert "Naruto" in folder_names
def test_scanner_respects_default_ignore_patterns(self, tmp_path):
"""Test scanner respects default ignore patterns."""
from src.core.SerieScanner import SerieScanner
from src.core.providers.aniworld_provider import AniworldLoader
# Create folder matching default ignore pattern (Chernobyl)
ignored_folder = tmp_path / "Chernobyl Complete Series"
ignored_folder.mkdir()
(ignored_folder / "S01E01.mp4").touch()
normal_folder = tmp_path / "Normal Anime"
normal_folder.mkdir()
(normal_folder / "S01E01.mp4").touch()
loader = AniworldLoader()
scanner = SerieScanner(str(tmp_path), loader)
mp4_files = list(scanner._SerieScanner__find_mp4_files())
folder_names = [name for name, _ in mp4_files]
assert "Normal Anime" in folder_names
assert "Chernobyl Complete Series" not in folder_names
class TestSerieListIgnorePatterns:
"""Test SerieList respects ignore patterns."""
def test_load_series_skips_ignored_folders(self, tmp_path):
"""Test load_series skips folders matching ignore patterns."""
from src.core.entities.SerieList import SerieList
from src.core.entities.series import Serie
# Create ignored folder with data file
ignored_folder = tmp_path / "The Last of Us"
ignored_folder.mkdir()
ignored_data = ignored_folder / "data"
ignored_serie = Serie(
key="the-last-of-us",
name="The Last of Us",
site="https://aniworld.to/anime/stream/the-last-of-us",
folder="The Last of Us",
episodeDict={1: [1, 2, 3]}
)
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
ignored_serie.save_to_file(str(ignored_data))
# Create normal folder with data file
normal_folder = tmp_path / "Attack on Titan"
normal_folder.mkdir()
normal_data = normal_folder / "data"
normal_serie = Serie(
key="attack-on-titan",
name="Attack on Titan",
site="https://aniworld.to/anime/stream/attack-on-titan",
folder="Attack on Titan",
episodeDict={1: [1, 2]}
)
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
normal_serie.save_to_file(str(normal_data))
# Load series
serie_list = SerieList(str(tmp_path))
# Verify ignored folder was skipped
assert serie_list.contains("attack-on-titan") is True
assert serie_list.contains("the-last-of-us") is False

View File

@@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.server.services.folder_rename_service import (
_cleanup_orphaned_folder,
_compute_expected_folder_name,
_is_series_being_downloaded,
_parse_nfo_title_and_year,
@@ -278,6 +279,71 @@ class TestUpdateDatabasePaths:
assert mock_episode.file_path == str(new_path)
class TestCleanupOrphanedFolder:
"""Tests for _cleanup_orphaned_folder."""
def test_returns_false_when_old_folder_does_not_exist(self, tmp_path: Path) -> None:
old_path = tmp_path / "nonexistent"
new_path = tmp_path / "new"
result = _cleanup_orphaned_folder(old_path, new_path)
assert result is False
def test_deletes_empty_folder(self, tmp_path: Path) -> None:
old_path = tmp_path / "empty_orphan"
old_path.mkdir()
new_path = tmp_path / "new"
new_path.mkdir()
result = _cleanup_orphaned_folder(old_path, new_path)
assert result is True
assert not old_path.exists()
def test_moves_files_and_deletes_folder(self, tmp_path: Path) -> None:
old_path = tmp_path / "old_orphan"
old_path.mkdir()
new_path = tmp_path / "new"
new_path.mkdir()
file1 = old_path / "S01E01.mkv"
file1.write_text("episode 1")
file2 = old_path / "S01E02.mkv"
file2.write_text("episode 2")
result = _cleanup_orphaned_folder(old_path, new_path)
assert result is True
assert not old_path.exists()
assert (new_path / "S01E01.mkv").exists()
assert (new_path / "S01E02.mkv").exists()
def test_dry_run_does_not_delete_empty_folder(self, tmp_path: Path) -> None:
old_path = tmp_path / "empty_orphan"
old_path.mkdir()
new_path = tmp_path / "new"
new_path.mkdir()
result = _cleanup_orphaned_folder(old_path, new_path, dry_run=True)
assert result is True
assert old_path.exists()
def test_dry_run_does_not_move_files(self, tmp_path: Path) -> None:
old_path = tmp_path / "old_orphan"
old_path.mkdir()
new_path = tmp_path / "new"
new_path.mkdir()
file1 = old_path / "S01E01.mkv"
file1.write_text("episode 1")
result = _cleanup_orphaned_folder(old_path, new_path, dry_run=True)
assert result is True
assert old_path.exists()
assert not (new_path / "S01E01.mkv").exists()
def test_handles_permission_error_gracefully(self, tmp_path: Path) -> None:
old_path = tmp_path / "permission_denied"
old_path.mkdir()
new_path = tmp_path / "new"
new_path.mkdir()
# Simulate permission error by patching rmdir
with patch.object(Path, "rmdir", side_effect=PermissionError("Access denied")):
result = _cleanup_orphaned_folder(old_path, new_path)
assert result is False
class TestValidateAndRenameSeriesFolders:
"""Integration-style tests for validate_and_rename_series_folders."""
@@ -389,7 +455,8 @@ class TestValidateAndRenameSeriesFolders:
assert series_dir.is_dir()
@pytest.mark.asyncio
async def test_errors_when_target_exists(self, tmp_path: Path) -> None:
async def test_duplicate_target_folder_source_removed_and_db_deleted(self, tmp_path: Path) -> None:
"""When target folder exists, source folder should be removed and its DB record deleted."""
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan"
@@ -398,7 +465,13 @@ class TestValidateAndRenameSeriesFolders:
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
# Pre-create the target folder to simulate a duplicate
(anime_dir / "Attack on Titan (2013)").mkdir()
target_dir = anime_dir / "Attack on Titan (2013)"
target_dir.mkdir()
mock_db = AsyncMock()
mock_session = AsyncMock()
mock_db.__aenter__.return_value = mock_session
mock_db.__aexit__.return_value = None
with patch(
"src.server.services.folder_rename_service.settings.anime_directory",
@@ -406,14 +479,28 @@ class TestValidateAndRenameSeriesFolders:
), patch(
"src.server.services.folder_rename_service._is_series_being_downloaded",
return_value=False,
), patch(
"src.server.services.folder_rename_service.get_db_session",
return_value=mock_db,
), patch(
"src.server.services.folder_rename_service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=None,
), patch(
"src.server.services.folder_rename_service.AnimeSeriesService.get_all",
new_callable=AsyncMock,
return_value=[],
):
stats = await validate_and_rename_series_folders()
# Source folder removed, target survives
assert not series_dir.exists()
assert target_dir.is_dir()
# Duplicate resolved: counts as renamed (source removed, target kept)
assert stats["scanned"] == 1
assert stats["renamed"] == 0
assert stats["renamed"] == 1
assert stats["skipped"] == 0
assert stats["errors"] == 1
assert series_dir.is_dir()
assert stats["errors"] == 0
@pytest.mark.asyncio
async def test_counts_multiple_folders(self, tmp_path: Path) -> None:
@@ -459,3 +546,30 @@ class TestValidateAndRenameSeriesFolders:
assert (anime_dir / "Show A (2020)").is_dir()
assert d2.is_dir()
assert d3.is_dir()
@pytest.mark.asyncio
async def test_dry_run_does_not_rename_folders(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
with patch(
"src.server.services.folder_rename_service.settings.anime_directory",
str(anime_dir),
), patch(
"src.server.services.folder_rename_service._is_series_being_downloaded",
return_value=False,
):
stats = await validate_and_rename_series_folders(dry_run=True)
assert stats["scanned"] == 1
assert stats["renamed"] == 1
assert stats["skipped"] == 0
assert stats["errors"] == 0
# Original folder should still exist (not renamed in dry-run)
assert series_dir.is_dir()
assert not (anime_dir / "Attack on Titan (2013)").exists()

View File

@@ -816,11 +816,14 @@ class TestPerformNfoRepairScan:
return_value=mock_repair_service,
), patch(
"asyncio.create_task"
) as mock_create_task:
) as mock_create_task, patch(
"asyncio.gather", new_callable=AsyncMock
) as mock_gather:
mock_factory_cls.return_value.create.return_value = MagicMock()
await perform_nfo_repair_scan(background_loader=AsyncMock())
mock_create_task.assert_called_once()
mock_gather.assert_called_once()
@pytest.mark.asyncio
async def test_skips_complete_series(self, tmp_path):
@@ -876,8 +879,11 @@ class TestPerformNfoRepairScan:
return_value=mock_repair_service,
), patch(
"asyncio.create_task"
) as mock_create_task:
) as mock_create_task, patch(
"asyncio.gather", new_callable=AsyncMock
) as mock_gather:
mock_factory_cls.return_value.create.return_value = MagicMock()
await perform_nfo_repair_scan(background_loader=None)
mock_create_task.assert_called_once()
mock_gather.assert_called_once()

View File

@@ -0,0 +1,218 @@
"""Unit tests for key_resolution_service."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.server.services.key_resolution_service import (
_extract_key_from_link,
_extract_year_from_folder,
_normalize_for_comparison,
_strip_year_from_folder,
resolve_key_for_folder,
)
class TestStripYearFromFolder:
"""Tests for _strip_year_from_folder."""
def test_removes_year_suffix(self):
assert _strip_year_from_folder("Rent-A-Girlfriend (2020)") == "Rent-A-Girlfriend"
def test_removes_year_suffix_with_spaces(self):
assert _strip_year_from_folder("Attack on Titan (2013)") == "Attack on Titan"
def test_no_year_returns_original(self):
assert _strip_year_from_folder("Naruto") == "Naruto"
def test_year_in_middle_not_stripped(self):
assert _strip_year_from_folder("2024 Anime (2024)") == "2024 Anime"
def test_empty_string(self):
assert _strip_year_from_folder("") == ""
def test_only_year(self):
assert _strip_year_from_folder("(2020)") == ""
class TestExtractYearFromFolder:
"""Tests for _extract_year_from_folder."""
def test_extracts_year(self):
assert _extract_year_from_folder("Rent-A-Girlfriend (2020)") == 2020
def test_no_year_returns_none(self):
assert _extract_year_from_folder("Naruto") is None
def test_year_in_middle_not_extracted(self):
# Only trailing year is extracted
assert _extract_year_from_folder("2024 Anime") is None
class TestExtractKeyFromLink:
"""Tests for _extract_key_from_link."""
def test_relative_link(self):
assert _extract_key_from_link("/anime/stream/rent-a-girlfriend") == "rent-a-girlfriend"
def test_full_url(self):
assert (
_extract_key_from_link("https://aniworld.to/anime/stream/attack-on-titan")
== "attack-on-titan"
)
def test_link_with_trailing_slash(self):
assert _extract_key_from_link("/anime/stream/naruto/") == "naruto"
def test_empty_link(self):
assert _extract_key_from_link("") is None
def test_none_link(self):
assert _extract_key_from_link(None) is None
def test_slug_only(self):
assert _extract_key_from_link("one-piece") == "one-piece"
class TestNormalizeForComparison:
"""Tests for _normalize_for_comparison."""
def test_case_insensitive(self):
assert _normalize_for_comparison("Rent-A-Girlfriend") == _normalize_for_comparison(
"rent-a-girlfriend"
)
def test_strips_whitespace(self):
assert _normalize_for_comparison(" Naruto ") == "naruto"
def test_normalizes_dashes(self):
assert _normalize_for_comparison("Rent-A-Girlfriend") == "rent a girlfriend"
def test_collapses_spaces(self):
assert _normalize_for_comparison("Attack on Titan") == "attack on titan"
class TestResolveKeyForFolder:
"""Tests for resolve_key_for_folder."""
@pytest.mark.asyncio
async def test_single_exact_match_returns_key(self):
"""When provider returns exactly one exact-name match, key is resolved."""
search_results = [
{"link": "/anime/stream/rent-a-girlfriend", "title": "Rent-A-Girlfriend"},
]
with patch(
"src.server.services.key_resolution_service._search_provider",
return_value=search_results,
):
key = await resolve_key_for_folder("Rent-A-Girlfriend (2020)")
assert key == "rent-a-girlfriend"
@pytest.mark.asyncio
async def test_no_results_returns_none(self):
"""When provider returns no results, returns None."""
with patch(
"src.server.services.key_resolution_service._search_provider",
return_value=[],
):
key = await resolve_key_for_folder("Unknown Anime (2020)")
assert key is None
@pytest.mark.asyncio
async def test_multiple_exact_matches_returns_none(self):
"""When multiple results match the same name exactly, returns None."""
search_results = [
{"link": "/anime/stream/my-anime", "title": "My Anime"},
{"link": "/anime/stream/my-anime-2", "title": "My Anime"},
]
with patch(
"src.server.services.key_resolution_service._search_provider",
return_value=search_results,
):
key = await resolve_key_for_folder("My Anime (2022)")
assert key is None
@pytest.mark.asyncio
async def test_no_exact_match_returns_none(self):
"""When results exist but none match the folder name, returns None."""
search_results = [
{"link": "/anime/stream/rent-a-girlfriend-2", "title": "Rent-A-Girlfriend 2nd Season"},
{"link": "/anime/stream/rent-a-girlfriend-3", "title": "Rent-A-Girlfriend 3rd Season"},
]
with patch(
"src.server.services.key_resolution_service._search_provider",
return_value=search_results,
):
key = await resolve_key_for_folder("Rent-A-Girlfriend (2020)")
assert key is None
@pytest.mark.asyncio
async def test_case_insensitive_match(self):
"""Matching is case-insensitive."""
search_results = [
{"link": "/anime/stream/naruto", "title": "NARUTO"},
]
with patch(
"src.server.services.key_resolution_service._search_provider",
return_value=search_results,
):
key = await resolve_key_for_folder("Naruto (2002)")
assert key == "naruto"
@pytest.mark.asyncio
async def test_provider_error_returns_none(self):
"""When provider search raises an exception, returns None gracefully."""
with patch(
"src.server.services.key_resolution_service._search_provider",
side_effect=RuntimeError("Network error"),
):
key = await resolve_key_for_folder("Some Anime (2020)")
assert key is None
@pytest.mark.asyncio
async def test_result_with_name_field_instead_of_title(self):
"""Search results using 'name' field instead of 'title' work."""
search_results = [
{"link": "/anime/stream/one-piece", "name": "One Piece"},
]
with patch(
"src.server.services.key_resolution_service._search_provider",
return_value=search_results,
):
key = await resolve_key_for_folder("One Piece (1999)")
assert key == "one-piece"
@pytest.mark.asyncio
async def test_folder_without_year(self):
"""Folders without year suffix still work."""
search_results = [
{"link": "/anime/stream/naruto", "title": "Naruto"},
]
with patch(
"src.server.services.key_resolution_service._search_provider",
return_value=search_results,
):
key = await resolve_key_for_folder("Naruto")
assert key == "naruto"
@pytest.mark.asyncio
async def test_exact_match_among_partial_matches(self):
"""Only exact matches count, partial matches are ignored."""
search_results = [
{"link": "/anime/stream/dororo", "title": "Dororo"},
{"link": "/anime/stream/dororo-to-hyakkimaru", "title": "Dororo to Hyakkimaru"},
]
with patch(
"src.server.services.key_resolution_service._search_provider",
return_value=search_results,
):
key = await resolve_key_for_folder("Dororo (2019)")
assert key == "dororo"

View File

@@ -0,0 +1,293 @@
"""
Unit tests for key generation utilities.
"""
import pytest
from src.core.utils.key_utils import (
generate_key_from_folder,
normalize_key,
is_valid_key,
sanitize_key_for_url,
validate_key_uniqueness,
)
class TestGenerateKeyFromFolder:
"""Test generate_key_from_folder function with edge cases."""
def test_standard_folder_name(self):
"""Test standard folder name with year."""
key = generate_key_from_folder("Attack on Titan (2013)")
assert key == "attack-on-titan-2013"
assert is_valid_key(key)
def test_a_time_called_you(self):
"""Test 'A Time Called You (2023)' - the specific failing case."""
key = generate_key_from_folder("A Time Called You (2023)")
assert key is not None
assert key != ""
assert is_valid_key(key)
def test_andor_2022(self):
"""Test 'Andor (2022)' - the specific failing case."""
key = generate_key_from_folder("Andor (2022)")
assert key is not None
assert key != ""
assert is_valid_key(key)
def test_japanese_mixed_folder(self):
"""Test '25-sai no Joshikousei (2018)' - Japanese + Latin mixed."""
key = generate_key_from_folder("25-sai no Joshikousei (2018)")
assert key is not None
assert key != ""
assert is_valid_key(key)
def test_folder_with_only_special_characters(self):
"""Test folder that would slugify to empty string."""
key = generate_key_from_folder("!!!@@@###")
assert key is not None
assert key != ""
# Should use UUID fallback
def test_folder_with_only_numbers(self):
"""Test folder that is just numbers."""
key = generate_key_from_folder("12345")
assert key is not None
assert key != ""
assert is_valid_key(key)
def test_folder_with_parentheses_and_year(self):
"""Test folder with parentheses containing year."""
key = generate_key_from_folder("My Series (2020)")
assert key is not None
assert key != ""
assert is_valid_key(key)
def test_folder_with_brackets(self):
"""Test folder with square brackets."""
key = generate_key_from_folder("My Series [Special] (2021)")
assert key is not None
assert key != ""
assert is_valid_key(key)
def test_unicode_characters(self):
"""Test folder with various Unicode characters."""
key = generate_key_from_folder("Héros Légende (2022)")
assert key is not None
assert key != ""
assert is_valid_key(key)
def test_korean_characters(self):
"""Test folder with Korean characters."""
key = generate_key_from_folder("나의 애니메이션 (2023)")
assert key is not None
assert key != ""
def test_chinese_characters(self):
"""Test folder with Chinese characters."""
key = generate_key_from_folder("我的动漫 (2024)")
assert key is not None
assert key != ""
def test_empty_string_input(self):
"""Test empty string input raises ValueError."""
with pytest.raises(ValueError, match="Folder name cannot be empty"):
generate_key_from_folder("")
def test_only_whitespace_input(self):
"""Test whitespace-only input raises ValueError."""
with pytest.raises(ValueError, match="Folder name cannot be empty"):
generate_key_from_folder(" ")
def test_single_character_folder(self):
"""Test single character folder name."""
key = generate_key_from_folder("X")
assert key is not None
assert key != ""
assert is_valid_key(key)
def test_very_long_folder_name(self):
"""Test very long folder name."""
long_name = "A" * 200
key = generate_key_from_folder(long_name)
assert key is not None
assert key != ""
def test_multiple_spaces(self):
"""Test folder with multiple consecutive spaces."""
key = generate_key_from_folder("My Series Name")
assert key is not None
assert key != ""
def test_leading_trailing_spaces(self):
"""Test folder with leading and trailing spaces."""
key = generate_key_from_folder(" My Series ")
assert key is not None
assert key != ""
def test_diacritics_normalization(self):
"""Test that diacritics are properly normalized."""
key = generate_key_from_folder("Animé (2023)")
assert key is not None
assert is_valid_key(key)
class TestNormalizeKey:
"""Test normalize_key function."""
def test_normalize_standard_key(self):
"""Test normalizing a standard key."""
result = normalize_key("Attack-on-Titan")
assert result == "attack-on-titan"
def test_normalize_with_underscores(self):
"""Test normalizing key with underscores."""
result = normalize_key("attack_on_titan")
assert result == "attack-on-titan"
def test_normalize_mixed_case(self):
"""Test normalizing mixed case key."""
result = normalize_key("Attack_On_Titan")
assert result == "attack-on-titan"
def test_normalize_with_spaces(self):
"""Test normalizing key with spaces."""
result = normalize_key("attack on titan")
assert result == "attack-on-titan"
def test_normalize_empty_string(self):
"""Test normalizing empty string returns empty."""
result = normalize_key("")
assert result == ""
def test_normalize_only_special_chars(self):
"""Test normalizing string with only special characters."""
result = normalize_key("!!!@@@")
assert result == ""
class TestIsValidKey:
"""Test is_valid_key function."""
def test_valid_simple_key(self):
"""Test valid simple key."""
assert is_valid_key("attack-on-titan")
def test_valid_key_with_numbers(self):
"""Test valid key with numbers."""
assert is_valid_key("a-time-called-you-2023")
def test_valid_key_with_underscores(self):
"""Test valid key with underscores."""
assert is_valid_key("a_time_called_you_2023")
def test_valid_key_starting_with_number(self):
"""Test valid key starting with number."""
assert is_valid_key("25-sai-no-joshikousei-2018")
def test_invalid_empty_key(self):
"""Test invalid empty key."""
assert not is_valid_key("")
def test_invalid_key_with_spaces(self):
"""Test invalid key with spaces."""
assert not is_valid_key("attack on titan")
def test_invalid_key_with_special_chars(self):
"""Test invalid key with special characters."""
assert not is_valid_key("attack@titan")
def test_invalid_key_with_unicode(self):
"""Test invalid key with unstripped unicode."""
assert not is_valid_key("attack\u00a0titan") # Non-breaking space
def test_invalid_single_char(self):
"""Test invalid single character key."""
assert not is_valid_key("a")
def test_valid_two_char_key(self):
"""Test valid two character key."""
assert is_valid_key("ab")
def test_invalid_key_starting_with_hyphen(self):
"""Test invalid key starting with hyphen."""
assert not is_valid_key("-attack")
class TestSanitizeKeyForUrl:
"""Test sanitize_key_for_url function."""
def test_standard_key_unchanged(self):
"""Test standard key remains unchanged."""
result = sanitize_key_for_url("attack-on-titan-2013")
assert result == "attack-on-titan-2013"
def test_spaces_replaced(self):
"""Test spaces are replaced with hyphens."""
result = sanitize_key_for_url("attack on titan")
assert result == "attack-on-titan"
def test_uppercase_preserved(self):
"""Test uppercase is preserved (use normalize_key for lowercase)."""
result = sanitize_key_for_url("AttackOnTitan")
# sanitize_key_for_url preserves case, only removes special chars
assert result == "AttackOnTitan"
def test_special_chars_removed(self):
"""Test special characters are removed."""
result = sanitize_key_for_url("Attack@#@On!Titan")
assert result == "AttackOnTitan"
def test_accents_preserved(self):
"""Test accented characters are preserved (use normalize_key for full normalization)."""
result = sanitize_key_for_url("AttäckÖnTïtan")
# Only removes truly problematic chars, preserves accented letters
assert "AttäckÖnTïtan" in result
def test_multiple_hyphens_collapses(self):
"""Test multiple hyphens are collapsed."""
result = sanitize_key_for_url("attack---on---titan")
assert result == "attack-on-titan"
def test_leading_trailing_hyphens_removed(self):
"""Test leading and trailing hyphens are removed."""
result = sanitize_key_for_url("-attack-on-titan-")
assert result == "attack-on-titan"
class TestValidateKeyUniqueness:
"""Test validate_key_uniqueness function."""
def test_unique_key(self):
"""Test key that is unique."""
existing_keys = {"attack-on-titan", "one-piece", "naruto"}
is_valid, error = validate_key_uniqueness("new-series", existing_keys)
assert is_valid is True
assert error == ""
def test_duplicate_key(self):
"""Test key that already exists."""
existing_keys = {"attack-on-titan", "one-piece", "naruto"}
is_valid, error = validate_key_uniqueness("one-piece", existing_keys)
assert is_valid is False
assert "already in use" in error
def test_empty_existing_set(self):
"""Test with empty existing keys set."""
is_valid, error = validate_key_uniqueness("new-series", set())
assert is_valid is True
assert error == ""
def test_key_differs_only_by_case(self):
"""Test key that differs only by case is NOT flagged by utility (API layer handles case-insensitivity)."""
existing_keys = {"attack-on-titan"} # lowercase in set
is_valid, error = validate_key_uniqueness("Attack-on-Titan", existing_keys)
# Utility function does case-sensitive check; API layer handles case-insensitivity
assert is_valid is True
assert error == ""
def test_same_key_same_case(self):
"""Test same key in existing set is flagged."""
existing_keys = {"my-series"}
is_valid, error = validate_key_uniqueness("my-series", existing_keys)
assert is_valid is False

View File

@@ -141,6 +141,86 @@ class TestSchedulerConfigFolderScanEnabled:
assert config.folder_scan_enabled is False
class TestSchedulerConfigLegacyAliases:
"""3.10 Legacy config key aliases (auto_download, folder_scan)."""
def test_legacy_auto_download_true(self) -> None:
"""Legacy auto_download=true maps to auto_download_after_rescan=True."""
config = SchedulerConfig(auto_download=True)
assert config.auto_download_after_rescan is True
assert config.folder_scan_enabled is False
def test_legacy_auto_download_false(self) -> None:
config = SchedulerConfig(auto_download=False)
assert config.auto_download_after_rescan is False
def test_legacy_folder_scan_true(self) -> None:
"""Legacy folder_scan=true maps to folder_scan_enabled=True."""
config = SchedulerConfig(folder_scan=True)
assert config.folder_scan_enabled is True
assert config.auto_download_after_rescan is False
def test_legacy_folder_scan_false(self) -> None:
config = SchedulerConfig(folder_scan=False)
assert config.folder_scan_enabled is False
def test_legacy_both_set(self) -> None:
"""Both legacy keys can be set simultaneously."""
config = SchedulerConfig(auto_download=True, folder_scan=True)
assert config.auto_download_after_rescan is True
assert config.folder_scan_enabled is True
def test_explicit_primary_overrides_legacy(self) -> None:
"""Primary field explicitly set to False still wins over legacy True.
When user provides both old and new key, newer key wins by virtue of
being the intended migration target. Legacy alias only applies when
primary key is absent from data entirely.
"""
config = SchedulerConfig(
auto_download=True,
auto_download_after_rescan=True,
folder_scan=True,
folder_scan_enabled=True,
)
# Both set to True — no conflict possible when both agree
assert config.auto_download_after_rescan is True
assert config.folder_scan_enabled is True
def test_explicit_primary_false_wins_over_legacy_true(self) -> None:
"""Primary=False explicitly set wins over legacy=True.
User has migrated config to new keys but old key still present.
Explicit primary value must be respected.
"""
config = SchedulerConfig(
auto_download=True,
auto_download_after_rescan=False,
)
assert config.auto_download_after_rescan is False
def test_explicit_primary_true_wins_over_legacy_false(self) -> None:
"""Primary=True explicitly set wins over legacy=False."""
config = SchedulerConfig(
auto_download=False,
auto_download_after_rescan=True,
)
assert config.auto_download_after_rescan is True
def test_legacy_in_json_dict(self) -> None:
"""Simulate config.json with legacy auto_download key."""
data = {
"enabled": True,
"schedule_time": "03:00",
"schedule_days": ALL_DAYS,
"auto_download": True,
"folder_scan": True,
}
config = SchedulerConfig(**data)
assert config.auto_download_after_rescan is True
assert config.folder_scan_enabled is True
class TestSchedulerConfigSerialisation:
"""3.9 Serialisation roundtrip."""
@@ -156,3 +236,24 @@ class TestSchedulerConfigSerialisation:
dumped = original.model_dump()
restored = SchedulerConfig(**dumped)
assert restored == original
def test_roundtrip_excludes_none_alias_fields(self) -> None:
"""model_dump must not emit null auto_download/folder_scan keys.
Previously these null keys were written to config.json on save.
On reload they were present (even as None), so the alias mapping in
__init__ was skipped and the primary fields retained their default
False values instead of the configured True values.
"""
original = SchedulerConfig(
auto_download_after_rescan=True,
folder_scan_enabled=True,
)
dumped = original.model_dump()
# Alias fields must not appear when None
assert "auto_download" not in dumped
assert "folder_scan" not in dumped
# Primary fields roundtrip correctly
restored = SchedulerConfig(**dumped)
assert restored.auto_download_after_rescan is True
assert restored.folder_scan_enabled is True

View File

@@ -519,23 +519,8 @@ class TestFindMp4Files:
class TestReadDataFromFile:
"""Test __read_data_from_file method."""
def test_reads_key_file(self, mock_loader):
"""Should read key from 'key' file."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
anime_folder = os.path.join(tmpdir, "SomeAnime")
os.makedirs(anime_folder)
with open(os.path.join(anime_folder, "key"), "w") as f:
f.write("some-key")
scanner = SerieScanner(tmpdir, mock_loader)
result = scanner._SerieScanner__read_data_from_file("SomeAnime")
assert result is not None
assert result.key == "some-key"
def test_reads_data_file(self, mock_loader):
"""Should read Serie from 'data' file when no 'key' file."""
"""Should read Serie from 'data' file when no DB entry exists."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
@@ -552,8 +537,8 @@ class TestReadDataFromFile:
assert result is not None
assert result.key == "test-key"
def test_no_files_returns_none(self, mock_loader):
"""Should return None when no key or data file exists."""
def test_no_files_returns_serie_with_generated_key(self, mock_loader):
"""Should return Serie with generated key when no key or data file exists."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
@@ -562,7 +547,30 @@ class TestReadDataFromFile:
scanner = SerieScanner(tmpdir, mock_loader)
result = scanner._SerieScanner__read_data_from_file("Empty")
assert result is None
# Step 5 (was Step 4) generates key from folder name when no files exist
assert result is not None
assert isinstance(result, Serie)
assert result.key == "empty"
def test_scan_key_override_used_instead_of_generated(self, mock_loader):
"""Should use override key when folder name matches override dict."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
anime_folder = os.path.join(tmpdir, "Anyway, I'm Falling in Love with You (2025)")
os.makedirs(anime_folder)
overrides = {
"Anyway, I'm Falling in Love with You (2025)": "anyway-im-falling-in-love-with-you-2025"
}
scanner = SerieScanner(tmpdir, mock_loader, scan_key_overrides=overrides)
result = scanner._SerieScanner__read_data_from_file(
"Anyway, I'm Falling in Love with You (2025)"
)
# Override key should be used instead of generated key
assert result is not None
assert isinstance(result, Serie)
assert result.key == "anyway-im-falling-in-love-with-you-2025"
class TestReinit:
@@ -763,7 +771,7 @@ class TestDbLookupFallback:
assert scanner.keyDict["rooster-fighter"].episodeDict == {1: [1, 2, 3]}
def test_db_lookup_returns_none_folder_skipped(self, mock_loader):
"""When db_lookup returns None, the folder is skipped with a warning."""
"""When db_lookup returns None, Step 4 fallback generates key from folder name."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
@@ -773,10 +781,11 @@ class TestDbLookupFallback:
with patch.object(scanner, 'get_total_to_scan', return_value=1):
scanner.scan()
assert len(scanner.keyDict) == 0
# Step 4 generates key from folder name, so keyDict is not empty
assert len(scanner.keyDict) == 1
def test_db_lookup_exception_skips_folder(self, mock_loader):
"""When db_lookup raises, the folder is skipped gracefully."""
"""When db_lookup raises, Step 4 fallback generates key from folder name."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
@@ -786,7 +795,8 @@ class TestDbLookupFallback:
with patch.object(scanner, 'get_total_to_scan', return_value=1):
scanner.scan() # should not raise
assert len(scanner.keyDict) == 0
# Step 4 generates key from folder name, so keyDict is not empty
assert len(scanner.keyDict) == 1
def test_db_lookup_warning_logged_when_no_files(
self, mock_loader, caplog

View File

@@ -77,23 +77,19 @@ class TestGetSerieFromFolderDbLookup:
assert result.key == "rooster-fighter"
lookup.assert_called_once_with("Rooster Fighter (2026)")
def test_legacy_key_file_as_last_resort(self, temp_directory, mock_loader):
"""No DB, no callback -> legacy 'key' file used with deprecation warning."""
def test_no_db_no_callback_generates_key_from_folder_name(self, temp_directory, mock_loader):
"""No DB entry, no callback -> key generated from folder name."""
folder = os.path.join(temp_directory, "Legacy Series")
os.makedirs(folder, exist_ok=True)
with open(os.path.join(folder, "key"), "w") as f:
f.write("legacy-key")
# No key file, no data file - should fall through to Step 4 (key generation)
scanner = SerieScanner(temp_directory, mock_loader)
with patch.object(logging.getLogger("src.core.SerieScanner"), "warning") as mock_warning:
result = scanner._SerieScanner__read_data_from_file("Legacy Series")
result = scanner._SerieScanner__read_data_from_file("Legacy Series")
assert result is not None
assert result.key == "legacy-key"
mock_warning.assert_called()
warning_calls = [str(c) for c in mock_warning.call_args_list]
assert any("deprecated" in c or "v3.0.0" in c for c in warning_calls)
assert result is not None
assert result.key == "legacy-series"
assert result.folder == "Legacy Series"
def test_db_lookup_exception_caught_and_logged(self, temp_directory, mock_loader):
"""DB exception -> fallback to provider callback."""

View File

@@ -23,6 +23,8 @@ async def test_system_settings_integration():
assert settings.initial_scan_completed is False
assert settings.initial_nfo_scan_completed is False
assert settings.initial_media_scan_completed is False
assert settings.migration_legacy_files_completed is False
assert settings.legacy_key_cleanup_completed is False
# Test checking individual flags
async with get_db_session() as db:
@@ -34,6 +36,12 @@ async def test_system_settings_integration():
is_media_done = await SystemSettingsService.is_initial_media_scan_completed(db)
assert is_media_done is False
is_migration_done = await SystemSettingsService.is_migration_legacy_files_completed(db)
assert is_migration_done is False
is_key_cleanup_done = await SystemSettingsService.is_legacy_key_cleanup_completed(db)
assert is_key_cleanup_done is False
# Test marking scans as completed
async with get_db_session() as db:
@@ -56,6 +64,8 @@ async def test_system_settings_integration():
assert settings.initial_scan_completed is False
assert settings.initial_nfo_scan_completed is False
assert settings.initial_media_scan_completed is False
assert settings.migration_legacy_files_completed is False
assert settings.legacy_key_cleanup_completed is False
if __name__ == "__main__":