feat(anime): add year to folder names on series add

- Add _compute_folder_name helper that deduplicates year (handles cases like 'Name (2023)' not becoming 'Name (2023) (2023)')
- Create anime folder on disk when adding series (not just DB + memory)
- Add rename_folder_if_needed to auto-rename existing folders without year
- Fetch year from aniworld_provider and include in folder as 'Name (YYYY)'

Closes: anime folders now include release year when available from provider
This commit is contained in:
2026-06-05 19:17:44 +02:00
parent e74b04c1ee
commit 2c47713339
2 changed files with 181 additions and 16 deletions

View File

@@ -1,4 +1,5 @@
import logging
import re
import warnings
from typing import Any, List, Optional
@@ -8,7 +9,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.config.settings import settings
from src.server.database.models import AnimeSeries
from src.server.utils.key_utils import generate_key_from_folder, is_valid_key
from src.server.database.service import AnimeSeriesService
from src.server.exceptions import (
BadRequestError,
@@ -19,7 +19,6 @@ from src.server.exceptions import (
from src.server.models.anime import AnimeMetadataUpdate
from src.server.services.anime_service import AnimeService, AnimeServiceError
from src.server.services.background_loader_service import BackgroundLoaderService
from src.server.utils.dependencies import (
get_anime_service,
get_background_loader_service,
@@ -29,6 +28,7 @@ from src.server.utils.dependencies import (
require_auth,
)
from src.server.utils.filesystem import sanitize_folder_name
from src.server.utils.key_utils import generate_key_from_folder, is_valid_key
from src.server.utils.validators import validate_filter_value, validate_search_query
logger = logging.getLogger(__name__)
@@ -36,6 +36,31 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/anime", tags=["anime"])
def _compute_folder_name(name: str, year: Optional[int]) -> str:
"""Compute sanitized folder name from display name and year.
If year is provided, strips any existing year in (YYYY) format to avoid
duplicates, then appends the new year. If year is None, preserves the
original name (with any existing year).
Args:
name: Display name of the series
year: Release year from provider, or None
Returns:
Sanitized folder name in format "Name (YYYY)" or just "Name"
"""
if year:
# Strip any existing year in (YYYY) format before adding new year
clean_name = re.sub(r'\s*\(\d{4}\)\s*$', '', name).strip()
folder_name_with_year = f"{clean_name} ({year})"
else:
# No new year provided, preserve original name (with any existing year)
folder_name_with_year = name
return sanitize_folder_name(folder_name_with_year)
@router.get("/status")
async def get_anime_status(
_auth: dict = Depends(require_auth),
@@ -764,18 +789,9 @@ async def add_series(
except Exception as e:
logger.warning("Could not fetch year for %s: %s", key, e)
# Create folder name with year if available
if year:
year_suffix = f" ({year})"
if name.endswith(year_suffix):
folder_name_with_year = name
else:
folder_name_with_year = f"{name}{year_suffix}"
else:
folder_name_with_year = name
# Step B: Compute sanitized folder name with year (deduplicates if year already in name)
try:
folder = sanitize_folder_name(folder_name_with_year)
folder = _compute_folder_name(name, year)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -784,7 +800,37 @@ async def add_series(
db_id = None
# Step C: Save to database if available
# Step C: Create folder on disk if it doesn't exist, and rename if needed
# Determine the anime directory path
anime_dir = settings.anime_directory if hasattr(settings, 'anime_directory') else None
current_folder_on_disk = None
if anime_dir:
import os
anime_path = os.path.join(anime_dir, folder)
# Check if an existing folder (without year) needs renaming
# Look for folder that matches name without year
if year:
potential_old_name = sanitize_folder_name(name)
potential_old_path = os.path.join(anime_dir, potential_old_name)
if potential_old_path != anime_path and os.path.exists(potential_old_path):
current_folder_on_disk = potential_old_name
logger.info(
"Found existing folder without year for %s: %s, renaming to %s",
key,
potential_old_name,
folder
)
elif not os.path.exists(anime_path):
# No existing folder to rename, create new one
os.makedirs(anime_path, exist_ok=True)
else:
# No year, just ensure folder exists
if not os.path.exists(anime_path):
os.makedirs(anime_path, exist_ok=True)
# Step D: Save to database if available
if db is not None:
# Check if series already exists in database
existing = await AnimeSeriesService.get_by_key(db, key)
@@ -850,7 +896,32 @@ async def add_series(
year
)
# Step E: Queue background loading task for episodes, NFO, and images
# Step E: Rename existing folder if needed (e.g., folder existed without year)
if current_folder_on_disk:
try:
renamed = await anime_service.rename_folder_if_needed(
key=key,
current_folder=current_folder_on_disk,
target_folder=folder,
db=db
)
if renamed:
logger.info(
"Successfully renamed folder for %s: %s -> %s",
key,
current_folder_on_disk,
folder
)
except Exception as e:
logger.warning(
"Failed to rename folder for %s: %s -> %s: %s",
key,
current_folder_on_disk,
folder,
e
)
# Step F: Queue background loading task for episodes, NFO, and images
try:
await background_loader.add_series_loading_task(
key=key,
@@ -871,7 +942,7 @@ async def add_series(
e
)
# Step F: Scan missing episodes immediately if background loader is not running
# Step G: Scan missing episodes immediately if background loader is not running
# Uses existing SerieScanner and AnimeService sync to avoid duplicates
try:
loader_running = bool(

View File

@@ -1204,6 +1204,100 @@ class AnimeService:
return anime_series
async def rename_folder_if_needed(
self,
key: str,
current_folder: str,
target_folder: str,
db: Optional[AsyncSession] = None,
) -> bool:
"""Rename anime folder if current and target folders differ.
Compares current_folder with target_folder, and if different,
renames the folder on disk using shutil.move. Updates the DB
record and in-memory cache if rename succeeds.
Args:
key: Series unique identifier
current_folder: Current folder name (metadata from DB)
target_folder: Desired folder name (computed with year)
db: Optional database session for updating DB record
Returns:
True if rename was performed, False if no rename needed or failed
"""
import os
import shutil
if current_folder == target_folder:
logger.debug(
"Folder rename not needed for %s: same folder name",
key
)
return False
current_path = self._directory / current_folder
target_path = self._directory / target_folder
if not current_path.exists():
logger.debug(
"Folder rename not needed for %s: current folder does not exist on disk",
key
)
return False
if target_path.exists():
logger.warning(
"Cannot rename folder for %s: target path already exists: %s",
key,
target_path
)
return False
try:
# Rename folder on disk
shutil.move(str(current_path), str(target_path))
logger.info(
"Renamed folder for %s: %s -> %s",
key,
current_folder,
target_folder
)
# Update in-memory cache
if key in self._app.list.keyDict:
self._app.list.keyDict[key].folder = target_folder
logger.debug(
"Updated in-memory cache folder for %s: %s",
key,
target_folder
)
# Update database if session provided
if db is not None:
from src.server.database.service import AnimeSeriesService
# Look up series by key to get database ID
series = await AnimeSeriesService.get_by_key(db, key)
if series:
await AnimeSeriesService.update(db, series_id=series.id, folder=target_folder)
logger.debug(
"Updated DB folder for %s: %s",
key,
target_folder
)
return True
except Exception as e:
logger.exception(
"Failed to rename folder for %s: %s -> %s",
key,
current_folder,
target_folder
)
return False
async def contains_in_db(self, key: str, db) -> bool:
"""
Check if a series with the given key exists in the database.