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:
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import warnings
|
import warnings
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
@@ -8,7 +9,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.server.database.models import AnimeSeries
|
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.database.service import AnimeSeriesService
|
||||||
from src.server.exceptions import (
|
from src.server.exceptions import (
|
||||||
BadRequestError,
|
BadRequestError,
|
||||||
@@ -19,7 +19,6 @@ from src.server.exceptions import (
|
|||||||
from src.server.models.anime import AnimeMetadataUpdate
|
from src.server.models.anime import AnimeMetadataUpdate
|
||||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||||
from src.server.services.background_loader_service import BackgroundLoaderService
|
from src.server.services.background_loader_service import BackgroundLoaderService
|
||||||
|
|
||||||
from src.server.utils.dependencies import (
|
from src.server.utils.dependencies import (
|
||||||
get_anime_service,
|
get_anime_service,
|
||||||
get_background_loader_service,
|
get_background_loader_service,
|
||||||
@@ -29,6 +28,7 @@ from src.server.utils.dependencies import (
|
|||||||
require_auth,
|
require_auth,
|
||||||
)
|
)
|
||||||
from src.server.utils.filesystem import sanitize_folder_name
|
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
|
from src.server.utils.validators import validate_filter_value, validate_search_query
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -36,6 +36,31 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api/anime", tags=["anime"])
|
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")
|
@router.get("/status")
|
||||||
async def get_anime_status(
|
async def get_anime_status(
|
||||||
_auth: dict = Depends(require_auth),
|
_auth: dict = Depends(require_auth),
|
||||||
@@ -764,18 +789,9 @@ async def add_series(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Could not fetch year for %s: %s", key, e)
|
logger.warning("Could not fetch year for %s: %s", key, e)
|
||||||
|
|
||||||
# Create folder name with year if available
|
# Step B: Compute sanitized folder name with year (deduplicates if year already in name)
|
||||||
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
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
folder = sanitize_folder_name(folder_name_with_year)
|
folder = _compute_folder_name(name, year)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -784,7 +800,37 @@ async def add_series(
|
|||||||
|
|
||||||
db_id = None
|
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:
|
if db is not None:
|
||||||
# Check if series already exists in database
|
# Check if series already exists in database
|
||||||
existing = await AnimeSeriesService.get_by_key(db, key)
|
existing = await AnimeSeriesService.get_by_key(db, key)
|
||||||
@@ -850,7 +896,32 @@ async def add_series(
|
|||||||
year
|
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:
|
try:
|
||||||
await background_loader.add_series_loading_task(
|
await background_loader.add_series_loading_task(
|
||||||
key=key,
|
key=key,
|
||||||
@@ -871,7 +942,7 @@ async def add_series(
|
|||||||
e
|
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
|
# Uses existing SerieScanner and AnimeService sync to avoid duplicates
|
||||||
try:
|
try:
|
||||||
loader_running = bool(
|
loader_running = bool(
|
||||||
|
|||||||
@@ -1204,6 +1204,100 @@ class AnimeService:
|
|||||||
|
|
||||||
return anime_series
|
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:
|
async def contains_in_db(self, key: str, db) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a series with the given key exists in the database.
|
Check if a series with the given key exists in the database.
|
||||||
|
|||||||
Reference in New Issue
Block a user