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:
2026-07-26 21:45:08 +02:00
parent a384072901
commit 5f46d2e802
9 changed files with 2974 additions and 2447 deletions

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

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

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

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

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