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:
@@ -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),
|
||||
|
||||
@@ -52,6 +52,12 @@ class SchedulerConfig(BaseModel):
|
||||
description="Download series images (poster.jpg, fanart.jpg, logo.png) "
|
||||
"from TMDB after a scheduled rescan completes.",
|
||||
)
|
||||
folder_naming_after_nfo_scan: bool = Field(
|
||||
default=True,
|
||||
description="Fix missing years in folder names after NFO refresh. "
|
||||
"Renames folders (e.g. 'Naruto' -> 'Naruto (1999)') using "
|
||||
"the year from the database record.",
|
||||
)
|
||||
# Legacy alias fields — read via Pydantic alias
|
||||
auto_download: Optional[bool] = Field(default=None, alias="auto_download")
|
||||
|
||||
|
||||
205
src/server/services/folder_naming_service.py
Normal file
205
src/server/services/folder_naming_service.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Folder naming service for fixing missing years in anime folder names."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import structlog
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.server.database.connection import get_db_session as _get_db_session
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FolderRenameResult:
|
||||
key: str
|
||||
old_folder: str
|
||||
new_folder: Optional[str]
|
||||
success: bool
|
||||
skipped: bool = False
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FolderNamingReport:
|
||||
total: int
|
||||
renamed: int
|
||||
skipped: int
|
||||
errors: int
|
||||
results: List[FolderRenameResult]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"total": self.total,
|
||||
"renamed": self.renamed,
|
||||
"skipped": self.skipped,
|
||||
"errors": self.errors,
|
||||
"results": [
|
||||
{
|
||||
"key": r.key,
|
||||
"old_folder": r.old_folder,
|
||||
"new_folder": r.new_folder,
|
||||
"success": r.success,
|
||||
"skipped": r.skipped,
|
||||
"reason": r.reason,
|
||||
}
|
||||
for r in self.results
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class FolderNamingService:
|
||||
def __init__(self) -> None:
|
||||
self._is_running = False
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def run(self) -> FolderNamingReport:
|
||||
async with self._lock:
|
||||
if self._is_running:
|
||||
logger.warning("FolderNamingService.run() called while already running")
|
||||
return FolderNamingReport(total=0, renamed=0, skipped=0, errors=0, results=[])
|
||||
self._is_running = True
|
||||
|
||||
try:
|
||||
logger.info("FolderNamingService: starting folder naming scan")
|
||||
results: List[FolderRenameResult] = []
|
||||
|
||||
async with _get_db_session() as db:
|
||||
all_series = await AnimeSeriesService.get_all(db)
|
||||
|
||||
for series in all_series:
|
||||
result = await self._process_series(series)
|
||||
results.append(result)
|
||||
|
||||
renamed = sum(1 for r in results if r.success and not r.skipped)
|
||||
skipped = sum(1 for r in results if r.skipped)
|
||||
errors = sum(1 for r in results if not r.skipped and not r.success)
|
||||
|
||||
report = FolderNamingReport(
|
||||
total=len(results),
|
||||
renamed=renamed,
|
||||
skipped=skipped,
|
||||
errors=errors,
|
||||
results=results,
|
||||
)
|
||||
logger.info(
|
||||
"FolderNamingService: scan complete — total=%d renamed=%d skipped=%d errors=%d",
|
||||
report.total, report.renamed, report.skipped, report.errors,
|
||||
)
|
||||
return report
|
||||
finally:
|
||||
self._is_running = False
|
||||
|
||||
async def _process_series(self, series) -> FolderRenameResult:
|
||||
key = series.key
|
||||
folder = series.folder or ""
|
||||
year = getattr(series, "year", None)
|
||||
|
||||
if year is None:
|
||||
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=True, skipped=True, reason="no year in DB record")
|
||||
|
||||
if self._folder_has_year(folder):
|
||||
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=True, skipped=True, reason="folder already has year")
|
||||
|
||||
target_folder = self._build_target_folder(folder, year)
|
||||
|
||||
# Safety: re-extract year from target to prevent double-year
|
||||
if target_folder != folder:
|
||||
extracted = self._extract_year_from_folder_name(target_folder)
|
||||
if extracted != year:
|
||||
logger.error("Safety guard for %s: target '%s' year=%s != DB year=%s — skipping", key, target_folder, extracted, year)
|
||||
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=False, skipped=True, reason="safety guard: target year mismatch")
|
||||
|
||||
return await self._execute_rename(series, folder, target_folder)
|
||||
|
||||
async def _execute_rename(self, series, old_folder: str, target_folder: str) -> FolderRenameResult:
|
||||
key = series.key
|
||||
|
||||
if old_folder == target_folder:
|
||||
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=True, skipped=True, reason="same folder name")
|
||||
|
||||
anime_dir = settings.anime_directory
|
||||
old_path = os.path.join(anime_dir, old_folder)
|
||||
target_path = os.path.join(anime_dir, target_folder)
|
||||
|
||||
if not os.path.isdir(old_path):
|
||||
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="source folder does not exist on disk")
|
||||
|
||||
if os.path.isdir(target_path):
|
||||
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="target folder already exists on disk")
|
||||
|
||||
try:
|
||||
shutil.move(old_path, target_path)
|
||||
logger.info("Renamed folder %s -> %s for series %s", old_folder, target_folder, key)
|
||||
|
||||
# Update in-memory cache
|
||||
try:
|
||||
from src.server.SeriesApp import get_series_app
|
||||
series_app = get_series_app()
|
||||
if key in series_app.list.keyDict:
|
||||
series_app.list.keyDict[key].folder = target_folder
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to update in-memory cache for %s: %s", key, exc)
|
||||
|
||||
# Update database
|
||||
async with _get_db_session() as db:
|
||||
db_series = await AnimeSeriesService.get_by_key(db, key)
|
||||
if db_series:
|
||||
await AnimeSeriesService.update(db, series_id=db_series.id, folder=target_folder)
|
||||
logger.debug("Updated DB folder for %s to %s", key, target_folder)
|
||||
|
||||
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=target_folder, success=True, skipped=False)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Failed to rename folder for %s (%s -> %s): %s", key, old_folder, target_folder, exc)
|
||||
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason=str(exc))
|
||||
|
||||
# Static helpers — public for direct testing
|
||||
@staticmethod
|
||||
def _folder_has_year(folder_name: str) -> bool:
|
||||
if not folder_name:
|
||||
return False
|
||||
return bool(re.search(r"\(\d{4}\)", folder_name))
|
||||
|
||||
@staticmethod
|
||||
def _extract_year_from_folder_name(folder_name: str) -> Optional[int]:
|
||||
if not folder_name:
|
||||
return None
|
||||
match = re.search(r"\((\d{4})\)", folder_name)
|
||||
if match:
|
||||
try:
|
||||
year = int(match.group(1))
|
||||
if 1900 <= year <= 2100:
|
||||
return year
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_title_from_folder_name(folder_name: str) -> str:
|
||||
return re.sub(r"\s*\(\d{4}\)\s*$", "", folder_name).strip()
|
||||
|
||||
@staticmethod
|
||||
def _build_target_folder(folder_name: str, year: int) -> str:
|
||||
title = FolderNamingService._extract_title_from_folder_name(folder_name)
|
||||
return f"{title} ({year})"
|
||||
|
||||
|
||||
_folder_naming_service: Optional[FolderNamingService] = None
|
||||
|
||||
def get_folder_naming_service() -> FolderNamingService:
|
||||
global _folder_naming_service
|
||||
if _folder_naming_service is None:
|
||||
_folder_naming_service = FolderNamingService()
|
||||
return _folder_naming_service
|
||||
|
||||
def reset_folder_naming_service() -> None:
|
||||
global _folder_naming_service
|
||||
_folder_naming_service = None
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 ?
|
||||
|
||||
Reference in New Issue
Block a user