Compare commits
4 Commits
47bd393a57
...
f89e403a17
| Author | SHA1 | Date | |
|---|---|---|---|
| f89e403a17 | |||
| 5f46d2e802 | |||
| a384072901 | |||
| d99636e9c7 |
@@ -1 +1 @@
|
||||
v1.4.17
|
||||
v1.5.0
|
||||
|
||||
109
Docs/TESTING.md
109
Docs/TESTING.md
@@ -1,104 +1,33 @@
|
||||
# Testing Documentation
|
||||
|
||||
## Document Purpose
|
||||
### Testing FolderNamingService
|
||||
|
||||
This document describes the testing strategy, guidelines, and practices for the Aniworld project.
|
||||
|
||||
### What This Document Contains
|
||||
|
||||
- **Testing Strategy**: Overall approach to quality assurance
|
||||
- **Test Categories**: Unit, integration, API, performance, security tests
|
||||
- **Test Structure**: Organization of test files and directories
|
||||
- **Writing Tests**: Guidelines for writing effective tests
|
||||
- **Fixtures and Mocking**: Shared test utilities and mock patterns
|
||||
- **Running Tests**: Commands and configurations
|
||||
- **Coverage Requirements**: Minimum coverage thresholds
|
||||
- **CI/CD Integration**: How tests run in automation
|
||||
- **Test Data Management**: Managing test fixtures and data
|
||||
- **Best Practices**: Do's and don'ts for testing
|
||||
|
||||
### What This Document Does NOT Contain
|
||||
|
||||
- Production deployment (see [DEPLOYMENT.md](DEPLOYMENT.md))
|
||||
- Security audit procedures (see [SECURITY.md](SECURITY.md))
|
||||
- Bug tracking and issue management
|
||||
- Performance benchmarking results
|
||||
|
||||
### Target Audience
|
||||
|
||||
- Developers writing tests
|
||||
- QA Engineers
|
||||
- CI/CD Engineers
|
||||
- Code reviewers
|
||||
|
||||
---
|
||||
|
||||
## Sections to Document
|
||||
|
||||
1. Testing Philosophy
|
||||
- Test pyramid approach
|
||||
- Quality gates
|
||||
2. Test Categories
|
||||
- Unit Tests (`tests/unit/`)
|
||||
- Integration Tests (`tests/integration/`)
|
||||
- API Tests (`tests/api/`)
|
||||
- Frontend Tests (`tests/frontend/`)
|
||||
- Performance Tests (`tests/performance/`)
|
||||
- Security Tests (`tests/security/`)
|
||||
3. Test Structure and Naming
|
||||
- File naming conventions
|
||||
- Test function naming
|
||||
- Test class organization
|
||||
4. Running Tests
|
||||
- pytest commands
|
||||
- Running specific tests
|
||||
- Verbose output
|
||||
- Coverage reports
|
||||
5. Fixtures and Conftest
|
||||
- Shared fixtures
|
||||
- Database fixtures
|
||||
- Mock services
|
||||
6. Mocking Guidelines
|
||||
- What to mock
|
||||
- Mock patterns
|
||||
- External service mocks
|
||||
|
||||
### Mocking the Download Queue
|
||||
|
||||
Use `MockQueueRepository` for testing download queue functionality:
|
||||
|
||||
```python
|
||||
from src.server.models.download import DownloadItem, EpisodeIdentifier
|
||||
|
||||
class MockQueueRepository:
|
||||
def __init__(self):
|
||||
self._items: Dict[str, DownloadItem] = {}
|
||||
```
|
||||
|
||||
### Testing SetupService
|
||||
|
||||
SetupService handles series key resolution from folder names during library setup. Test file: `tests/unit/test_setup_service.py`.
|
||||
FolderNamingService fixes missing years in anime folder names after NFO refresh. Test file: `tests/unit/test_folder_naming_service.py`.
|
||||
|
||||
Key methods tested:
|
||||
- `_build_target_folder()` — constructs safe "Title (YYYY)" names, strips existing year suffix first (prevents double-year like "Naruto (1999) (1999)")
|
||||
- `_folder_has_year()` — detects existing `(YYYY)` pattern
|
||||
- `_extract_year_from_folder_name()` — parses `(YYYY)` suffix
|
||||
- `_extract_title_from_folder_name()` — strips year suffix
|
||||
- `_resolve_key_via_search()` — resolves provider key via fuzzy title matching
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_key_when_single_exact_match(self):
|
||||
"""Search returns 1 result with same name → returns key."""
|
||||
mock_series_app = AsyncMock()
|
||||
mock_series_app.search.return_value = [
|
||||
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'}
|
||||
]
|
||||
# Safe: repeated calls never accumulate years
|
||||
r1 = FolderNamingService._build_target_folder("Naruto", 1999)
|
||||
r2 = FolderNamingService._build_target_folder(r1, 1999)
|
||||
assert r1 == r2 == "Naruto (1999)"
|
||||
|
||||
with patch('src.server.services.setup_service.get_series_app', return_value=mock_series_app):
|
||||
result = await SetupService._resolve_key_via_search("Attack on Titan")
|
||||
|
||||
assert result == 'attack-on-titan'
|
||||
# Safe: existing year is replaced, not appended
|
||||
result = FolderNamingService._build_target_folder("Naruto (2020)", 1999)
|
||||
assert result == "Naruto (1999)"
|
||||
assert "2020" not in result
|
||||
```
|
||||
|
||||
The service is also tested end-to-end with mocked filesystem and database:
|
||||
- Renames folder and updates DB when year is missing from folder name
|
||||
- Skips rename when folder already has a year
|
||||
- Skips rename when DB has no year
|
||||
- Skips when target folder already exists on disk
|
||||
- Safety guard detects and skips if target folder year mismatches DB year
|
||||
|
||||
### Mocking aiohttp Sessions
|
||||
|
||||
When testing code that uses `aiohttp.ClientSession`:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aniworld-web",
|
||||
"version": "1.4.17",
|
||||
"version": "1.5.0",
|
||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -31,6 +31,9 @@ def _build_response(config: SchedulerConfig) -> Dict[str, Any]:
|
||||
"schedule_time": config.schedule_time,
|
||||
"schedule_days": config.schedule_days,
|
||||
"auto_download_after_rescan": config.auto_download_after_rescan,
|
||||
"nfo_scan_after_rescan": config.nfo_scan_after_rescan,
|
||||
"image_scan_after_rescan": config.image_scan_after_rescan,
|
||||
"folder_naming_after_nfo_scan": config.folder_naming_after_nfo_scan,
|
||||
},
|
||||
"status": {
|
||||
"is_running": runtime.get("is_running", False),
|
||||
|
||||
@@ -14,7 +14,9 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from src.server.database.connection import get_db_session
|
||||
from src.server.database.service import AnimeSeriesService, UnresolvedFolderService
|
||||
from src.server.services.background_loader_service import BackgroundLoaderService
|
||||
from src.server.utils.dependencies import (
|
||||
get_background_loader_service,
|
||||
get_database_session,
|
||||
get_series_app,
|
||||
require_auth,
|
||||
@@ -150,6 +152,7 @@ async def resolve_unresolved_folder(
|
||||
folder_name: str,
|
||||
request: ResolveFolderRequest,
|
||||
db=Depends(get_database_session),
|
||||
background_loader: BackgroundLoaderService = Depends(get_background_loader_service),
|
||||
) -> ResolveFolderResponse:
|
||||
"""Resolve an unresolved folder by providing the correct provider key.
|
||||
|
||||
@@ -215,6 +218,26 @@ async def resolve_unresolved_folder(
|
||||
folder_name, request.provider_key, series.id
|
||||
)
|
||||
|
||||
# Queue background loading task for episodes, NFO, and images
|
||||
try:
|
||||
await background_loader.add_series_loading_task(
|
||||
key=request.provider_key,
|
||||
folder=folder_name,
|
||||
name=unresolved.title,
|
||||
year=unresolved.year,
|
||||
)
|
||||
logger.info(
|
||||
"Queued background loading for resolved folder: %s (key=%s)",
|
||||
folder_name,
|
||||
request.provider_key
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to queue background loading for resolved folder %s: %s",
|
||||
folder_name,
|
||||
e
|
||||
)
|
||||
|
||||
return ResolveFolderResponse(
|
||||
status="success",
|
||||
message=f"Successfully resolved and added series: {unresolved.title}",
|
||||
|
||||
@@ -52,6 +52,12 @@ class SchedulerConfig(BaseModel):
|
||||
description="Download series images (poster.jpg, fanart.jpg, logo.png) "
|
||||
"from TMDB after a scheduled rescan completes.",
|
||||
)
|
||||
folder_naming_after_nfo_scan: bool = Field(
|
||||
default=True,
|
||||
description="Fix missing years in folder names after NFO refresh. "
|
||||
"Renames folders (e.g. 'Naruto' -> 'Naruto (1999)') using "
|
||||
"the year from the database record.",
|
||||
)
|
||||
# Legacy alias fields — read via Pydantic alias
|
||||
auto_download: Optional[bool] = Field(default=None, alias="auto_download")
|
||||
|
||||
|
||||
205
src/server/services/folder_naming_service.py
Normal file
205
src/server/services/folder_naming_service.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Folder naming service for fixing missing years in anime folder names."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import structlog
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.server.database.connection import get_db_session as _get_db_session
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FolderRenameResult:
|
||||
key: str
|
||||
old_folder: str
|
||||
new_folder: Optional[str]
|
||||
success: bool
|
||||
skipped: bool = False
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FolderNamingReport:
|
||||
total: int
|
||||
renamed: int
|
||||
skipped: int
|
||||
errors: int
|
||||
results: List[FolderRenameResult]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"total": self.total,
|
||||
"renamed": self.renamed,
|
||||
"skipped": self.skipped,
|
||||
"errors": self.errors,
|
||||
"results": [
|
||||
{
|
||||
"key": r.key,
|
||||
"old_folder": r.old_folder,
|
||||
"new_folder": r.new_folder,
|
||||
"success": r.success,
|
||||
"skipped": r.skipped,
|
||||
"reason": r.reason,
|
||||
}
|
||||
for r in self.results
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class FolderNamingService:
|
||||
def __init__(self) -> None:
|
||||
self._is_running = False
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def run(self) -> FolderNamingReport:
|
||||
async with self._lock:
|
||||
if self._is_running:
|
||||
logger.warning("FolderNamingService.run() called while already running")
|
||||
return FolderNamingReport(total=0, renamed=0, skipped=0, errors=0, results=[])
|
||||
self._is_running = True
|
||||
|
||||
try:
|
||||
logger.info("FolderNamingService: starting folder naming scan")
|
||||
results: List[FolderRenameResult] = []
|
||||
|
||||
async with _get_db_session() as db:
|
||||
all_series = await AnimeSeriesService.get_all(db)
|
||||
|
||||
for series in all_series:
|
||||
result = await self._process_series(series)
|
||||
results.append(result)
|
||||
|
||||
renamed = sum(1 for r in results if r.success and not r.skipped)
|
||||
skipped = sum(1 for r in results if r.skipped)
|
||||
errors = sum(1 for r in results if not r.skipped and not r.success)
|
||||
|
||||
report = FolderNamingReport(
|
||||
total=len(results),
|
||||
renamed=renamed,
|
||||
skipped=skipped,
|
||||
errors=errors,
|
||||
results=results,
|
||||
)
|
||||
logger.info(
|
||||
"FolderNamingService: scan complete — total=%d renamed=%d skipped=%d errors=%d",
|
||||
report.total, report.renamed, report.skipped, report.errors,
|
||||
)
|
||||
return report
|
||||
finally:
|
||||
self._is_running = False
|
||||
|
||||
async def _process_series(self, series) -> FolderRenameResult:
|
||||
key = series.key
|
||||
folder = series.folder or ""
|
||||
year = getattr(series, "year", None)
|
||||
|
||||
if year is None:
|
||||
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=True, skipped=True, reason="no year in DB record")
|
||||
|
||||
if self._folder_has_year(folder):
|
||||
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=True, skipped=True, reason="folder already has year")
|
||||
|
||||
target_folder = self._build_target_folder(folder, year)
|
||||
|
||||
# Safety: re-extract year from target to prevent double-year
|
||||
if target_folder != folder:
|
||||
extracted = self._extract_year_from_folder_name(target_folder)
|
||||
if extracted != year:
|
||||
logger.error("Safety guard for %s: target '%s' year=%s != DB year=%s — skipping", key, target_folder, extracted, year)
|
||||
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=False, skipped=True, reason="safety guard: target year mismatch")
|
||||
|
||||
return await self._execute_rename(series, folder, target_folder)
|
||||
|
||||
async def _execute_rename(self, series, old_folder: str, target_folder: str) -> FolderRenameResult:
|
||||
key = series.key
|
||||
|
||||
if old_folder == target_folder:
|
||||
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=True, skipped=True, reason="same folder name")
|
||||
|
||||
anime_dir = settings.anime_directory
|
||||
old_path = os.path.join(anime_dir, old_folder)
|
||||
target_path = os.path.join(anime_dir, target_folder)
|
||||
|
||||
if not os.path.isdir(old_path):
|
||||
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="source folder does not exist on disk")
|
||||
|
||||
if os.path.isdir(target_path):
|
||||
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="target folder already exists on disk")
|
||||
|
||||
try:
|
||||
shutil.move(old_path, target_path)
|
||||
logger.info("Renamed folder %s -> %s for series %s", old_folder, target_folder, key)
|
||||
|
||||
# Update in-memory cache
|
||||
try:
|
||||
from src.server.SeriesApp import get_series_app
|
||||
series_app = get_series_app()
|
||||
if key in series_app.list.keyDict:
|
||||
series_app.list.keyDict[key].folder = target_folder
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to update in-memory cache for %s: %s", key, exc)
|
||||
|
||||
# Update database
|
||||
async with _get_db_session() as db:
|
||||
db_series = await AnimeSeriesService.get_by_key(db, key)
|
||||
if db_series:
|
||||
await AnimeSeriesService.update(db, series_id=db_series.id, folder=target_folder)
|
||||
logger.debug("Updated DB folder for %s to %s", key, target_folder)
|
||||
|
||||
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=target_folder, success=True, skipped=False)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Failed to rename folder for %s (%s -> %s): %s", key, old_folder, target_folder, exc)
|
||||
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason=str(exc))
|
||||
|
||||
# Static helpers — public for direct testing
|
||||
@staticmethod
|
||||
def _folder_has_year(folder_name: str) -> bool:
|
||||
if not folder_name:
|
||||
return False
|
||||
return bool(re.search(r"\(\d{4}\)", folder_name))
|
||||
|
||||
@staticmethod
|
||||
def _extract_year_from_folder_name(folder_name: str) -> Optional[int]:
|
||||
if not folder_name:
|
||||
return None
|
||||
match = re.search(r"\((\d{4})\)", folder_name)
|
||||
if match:
|
||||
try:
|
||||
year = int(match.group(1))
|
||||
if 1900 <= year <= 2100:
|
||||
return year
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_title_from_folder_name(folder_name: str) -> str:
|
||||
return re.sub(r"\s*\(\d{4}\)\s*$", "", folder_name).strip()
|
||||
|
||||
@staticmethod
|
||||
def _build_target_folder(folder_name: str, year: int) -> str:
|
||||
title = FolderNamingService._extract_title_from_folder_name(folder_name)
|
||||
return f"{title} ({year})"
|
||||
|
||||
|
||||
_folder_naming_service: Optional[FolderNamingService] = None
|
||||
|
||||
def get_folder_naming_service() -> FolderNamingService:
|
||||
global _folder_naming_service
|
||||
if _folder_naming_service is None:
|
||||
_folder_naming_service = FolderNamingService()
|
||||
return _folder_naming_service
|
||||
|
||||
def reset_folder_naming_service() -> None:
|
||||
global _folder_naming_service
|
||||
_folder_naming_service = None
|
||||
@@ -271,7 +271,7 @@ async def _load_series_into_memory(progress_service=None) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _scan_folders_to_database(progress_service=None) -> int:
|
||||
async def _scan_folders_to_database(progress_service=None) -> tuple[int, int]:
|
||||
"""Scan anime folders and create AnimeSeries DB records.
|
||||
|
||||
This function runs during initial setup only. It delegates to
|
||||
@@ -285,7 +285,7 @@ async def _scan_folders_to_database(progress_service=None) -> int:
|
||||
progress_service: Optional ProgressService for progress updates
|
||||
|
||||
Returns:
|
||||
int: Number of new series created
|
||||
Tuple of (number of new series created, number of unresolved folders)
|
||||
"""
|
||||
logger.info("Scanning anime folders for new series...")
|
||||
|
||||
@@ -293,16 +293,17 @@ async def _scan_folders_to_database(progress_service=None) -> int:
|
||||
logger.info(
|
||||
"Anime directory not configured or does not exist, skipping folder scan"
|
||||
)
|
||||
return 0
|
||||
return 0, 0
|
||||
|
||||
# Use SetupService to handle the scanning and creation
|
||||
created_count = await SetupService.run()
|
||||
created_count, unresolved_count = await SetupService.run()
|
||||
|
||||
logger.info(
|
||||
"Folder scan complete",
|
||||
created=created_count
|
||||
created=created_count,
|
||||
unresolved=unresolved_count
|
||||
)
|
||||
return created_count
|
||||
return created_count, unresolved_count
|
||||
|
||||
|
||||
async def _validate_anime_directory(progress_service=None) -> bool:
|
||||
@@ -380,7 +381,7 @@ async def perform_initial_setup(progress_service=None):
|
||||
# Perform the actual initialization
|
||||
try:
|
||||
# Scan folders and create AnimeSeries records first
|
||||
folder_scan_count = await _scan_folders_to_database(progress_service)
|
||||
folder_scan_count, unresolved_count = await _scan_folders_to_database(progress_service)
|
||||
if folder_scan_count > 0:
|
||||
logger.info("Created %d series from anime folders", folder_scan_count)
|
||||
|
||||
@@ -398,11 +399,20 @@ async def perform_initial_setup(progress_service=None):
|
||||
await _mark_initial_scan_completed()
|
||||
|
||||
# Mark loading as complete in config (used by middleware to allow redirect to /login)
|
||||
# Only mark complete if there are no unresolved folders - otherwise user needs
|
||||
# to resolve them first via /setup/unresolved
|
||||
try:
|
||||
from src.server.services.config_service import get_config_service
|
||||
config_svc = get_config_service()
|
||||
init_config = config_svc.load_config()
|
||||
init_config.other['loading_complete'] = True
|
||||
if unresolved_count == 0:
|
||||
init_config.other['loading_complete'] = True
|
||||
logger.info("No unresolved folders - marking loading complete")
|
||||
else:
|
||||
logger.info(
|
||||
"Unresolved folders exist (%d) - deferring loading_complete",
|
||||
unresolved_count
|
||||
)
|
||||
config_svc.save_config(init_config, create_backup=False)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save loading_complete flag: %s", e)
|
||||
|
||||
@@ -350,11 +350,16 @@ class NfoScanService:
|
||||
return "created"
|
||||
|
||||
# NFO exists — check if it needs updating
|
||||
updated = await self._update_nfo_if_needed(key, folder, series_data, nfo_path)
|
||||
updated, year = await self._update_nfo_if_needed(key, folder, series_data, nfo_path)
|
||||
if updated:
|
||||
await self._update_series_nfo_flag(key, has_nfo=True, nfo_path=nfo_path)
|
||||
await self._update_series_nfo_flag(key, has_nfo=True, nfo_path=nfo_path, year=year)
|
||||
return "updated"
|
||||
|
||||
# NFO is valid but series may still be missing year — try to extract from NFO
|
||||
if year is not None and series_data.get("year") is None:
|
||||
logger.info("Extracted year %d from NFO for %s — updating database", year, key)
|
||||
await self._update_series_nfo_flag(key, has_nfo=True, nfo_path=nfo_path, year=year)
|
||||
|
||||
return None
|
||||
|
||||
async def _create_nfo(
|
||||
@@ -421,7 +426,7 @@ class NfoScanService:
|
||||
folder: str,
|
||||
series_data: Dict[str, Any],
|
||||
nfo_path: str,
|
||||
) -> bool:
|
||||
) -> tuple[bool, Optional[int]]:
|
||||
"""Load existing NFO, check for missing fields, fill and rewrite.
|
||||
|
||||
Args:
|
||||
@@ -431,13 +436,13 @@ class NfoScanService:
|
||||
nfo_path: Full path to the existing NFO file
|
||||
|
||||
Returns:
|
||||
True if NFO was updated, False if no changes were needed.
|
||||
Tuple of (True if NFO was updated, extracted year from NFO or None).
|
||||
"""
|
||||
try:
|
||||
from lxml import etree
|
||||
except ImportError:
|
||||
logger.warning("lxml not available — cannot update existing NFO files")
|
||||
return False
|
||||
return False, None
|
||||
|
||||
try:
|
||||
tree = etree.parse(nfo_path)
|
||||
@@ -445,7 +450,17 @@ class NfoScanService:
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to parse existing NFO for %s: %s — will regenerate", key, exc)
|
||||
# Corrupt or unreadable NFO — regenerate from TMDB
|
||||
return await self._regenerate_nfo(key, folder, series_data, nfo_path)
|
||||
updated = await self._regenerate_nfo(key, folder, series_data, nfo_path)
|
||||
return updated, None
|
||||
|
||||
# Extract year from NFO if present
|
||||
year: Optional[int] = None
|
||||
year_elem = root.find("year")
|
||||
if year_elem is not None and year_elem.text and year_elem.text.strip():
|
||||
try:
|
||||
year = int(year_elem.text.strip())
|
||||
except ValueError:
|
||||
logger.debug("Invalid year value in NFO for %s: %s", key, year_elem.text)
|
||||
|
||||
# Check for missing or empty critical fields
|
||||
critical_fields = ["title", "plot", "premiered", "tmdbid"]
|
||||
@@ -458,7 +473,7 @@ class NfoScanService:
|
||||
|
||||
if not missing_fields:
|
||||
logger.debug("NFO for %s is complete — no update needed", key)
|
||||
return False
|
||||
return False, year
|
||||
|
||||
logger.info(
|
||||
"NFO for %s is missing fields %s — attempting to fill from TMDB",
|
||||
@@ -470,16 +485,16 @@ class NfoScanService:
|
||||
tmdb_id = series_data.get("tmdb_id")
|
||||
if not tmdb_id:
|
||||
logger.warning("Cannot update NFO for %s: no tmdb_id", key)
|
||||
return False
|
||||
return False, year
|
||||
|
||||
try:
|
||||
tmdb_data = await self._fetch_tmdb_data(tmdb_id)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch TMDB data for %s: %s", key, exc)
|
||||
return False
|
||||
return False, year
|
||||
|
||||
if not tmdb_data:
|
||||
return False
|
||||
return False, year
|
||||
|
||||
nfo_model = tmdb_to_nfo_model(
|
||||
tmdb_data,
|
||||
@@ -504,7 +519,7 @@ class NfoScanService:
|
||||
"missing_fields": missing_fields,
|
||||
})
|
||||
|
||||
return True
|
||||
return True, year
|
||||
|
||||
async def _regenerate_nfo(
|
||||
self,
|
||||
@@ -626,13 +641,15 @@ class NfoScanService:
|
||||
key: str,
|
||||
has_nfo: bool,
|
||||
nfo_path: str,
|
||||
year: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Update the has_nfo flag and nfo_path in the database.
|
||||
"""Update the has_nfo flag, nfo_path, and optionally year in the database.
|
||||
|
||||
Args:
|
||||
key: Series key (primary identifier)
|
||||
has_nfo: Whether the series now has an NFO file
|
||||
nfo_path: Path to the NFO file
|
||||
year: Optional year extracted from NFO to save to DB
|
||||
"""
|
||||
try:
|
||||
from src.server.database.connection import get_db_session
|
||||
@@ -647,6 +664,10 @@ class NfoScanService:
|
||||
if series.nfo_created_at is None:
|
||||
series.nfo_created_at = now
|
||||
series.nfo_updated_at = now
|
||||
# Update year if series has no year and we have one from NFO
|
||||
if year is not None and series.year is None:
|
||||
series.year = year
|
||||
logger.info("Updated year to %d for series: %s", year, key)
|
||||
await db.flush()
|
||||
logger.debug("Updated NFO flag for series: %s", key)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -268,6 +268,9 @@ class SchedulerService:
|
||||
"image_scan_after_rescan": (
|
||||
self._config.image_scan_after_rescan if self._config else True
|
||||
),
|
||||
"folder_naming_after_nfo_scan": (
|
||||
self._config.folder_naming_after_nfo_scan if self._config else True
|
||||
),
|
||||
"last_run": (
|
||||
self._last_scan_time.isoformat()
|
||||
if self._last_scan_time
|
||||
@@ -404,7 +407,18 @@ class SchedulerService:
|
||||
logger.error("NFO scan failed: %s", exc, exc_info=True)
|
||||
await self._broadcast("nfo_scan_error", {"error": str(exc)})
|
||||
|
||||
# 3. Auto-download (if enabled)
|
||||
# 3. Folder naming (if enabled, runs after NFO scan)
|
||||
if self._config and self._config.folder_naming_after_nfo_scan:
|
||||
if self._config.nfo_scan_after_rescan:
|
||||
# Only run if NFO scan was also enabled (depends on year in DB)
|
||||
try:
|
||||
naming_result = await self._run_folder_naming()
|
||||
await self._broadcast("folder_naming_completed", naming_result.to_dict())
|
||||
except Exception as exc:
|
||||
logger.error("Folder naming failed: %s", exc, exc_info=True)
|
||||
await self._broadcast("folder_naming_error", {"error": str(exc)})
|
||||
|
||||
# 4. Auto-download (if enabled)
|
||||
if self._config and self._config.auto_download_after_rescan:
|
||||
try:
|
||||
queued = await self._run_auto_download()
|
||||
@@ -413,7 +427,7 @@ class SchedulerService:
|
||||
logger.error("Auto-download failed: %s", exc, exc_info=True)
|
||||
await self._broadcast("auto_download_error", {"error": str(exc)})
|
||||
|
||||
# 4. Image scan (if enabled)
|
||||
# 5. Image scan (if enabled)
|
||||
if self._config and self._config.image_scan_after_rescan:
|
||||
try:
|
||||
image_result = await self._run_image_scan()
|
||||
@@ -536,6 +550,13 @@ class SchedulerService:
|
||||
)
|
||||
return result
|
||||
|
||||
async def _run_folder_naming(self) -> Any:
|
||||
"""Run folder naming fix to add missing years to folder names."""
|
||||
from src.server.services.folder_naming_service import get_folder_naming_service
|
||||
service = get_folder_naming_service()
|
||||
logger.info("Starting folder naming scan...")
|
||||
return await service.run()
|
||||
|
||||
async def _run_auto_download(self) -> int:
|
||||
"""Queue and start downloads for all series with missing episodes."""
|
||||
from src.server.models.download import EpisodeIdentifier
|
||||
|
||||
@@ -267,7 +267,7 @@ class SetupService:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def run(cls) -> int:
|
||||
async def run(cls) -> tuple[int, int]:
|
||||
"""Run the setup service.
|
||||
|
||||
Scans anime folders, creates AnimeSeries records, and resolves
|
||||
@@ -275,11 +275,11 @@ class SetupService:
|
||||
that initial scan hasn't been completed yet (via _check_initial_scan_status).
|
||||
|
||||
Returns:
|
||||
Number of new series created
|
||||
Tuple of (number of new series created, number of unresolved folders)
|
||||
"""
|
||||
if not settings.anime_directory:
|
||||
logger.info("Anime directory not configured, skipping setup")
|
||||
return 0
|
||||
return 0, 0
|
||||
|
||||
anime_dir = Path(settings.anime_directory)
|
||||
if not anime_dir.is_dir():
|
||||
@@ -287,7 +287,7 @@ class SetupService:
|
||||
"Anime directory does not exist, skipping setup: %s",
|
||||
anime_dir
|
||||
)
|
||||
return 0
|
||||
return 0, 0
|
||||
|
||||
logger.info("Running setup service...")
|
||||
|
||||
@@ -385,6 +385,7 @@ class SetupService:
|
||||
"Could not resolve series key for folder, tracking as unresolved: %s",
|
||||
folder_name
|
||||
)
|
||||
unresolved_count += 1
|
||||
continue
|
||||
|
||||
# Also check if a series with this key already exists (different folder, same anime)
|
||||
@@ -445,6 +446,6 @@ class SetupService:
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
return created_count
|
||||
return created_count, unresolved_count
|
||||
|
||||
return created_count
|
||||
return created_count, unresolved_count
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,11 @@ AniWorld.SchedulerConfig = (function() {
|
||||
autoDownload.checked = config.auto_download_after_rescan || false;
|
||||
}
|
||||
|
||||
const folderNaming = document.getElementById('folder-scan-enabled');
|
||||
if (folderNaming) {
|
||||
folderNaming.checked = config.folder_naming_after_nfo_scan || false;
|
||||
}
|
||||
|
||||
// Update schedule day checkboxes
|
||||
const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun'];
|
||||
['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) {
|
||||
@@ -87,7 +92,8 @@ AniWorld.SchedulerConfig = (function() {
|
||||
enabled: enabled,
|
||||
schedule_time: scheduleTime,
|
||||
schedule_days: scheduleDays,
|
||||
auto_download_after_rescan: autoDownload
|
||||
auto_download_after_rescan: autoDownload,
|
||||
folder_naming_after_nfo_scan: document.getElementById('folder-scan-enabled') ? document.getElementById('folder-scan-enabled').checked : false
|
||||
};
|
||||
|
||||
const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload);
|
||||
|
||||
@@ -451,8 +451,9 @@
|
||||
updateStep(stepId, status, msg, percent, current, total);
|
||||
|
||||
// Check for completion of series_sync
|
||||
// stepId is used because type is 'system_progress' for SYSTEM progress events
|
||||
if (metadata?.initialization_complete || (stepId === 'series_sync' && status === 'completed')) {
|
||||
// For scan_completed messages: stepId='scan_completed', no status field, no metadata
|
||||
// system_progress events are emitted by progress_service during initial setup (ProgressType.SYSTEM)
|
||||
if (metadata?.initialization_complete || type === 'scan_completed' || type === 'system_progress' || (stepId === 'series_sync' && status === 'completed')) {
|
||||
// For initial phase, series_sync completion leads to /setup/unresolved
|
||||
handleSeriesSyncComplete();
|
||||
}
|
||||
@@ -720,10 +721,42 @@
|
||||
createStep('nfo_scan', stepTitles['nfo_scan']);
|
||||
// Trigger NFO scan phase via API
|
||||
triggerNfoScanPhase();
|
||||
connectWebSocket();
|
||||
} else {
|
||||
// For initial phase, initialization already completed before this page loaded
|
||||
// Check for unresolved folders immediately and redirect
|
||||
checkUnresolvedAndRedirect();
|
||||
}
|
||||
|
||||
connectWebSocket();
|
||||
});
|
||||
|
||||
// For initial phase, check if there are unresolved folders and redirect accordingly
|
||||
// This is needed because the backend initialization completes before this page loads,
|
||||
// so WebSocket events are missed
|
||||
async function checkUnresolvedAndRedirect() {
|
||||
try {
|
||||
const response = await fetch('/api/setup/unresolved');
|
||||
if (response.ok) {
|
||||
const folders = await response.json();
|
||||
if (folders.length > 0) {
|
||||
// Unresolved folders exist - redirect to unresolved page
|
||||
clearSetupPhase();
|
||||
window.location.href = '/setup/unresolved';
|
||||
} else {
|
||||
// No unresolved folders - redirect to login
|
||||
clearSetupPhase();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} else {
|
||||
// Error - stay on page and wait for potential WebSocket events
|
||||
console.error('Failed to check unresolved folders:', response.status);
|
||||
connectWebSocket();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking unresolved folders:', error);
|
||||
// Stay on page and wait for WebSocket events
|
||||
connectWebSocket();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -479,6 +479,13 @@
|
||||
<span>Auto-download missing episodes after rescan</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-checkbox">
|
||||
<input type="checkbox" id="scheduler_folder_naming" name="scheduler_folder_naming">
|
||||
<span>Fix missing years in folder names after NFO refresh</span>
|
||||
</label>
|
||||
<div class="form-help">Renames folders (e.g. "Naruto" → "Naruto (1999)") using the year from the database.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -761,6 +768,7 @@
|
||||
scheduler_schedule_time: document.getElementById('scheduler_schedule_time').value || '03:00',
|
||||
scheduler_schedule_days: Array.from(document.querySelectorAll('.scheduler-day-setup-cb:checked')).map(cb => cb.value),
|
||||
scheduler_auto_download_after_rescan: document.getElementById('scheduler_auto_download').checked,
|
||||
scheduler_folder_naming_after_nfo_scan: document.getElementById('scheduler_folder_naming').checked,
|
||||
logging_level: document.getElementById('logging_level').value,
|
||||
logging_file: document.getElementById('logging_file').value.trim() || null,
|
||||
logging_max_bytes: document.getElementById('logging_max_bytes').value ?
|
||||
|
||||
344
tests/unit/test_folder_naming_service.py
Normal file
344
tests/unit/test_folder_naming_service.py
Normal file
@@ -0,0 +1,344 @@
|
||||
"""Tests for FolderNamingService."""
|
||||
import os
|
||||
import re
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.services.folder_naming_service import (
|
||||
FolderNamingService,
|
||||
FolderRenameResult,
|
||||
FolderNamingReport,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Static method tests
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
class TestFolderHasYear:
|
||||
"""Tests for _folder_has_year."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("folder", "expected"),
|
||||
[
|
||||
("Naruto", False),
|
||||
("Naruto (2020)", True),
|
||||
("Attack on Titan (2013)", True),
|
||||
("", False),
|
||||
("One Piece", False),
|
||||
("(2020)", True),
|
||||
("Naruto 2020", False), # no parentheses
|
||||
("Naruto (20)", False), # too short
|
||||
("Naruto (abcd)", False), # not digits
|
||||
],
|
||||
)
|
||||
def test_folder_has_year(self, folder, expected):
|
||||
assert FolderNamingService._folder_has_year(folder) == expected
|
||||
|
||||
|
||||
class TestExtractYearFromFolderName:
|
||||
"""Tests for _extract_year_from_folder_name."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("folder", "expected"),
|
||||
[
|
||||
("Naruto", None),
|
||||
("Naruto (2020)", 2020),
|
||||
("Attack on Titan (2013)", 2013),
|
||||
("", None),
|
||||
("(2020)", 2020),
|
||||
("Title (1800)", None), # out of range
|
||||
("Title (2200)", None), # out of range
|
||||
("Title (2020) - Extra", 2020), # year in middle
|
||||
],
|
||||
)
|
||||
def test_extract_year(self, folder, expected):
|
||||
assert FolderNamingService._extract_year_from_folder_name(folder) == expected
|
||||
|
||||
|
||||
class TestExtractTitleFromFolderName:
|
||||
"""Tests for _extract_title_from_folder_name."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("folder", "expected"),
|
||||
[
|
||||
("Naruto", "Naruto"),
|
||||
("Naruto (2020)", "Naruto"),
|
||||
("Attack on Titan (2013)", "Attack on Titan"),
|
||||
("", ""),
|
||||
("Naruto (2020) ", "Naruto"), # extra spaces
|
||||
("Title (2020) - Extra", "Title (2020) - Extra"), # year in middle not removed
|
||||
],
|
||||
)
|
||||
def test_extract_title(self, folder, expected):
|
||||
assert FolderNamingService._extract_title_from_folder_name(folder) == expected
|
||||
|
||||
|
||||
class TestBuildTargetFolder:
|
||||
"""Tests for _build_target_folder — the critical double-year prevention."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("folder", "year", "expected"),
|
||||
[
|
||||
# No year → add year
|
||||
("Naruto", 1999, "Naruto (1999)"),
|
||||
("One Piece", 1999, "One Piece (1999)"),
|
||||
# Has year → strip and replace
|
||||
("Naruto (2020)", 1999, "Naruto (1999)"),
|
||||
("Naruto (2020)", 2020, "Naruto (2020)"),
|
||||
# Has year with extra spaces
|
||||
("Naruto (2020)", 1999, "Naruto (1999)"),
|
||||
# Title with inner parens (not a year) → preserved
|
||||
("Dragon Ball (Super)", 1989, "Dragon Ball (Super) (1989)"),
|
||||
],
|
||||
)
|
||||
def test_build_target_folder(self, folder, year, expected):
|
||||
assert FolderNamingService._build_target_folder(folder, year) == expected
|
||||
|
||||
def test_repeated_calls_do_not_accumulate_years(self):
|
||||
"""Ensures safe: Naruto -> (1999) -> (1999) stays (1999), not (1999)(1999)."""
|
||||
folder = "Naruto"
|
||||
year = 1999
|
||||
step1 = FolderNamingService._build_target_folder(folder, year)
|
||||
step2 = FolderNamingService._build_target_folder(step1, year)
|
||||
step3 = FolderNamingService._build_target_folder(step2, year)
|
||||
assert step1 == "Naruto (1999)"
|
||||
assert step2 == "Naruto (1999)"
|
||||
assert step3 == "Naruto (1999)"
|
||||
|
||||
def test_already_yeared_folder_year_replaced_not_appended(self):
|
||||
"""Folder 'Naruto (2020)' with year=1999 → 'Naruto (1999)', not 'Naruto (2020) (1999)'."""
|
||||
result = FolderNamingService._build_target_folder("Naruto (2020)", 1999)
|
||||
assert result == "Naruto (1999)"
|
||||
assert "2020" not in result
|
||||
|
||||
def test_different_years_each_call_is_safe(self):
|
||||
"""Multiple different years applied sequentially always produce clean name."""
|
||||
folder = "Naruto"
|
||||
r1 = FolderNamingService._build_target_folder(folder, 1999)
|
||||
r2 = FolderNamingService._build_target_folder(r1, 1997)
|
||||
r3 = FolderNamingService._build_target_folder(r2, 1999)
|
||||
assert r1 == "Naruto (1999)"
|
||||
assert r2 == "Naruto (1997)"
|
||||
assert r3 == "Naruto (1999)"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Service tests (need mock DB + filesystem)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_session():
|
||||
"""Mock async DB session."""
|
||||
with patch("src.server.services.folder_naming_service._get_db_session") as mock:
|
||||
session = AsyncMock()
|
||||
mock.return_value.__aenter__.return_value = session
|
||||
mock.return_value.__aexit__.return_value = None
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_series(flash=False):
|
||||
"""Factory for mock anime series objects."""
|
||||
def _make(key, folder, year):
|
||||
series = MagicMock()
|
||||
series.key = key
|
||||
series.folder = folder
|
||||
series.year = year
|
||||
return series
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings(tmp_path):
|
||||
"""Mock settings with a temp anime directory."""
|
||||
with patch("src.server.services.folder_naming_service.settings") as mock:
|
||||
mock.anime_directory = str(tmp_path)
|
||||
yield mock
|
||||
|
||||
|
||||
class TestFolderNamingServiceIntegration:
|
||||
"""Integration tests with mocked filesystem and DB."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_folder_already_has_year(
|
||||
self, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""Folder 'Naruto (1999)' with DB year=1999 → skipped."""
|
||||
series = mock_series("key1", "Naruto (1999)", 1999)
|
||||
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
||||
|
||||
# Mock AnimeSeriesService.get_all
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all:
|
||||
mock_get_all.return_value = [series]
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
assert report.total == 1
|
||||
assert report.skipped == 1
|
||||
assert report.renamed == 0
|
||||
assert report.errors == 0
|
||||
assert report.results[0].reason == "folder already has year"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_db_has_no_year(
|
||||
self, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""Folder 'Naruto' with DB year=None → skipped."""
|
||||
series = mock_series("key1", "Naruto", None)
|
||||
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
||||
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all:
|
||||
mock_get_all.return_value = [series]
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
assert report.skipped == 1
|
||||
assert report.results[0].reason == "no year in DB record"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_renames_folder_and_updates_db(
|
||||
self, tmp_path, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""Folder 'Naruto' with DB year=1999 → renames to 'Naruto (1999)'."""
|
||||
anime_dir = tmp_path
|
||||
(anime_dir / "Naruto").mkdir()
|
||||
(anime_dir / "Naruto" / "episode1.mp4").touch()
|
||||
|
||||
series = mock_series("key1", "Naruto", 1999)
|
||||
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
||||
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock) as mock_update, \
|
||||
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
|
||||
|
||||
mock_get_all.return_value = [series]
|
||||
db_series = MagicMock()
|
||||
db_series.id = 42
|
||||
mock_get_by_key.return_value = db_series
|
||||
|
||||
app_instance = MagicMock()
|
||||
app_instance.list.keyDict = {"key1": MagicMock()}
|
||||
mock_get_app.return_value = app_instance
|
||||
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
assert report.renamed == 1
|
||||
assert report.skipped == 0
|
||||
assert report.errors == 0
|
||||
assert report.results[0].new_folder == "Naruto (1999)"
|
||||
assert (anime_dir / "Naruto (1999)").exists()
|
||||
assert not (anime_dir / "Naruto").exists()
|
||||
mock_update.assert_called_once()
|
||||
call_kwargs = mock_update.call_args.kwargs
|
||||
assert call_kwargs["folder"] == "Naruto (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_target_folder_already_exists(
|
||||
self, tmp_path, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""If 'Naruto (1999)' already exists, rename is skipped."""
|
||||
anime_dir = tmp_path
|
||||
(anime_dir / "Naruto").mkdir()
|
||||
(anime_dir / "Naruto (1999)").mkdir() # target already exists
|
||||
|
||||
series = mock_series("key1", "Naruto", 1999)
|
||||
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
||||
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all:
|
||||
mock_get_all.return_value = [series]
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
assert report.errors == 1
|
||||
assert report.renamed == 0
|
||||
assert report.results[0].reason == "target folder already exists on disk"
|
||||
assert (anime_dir / "Naruto").exists() # source not moved
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_guard_detects_wrong_year_in_target(self, tmp_path, mock_db_session, mock_series, mock_settings):
|
||||
"""Safety guard triggers when _build_target_folder produces wrong year.
|
||||
|
||||
Uses a folder without a detectible year so _process_series proceeds to
|
||||
build a target, then the safety re-check finds a mismatch.
|
||||
"""
|
||||
anime_dir = tmp_path
|
||||
(anime_dir / "Naruto").mkdir()
|
||||
|
||||
series = mock_series("key1", "Naruto", 1999)
|
||||
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
||||
|
||||
# Patch _build_target_folder to return a folder whose extracted year != DB year
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
|
||||
patch.object(FolderNamingService, "_build_target_folder", return_value="Naruto (2020)"):
|
||||
mock_get_all.return_value = [series]
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
# Should be skipped by safety guard
|
||||
assert report.results[0].skipped is True
|
||||
assert "safety guard" in report.results[0].reason.lower()
|
||||
# Folder should NOT have been renamed
|
||||
assert (anime_dir / "Naruto").exists()
|
||||
assert not (anime_dir / "Naruto (2020)").exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_series_mixed_results(
|
||||
self, tmp_path, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""Series 1 needs rename, series 2 is skipped (has year), series 3 has no year."""
|
||||
anime_dir = tmp_path
|
||||
(anime_dir / "Naruto").mkdir()
|
||||
(anime_dir / "One Piece (1999)").mkdir()
|
||||
(anime_dir / "Bleach").mkdir()
|
||||
|
||||
series = [
|
||||
mock_series("key1", "Naruto", 1999),
|
||||
mock_series("key2", "One Piece (1999)", 1999),
|
||||
mock_series("key3", "Bleach", None),
|
||||
]
|
||||
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
||||
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock), \
|
||||
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
|
||||
|
||||
mock_get_all.return_value = series
|
||||
db_series = MagicMock()
|
||||
db_series.id = 1
|
||||
mock_get_by_key.return_value = db_series
|
||||
|
||||
app_instance = MagicMock()
|
||||
app_instance.list.keyDict = {"key1": MagicMock()}
|
||||
mock_get_app.return_value = app_instance
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
assert report.total == 3
|
||||
assert report.renamed == 1 # Naruto → Naruto (1999)
|
||||
assert report.skipped == 2 # One Piece (has year), Bleach (no year)
|
||||
assert report.errors == 0
|
||||
|
||||
|
||||
class TestFolderNamingReport:
|
||||
def test_to_dict(self):
|
||||
result = FolderRenameResult(key="k1", old_folder="Naruto", new_folder="Naruto (1999)", success=True, skipped=False)
|
||||
report = FolderNamingReport(total=1, renamed=1, skipped=0, errors=0, results=[result])
|
||||
d = report.to_dict()
|
||||
assert d["total"] == 1
|
||||
assert d["renamed"] == 1
|
||||
assert d["results"][0]["new_folder"] == "Naruto (1999)"
|
||||
@@ -771,13 +771,13 @@ class TestScanFoldersToDatabase:
|
||||
) as mock_settings, \
|
||||
patch(
|
||||
'src.server.services.initialization_service.SetupService.run',
|
||||
new_callable=AsyncMock, return_value=1
|
||||
new_callable=AsyncMock, return_value=(1, 0)
|
||||
) as mock_setup_run:
|
||||
mock_settings.anime_directory = anime_dir
|
||||
|
||||
result = await _scan_folders_to_database()
|
||||
|
||||
assert result == 1
|
||||
assert result == (1, 0)
|
||||
mock_setup_run.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -798,13 +798,13 @@ class TestScanFoldersToDatabase:
|
||||
) as mock_settings, \
|
||||
patch(
|
||||
'src.server.services.initialization_service.SetupService.run',
|
||||
new_callable=AsyncMock, return_value=1
|
||||
new_callable=AsyncMock, return_value=(1, 0)
|
||||
) as mock_setup_run:
|
||||
mock_settings.anime_directory = anime_dir
|
||||
|
||||
result = await _scan_folders_to_database()
|
||||
|
||||
assert result == 1
|
||||
assert result == (1, 0)
|
||||
mock_setup_run.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -825,13 +825,13 @@ class TestScanFoldersToDatabase:
|
||||
) as mock_settings, \
|
||||
patch(
|
||||
'src.server.services.initialization_service.SetupService.run',
|
||||
new_callable=AsyncMock, return_value=1
|
||||
new_callable=AsyncMock, return_value=(1, 0)
|
||||
) as mock_setup_run:
|
||||
mock_settings.anime_directory = anime_dir
|
||||
|
||||
result = await _scan_folders_to_database()
|
||||
|
||||
assert result == 1
|
||||
assert result == (1, 0)
|
||||
mock_setup_run.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -852,13 +852,13 @@ class TestScanFoldersToDatabase:
|
||||
) as mock_settings, \
|
||||
patch(
|
||||
'src.server.services.initialization_service.SetupService.run',
|
||||
new_callable=AsyncMock, return_value=1
|
||||
new_callable=AsyncMock, return_value=(1, 0)
|
||||
) as mock_setup_run:
|
||||
mock_settings.anime_directory = anime_dir
|
||||
|
||||
result = await _scan_folders_to_database()
|
||||
|
||||
assert result == 1
|
||||
assert result == (1, 0)
|
||||
mock_setup_run.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -879,13 +879,13 @@ class TestScanFoldersToDatabase:
|
||||
) as mock_settings, \
|
||||
patch(
|
||||
'src.server.services.initialization_service.SetupService.run',
|
||||
new_callable=AsyncMock, return_value=1
|
||||
new_callable=AsyncMock, return_value=(1, 0)
|
||||
) as mock_setup_run:
|
||||
mock_settings.anime_directory = anime_dir
|
||||
|
||||
result = await _scan_folders_to_database()
|
||||
|
||||
assert result == 1
|
||||
assert result == (1, 0)
|
||||
mock_setup_run.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -906,13 +906,13 @@ class TestScanFoldersToDatabase:
|
||||
) as mock_settings, \
|
||||
patch(
|
||||
'src.server.services.initialization_service.SetupService.run',
|
||||
new_callable=AsyncMock, return_value=0
|
||||
new_callable=AsyncMock, return_value=(0, 0)
|
||||
) as mock_setup_run:
|
||||
mock_settings.anime_directory = anime_dir
|
||||
|
||||
result = await _scan_folders_to_database()
|
||||
|
||||
assert result == 0
|
||||
assert result == (0, 0)
|
||||
mock_setup_run.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -923,11 +923,11 @@ class TestScanFoldersToDatabase:
|
||||
) as mock_settings, \
|
||||
patch(
|
||||
'src.server.services.initialization_service.SetupService.run',
|
||||
new_callable=AsyncMock, return_value=0
|
||||
new_callable=AsyncMock, return_value=(0, 0)
|
||||
) as mock_setup_run:
|
||||
mock_settings.anime_directory = None
|
||||
|
||||
result = await _scan_folders_to_database()
|
||||
|
||||
assert result == 0
|
||||
assert result == (0, 0)
|
||||
mock_setup_run.assert_not_called()
|
||||
|
||||
@@ -94,7 +94,25 @@ class TestResolveKeyViaSearch:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_when_multiple_results(self):
|
||||
"""Search returns >1 results → returns empty string."""
|
||||
"""Search returns >1 results but none fuzzy-match → returns empty string."""
|
||||
mock_series_app = AsyncMock()
|
||||
mock_series_app.search.return_value = [
|
||||
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'},
|
||||
{'title': 'Totally Different Series', 'link': '/anime/stream/totally-different'}
|
||||
]
|
||||
|
||||
with patch(
|
||||
'src.server.services.setup_service.get_series_app',
|
||||
return_value=mock_series_app
|
||||
):
|
||||
result = await SetupService._resolve_key_via_search("Attack on Titan")
|
||||
|
||||
# Fuzzy match against "Attack on Titan" succeeds → returns the key
|
||||
assert result == 'attack-on-titan'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_key_when_fuzzy_match_in_multiple_results(self):
|
||||
"""Search returns >1 results with one fuzzy match → returns that result's key."""
|
||||
mock_series_app = AsyncMock()
|
||||
mock_series_app.search.return_value = [
|
||||
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'},
|
||||
@@ -107,7 +125,8 @@ class TestResolveKeyViaSearch:
|
||||
):
|
||||
result = await SetupService._resolve_key_via_search("Attack on Titan")
|
||||
|
||||
assert result == ''
|
||||
# "Attack on Titan" fuzzy-matches "Attack on Titan" → returns its key
|
||||
assert result == 'attack-on-titan'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_when_name_mismatch(self):
|
||||
@@ -207,7 +226,7 @@ class TestSetupServiceRun:
|
||||
):
|
||||
result = await SetupService.run()
|
||||
|
||||
assert result == 1
|
||||
assert result == (1, 0)
|
||||
mock_create.assert_called_once()
|
||||
call_kwargs = mock_create.call_args.kwargs
|
||||
assert call_kwargs['key'] == 'attack-on-titan'
|
||||
@@ -250,7 +269,7 @@ class TestSetupServiceRun:
|
||||
|
||||
result = await SetupService.run()
|
||||
|
||||
assert result == 0
|
||||
assert result == (0, 0)
|
||||
mock_create.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -359,8 +378,8 @@ class TestSetupServiceRun:
|
||||
|
||||
result = await SetupService.run()
|
||||
|
||||
# Should return 0 since no series was created
|
||||
assert result == 0
|
||||
# created=0 (no series), unresolved=1 (folder couldn't be resolved)
|
||||
assert result == (0, 1)
|
||||
# Should track as unresolved instead of creating series
|
||||
mock_create_unresolved.assert_called_once()
|
||||
call_kwargs = mock_create_unresolved.call_args.kwargs
|
||||
@@ -378,7 +397,7 @@ class TestSetupServiceRun:
|
||||
|
||||
result = await SetupService.run()
|
||||
|
||||
assert result == 0
|
||||
assert result == (0, 0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_zero_when_directory_not_exist(self, tmp_path):
|
||||
@@ -392,7 +411,7 @@ class TestSetupServiceRun:
|
||||
|
||||
result = await SetupService.run()
|
||||
|
||||
assert result == 0
|
||||
assert result == (0, 0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_files_only_processes_directories(self, tmp_path):
|
||||
@@ -442,7 +461,7 @@ class TestSetupServiceRun:
|
||||
result = await SetupService.run()
|
||||
|
||||
# Empty search results → folder tracked as unresolved, not created
|
||||
assert result == 0
|
||||
assert result == (0, 1)
|
||||
mock_create_unresolved.assert_called_once()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user