Compare commits

...

5 Commits

Author SHA1 Message Date
e7628ac44c chore: bump version 2026-07-30 20:10:21 +02:00
f89e403a17 chore: bump version 2026-07-26 21:46:51 +02:00
5f46d2e802 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
2026-07-26 21:45:08 +02:00
a384072901 fix: queue background loading after folder resolve; extract year from NFO 2026-07-26 20:02:02 +02:00
d99636e9c7 fix: redirect to /setup/unresolved after series scan completes
- Add 'system_progress' event type to loading page redirect condition
- Add checkUnresolvedAndRedirect() for phase=initial to handle race condition
  where backend initialization completes before WebSocket connects
- Backend now emits series_sync progress events during initial setup
- Loading page checks /api/setup/unresolved immediately on load for phase=initial
- Fixes users getting stuck on loading page after setup
2026-07-15 22:06:40 +02:00
18 changed files with 3136 additions and 2502 deletions

View File

@@ -1 +1 @@
v1.4.17
v1.5.1

View File

@@ -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`:

View File

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

View File

@@ -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),

View File

@@ -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}",

View File

@@ -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")

View 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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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>

View File

@@ -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 ?

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

View File

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

View File

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