diff --git a/src/server/api/anime.py b/src/server/api/anime.py index 9cdde35..d87b136 100644 --- a/src/server/api/anime.py +++ b/src/server/api/anime.py @@ -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( diff --git a/src/server/services/anime_service.py b/src/server/services/anime_service.py index 2a87bb9..5892f1c 100644 --- a/src/server/services/anime_service.py +++ b/src/server/services/anime_service.py @@ -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.