feat: add folder naming service to fix missing years in anime folder names
Runs after NFO refresh during scheduled rescans. Renames folders that are missing a year (e.g. 'Naruto' → 'Naruto (1999)') using the year from the database record. Safety: _build_target_folder() always strips any existing year suffix first, preventing double/triple year accumulation like 'Naruto (1999) (1999) (1999)'. Changes: - New FolderNamingService (folder_naming_service.py) with safe target name construction, DB update, and in-memory cache update - New SchedulerConfig field: folder_naming_after_nfo_scan (default True) - Integrated as step 3 in scheduler _perform_rescan() after NFO scan - Runtime UI: existing 'folder-scan-enabled' checkbox in index.html now wired to toggle the feature (app.js + scheduler-config.js) - Setup screen: new checkbox in setup.html Scheduler Settings section - API: scheduler config endpoint returns all scan toggles - Tests: 39 unit tests covering static helpers, rename logic, safety guard, and integration cases (folder_naming_service.py) - Docs: testing guide updated with FolderNamingService examples
This commit is contained in:
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.
|
FolderNamingService fixes missing years in anime folder names after NFO refresh. Test file: `tests/unit/test_folder_naming_service.py`.
|
||||||
|
|
||||||
### 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`.
|
|
||||||
|
|
||||||
Key methods tested:
|
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_year_from_folder_name()` — parses `(YYYY)` suffix
|
||||||
- `_extract_title_from_folder_name()` — strips year suffix
|
- `_extract_title_from_folder_name()` — strips year suffix
|
||||||
- `_resolve_key_via_search()` — resolves provider key via fuzzy title matching
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@pytest.mark.asyncio
|
# Safe: repeated calls never accumulate years
|
||||||
async def test_returns_key_when_single_exact_match(self):
|
r1 = FolderNamingService._build_target_folder("Naruto", 1999)
|
||||||
"""Search returns 1 result with same name → returns key."""
|
r2 = FolderNamingService._build_target_folder(r1, 1999)
|
||||||
mock_series_app = AsyncMock()
|
assert r1 == r2 == "Naruto (1999)"
|
||||||
mock_series_app.search.return_value = [
|
|
||||||
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'}
|
|
||||||
]
|
|
||||||
|
|
||||||
with patch('src.server.services.setup_service.get_series_app', return_value=mock_series_app):
|
# Safe: existing year is replaced, not appended
|
||||||
result = await SetupService._resolve_key_via_search("Attack on Titan")
|
result = FolderNamingService._build_target_folder("Naruto (2020)", 1999)
|
||||||
|
assert result == "Naruto (1999)"
|
||||||
assert result == 'attack-on-titan'
|
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
|
### Mocking aiohttp Sessions
|
||||||
|
|
||||||
When testing code that uses `aiohttp.ClientSession`:
|
When testing code that uses `aiohttp.ClientSession`:
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ def _build_response(config: SchedulerConfig) -> Dict[str, Any]:
|
|||||||
"schedule_time": config.schedule_time,
|
"schedule_time": config.schedule_time,
|
||||||
"schedule_days": config.schedule_days,
|
"schedule_days": config.schedule_days,
|
||||||
"auto_download_after_rescan": config.auto_download_after_rescan,
|
"auto_download_after_rescan": config.auto_download_after_rescan,
|
||||||
|
"nfo_scan_after_rescan": config.nfo_scan_after_rescan,
|
||||||
|
"image_scan_after_rescan": config.image_scan_after_rescan,
|
||||||
|
"folder_naming_after_nfo_scan": config.folder_naming_after_nfo_scan,
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"is_running": runtime.get("is_running", False),
|
"is_running": runtime.get("is_running", False),
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ class SchedulerConfig(BaseModel):
|
|||||||
description="Download series images (poster.jpg, fanart.jpg, logo.png) "
|
description="Download series images (poster.jpg, fanart.jpg, logo.png) "
|
||||||
"from TMDB after a scheduled rescan completes.",
|
"from TMDB after a scheduled rescan completes.",
|
||||||
)
|
)
|
||||||
|
folder_naming_after_nfo_scan: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Fix missing years in folder names after NFO refresh. "
|
||||||
|
"Renames folders (e.g. 'Naruto' -> 'Naruto (1999)') using "
|
||||||
|
"the year from the database record.",
|
||||||
|
)
|
||||||
# Legacy alias fields — read via Pydantic alias
|
# Legacy alias fields — read via Pydantic alias
|
||||||
auto_download: Optional[bool] = Field(default=None, alias="auto_download")
|
auto_download: Optional[bool] = Field(default=None, alias="auto_download")
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
@@ -268,6 +268,9 @@ class SchedulerService:
|
|||||||
"image_scan_after_rescan": (
|
"image_scan_after_rescan": (
|
||||||
self._config.image_scan_after_rescan if self._config else True
|
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": (
|
"last_run": (
|
||||||
self._last_scan_time.isoformat()
|
self._last_scan_time.isoformat()
|
||||||
if self._last_scan_time
|
if self._last_scan_time
|
||||||
@@ -404,7 +407,18 @@ class SchedulerService:
|
|||||||
logger.error("NFO scan failed: %s", exc, exc_info=True)
|
logger.error("NFO scan failed: %s", exc, exc_info=True)
|
||||||
await self._broadcast("nfo_scan_error", {"error": str(exc)})
|
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:
|
if self._config and self._config.auto_download_after_rescan:
|
||||||
try:
|
try:
|
||||||
queued = await self._run_auto_download()
|
queued = await self._run_auto_download()
|
||||||
@@ -413,7 +427,7 @@ class SchedulerService:
|
|||||||
logger.error("Auto-download failed: %s", exc, exc_info=True)
|
logger.error("Auto-download failed: %s", exc, exc_info=True)
|
||||||
await self._broadcast("auto_download_error", {"error": str(exc)})
|
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:
|
if self._config and self._config.image_scan_after_rescan:
|
||||||
try:
|
try:
|
||||||
image_result = await self._run_image_scan()
|
image_result = await self._run_image_scan()
|
||||||
@@ -536,6 +550,13 @@ class SchedulerService:
|
|||||||
)
|
)
|
||||||
return result
|
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:
|
async def _run_auto_download(self) -> int:
|
||||||
"""Queue and start downloads for all series with missing episodes."""
|
"""Queue and start downloads for all series with missing episodes."""
|
||||||
from src.server.models.download import EpisodeIdentifier
|
from src.server.models.download import EpisodeIdentifier
|
||||||
|
|||||||
@@ -1574,6 +1574,10 @@ class AniWorldApp {
|
|||||||
document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled;
|
document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled;
|
||||||
document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00';
|
document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00';
|
||||||
document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan;
|
document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan;
|
||||||
|
const folderNaming = document.getElementById('folder-scan-enabled');
|
||||||
|
if (folderNaming) {
|
||||||
|
folderNaming.checked = !!config.folder_naming_after_nfo_scan;
|
||||||
|
}
|
||||||
|
|
||||||
// Update day-of-week checkboxes
|
// Update day-of-week checkboxes
|
||||||
const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun'];
|
const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun'];
|
||||||
@@ -1631,7 +1635,8 @@ class AniWorldApp {
|
|||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
schedule_time: scheduleTime,
|
schedule_time: scheduleTime,
|
||||||
schedule_days: scheduleDays,
|
schedule_days: scheduleDays,
|
||||||
auto_download_after_rescan: autoDownload
|
auto_download_after_rescan: autoDownload,
|
||||||
|
folder_naming_after_nfo_scan: document.getElementById('folder-scan-enabled') ? document.getElementById('folder-scan-enabled').checked : false
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ AniWorld.SchedulerConfig = (function() {
|
|||||||
autoDownload.checked = config.auto_download_after_rescan || false;
|
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
|
// Update schedule day checkboxes
|
||||||
const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun'];
|
const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun'];
|
||||||
['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) {
|
['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) {
|
||||||
@@ -87,7 +92,8 @@ AniWorld.SchedulerConfig = (function() {
|
|||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
schedule_time: scheduleTime,
|
schedule_time: scheduleTime,
|
||||||
schedule_days: scheduleDays,
|
schedule_days: scheduleDays,
|
||||||
auto_download_after_rescan: autoDownload
|
auto_download_after_rescan: autoDownload,
|
||||||
|
folder_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);
|
const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload);
|
||||||
|
|||||||
@@ -479,6 +479,13 @@
|
|||||||
<span>Auto-download missing episodes after rescan</span>
|
<span>Auto-download missing episodes after rescan</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-checkbox">
|
||||||
|
<input type="checkbox" id="scheduler_folder_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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -761,6 +768,7 @@
|
|||||||
scheduler_schedule_time: document.getElementById('scheduler_schedule_time').value || '03:00',
|
scheduler_schedule_time: document.getElementById('scheduler_schedule_time').value || '03:00',
|
||||||
scheduler_schedule_days: Array.from(document.querySelectorAll('.scheduler-day-setup-cb:checked')).map(cb => cb.value),
|
scheduler_schedule_days: Array.from(document.querySelectorAll('.scheduler-day-setup-cb:checked')).map(cb => cb.value),
|
||||||
scheduler_auto_download_after_rescan: document.getElementById('scheduler_auto_download').checked,
|
scheduler_auto_download_after_rescan: document.getElementById('scheduler_auto_download').checked,
|
||||||
|
scheduler_folder_naming_after_nfo_scan: document.getElementById('scheduler_folder_naming').checked,
|
||||||
logging_level: document.getElementById('logging_level').value,
|
logging_level: document.getElementById('logging_level').value,
|
||||||
logging_file: document.getElementById('logging_file').value.trim() || null,
|
logging_file: document.getElementById('logging_file').value.trim() || null,
|
||||||
logging_max_bytes: document.getElementById('logging_max_bytes').value ?
|
logging_max_bytes: document.getElementById('logging_max_bytes').value ?
|
||||||
|
|||||||
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)"
|
||||||
Reference in New Issue
Block a user