Files
Aniworld/src/server/services/anime_service.py
Lukas 0f872276dd fix: merge bare folder into year-suffixed one instead of bailing
When the year-suffixed target folder already exists on disk, both
FolderNamingService and AnimeService.rename_folder_if_needed used to
silently bail out. The bare folder (e.g. 'Ultraman') was left next to
the year-suffixed one ('Ultraman (2019)'), producing the symptom
'reports series like Ultraman as added twice' — the DB has one row but
the filesystem has two folders holding the same content.

Fix: when the target already exists, merge the source's contents into
the target (target version wins on file conflicts; source copies are
removed so cleanup succeeds), remove the now-empty source directory,
update DB row + in-memory cache. Plain rename path is unchanged.

Also fixes a latent TypeError in rename_folder_if_needed where
self._directory (a str) was used with the '/' operator. Production
behavior was that any rename through that method raised and was
swallowed by the caller's try/except, leaving the bare folder
untouched. The new path builds Path objects from the string base.

Tests:
- Replaced test_skips_when_target_folder_already_exists (which
  codified the bug) with three tests that cover the new merge
  contract: clean merge, no-overwrite, and empty-source removal.
- Added tests/unit/test_rename_folder_if_needed.py covering the
  same scenarios plus the str-directory regression. All seven go
  red on the unfixed code and green with the fix.

Fixes the 'Ultraman' / 'Ultraman (2019)' duplicate-folder bug.
2026-08-28 21:42:55 +02:00

2131 lines
84 KiB
Python

from __future__ import annotations
import asyncio
import logging
import time
from datetime import datetime, timezone
from functools import lru_cache
from typing import Optional
from src.server.SeriesApp import SeriesApp
from src.server.services.progress_service import (
ProgressService,
ProgressType,
get_progress_service,
)
from src.server.services.websocket_service import (
WebSocketService,
get_websocket_service,
)
logger = logging.getLogger(__name__)
class AnimeServiceError(Exception):
"""Service-level exception for anime operations."""
class AnimeService:
"""Wraps SeriesApp for use in the FastAPI web layer.
This service provides a clean interface to anime operations, using 'key'
as the primary series identifier (provider-assigned, URL-safe) and 'folder'
as metadata only (filesystem folder name for display purposes).
- SeriesApp methods are now async, no need for threadpool
- Subscribes to SeriesApp events for progress tracking
- Exposes async methods using 'key' for all series identification
- Adds simple in-memory caching for read operations
"""
def __init__(
self,
series_app: SeriesApp,
progress_service: Optional[ProgressService] = None,
websocket_service: Optional[WebSocketService] = None,
):
self._app = series_app
self._directory = series_app.directory_to_search
self._progress_service = progress_service or get_progress_service()
self._websocket_service = websocket_service or get_websocket_service()
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
# Track scan progress for WebSocket updates
self._scan_start_time: Optional[float] = None
self._scan_directories_count: int = 0
self._scan_files_count: int = 0
self._scan_total_items: int = 0
self._is_scanning: bool = False
self._scan_current_directory: str = ""
# Lock to prevent concurrent rescans
self._scan_lock = asyncio.Lock()
# Subscribe to SeriesApp events
# Note: Events library uses assignment (=), not += operator
import logging
_logger = logging.getLogger(__name__)
try:
self._app.download_status = self._on_download_status
self._app.scan_status = self._on_scan_status
_logger.info(
"Subscribed to SeriesApp events: scan_status=%s series_app_id=%s",
str(self._app.scan_status),
id(self._app),
)
except (BrokenPipeError, OSError) as e:
# Handle "broken pipe" when structlog tries to write to closed stdout
# This can happen when server runs in background with stdout redirected
import sys
print(
f"WARNING: Failed to subscribe to SeriesApp events: {e}. "
f"Download/scan status callbacks may not work.",
file=sys.stderr,
flush=True
)
except Exception as e:
_logger.error("Failed to subscribe to SeriesApp events: %s", e)
raise AnimeServiceError("Initialization failed") from e
def _on_download_status(self, args) -> None:
"""Handle download status events from SeriesApp.
Events include both 'key' (primary identifier) and 'serie_folder'
(metadata for display and filesystem operations).
Args:
args: DownloadStatusEventArgs from SeriesApp containing key,
serie_folder, season, episode, status, and progress info
"""
try:
# Get event loop - try running loop first, then stored loop
loop = None
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# No running loop in this thread - use stored loop
loop = self._event_loop
if not loop:
logger.debug(
"No event loop available for download status event status=%s",
args.status
)
return
# Use item_id if available, otherwise fallback to constructing ID
progress_id = (
args.item_id
if args.item_id
else (
f"download_{args.serie_folder}_"
f"{args.season}_{args.episode}"
)
)
# Map SeriesApp download events to progress service
if args.status == "started":
asyncio.run_coroutine_threadsafe(
self._progress_service.start_progress(
progress_id=progress_id,
progress_type=ProgressType.DOWNLOAD,
title=f"Downloading {args.serie_folder}",
message=f"S{args.season:02d}E{args.episode:02d}",
metadata=(
{"item_id": args.item_id}
if args.item_id
else None
),
),
loop
)
elif args.status == "progress":
# Build metadata with item_id and speed
progress_metadata = {}
if args.item_id:
progress_metadata["item_id"] = args.item_id
if args.mbper_sec is not None:
progress_metadata["speed_mbps"] = round(args.mbper_sec, 2)
if args.eta is not None:
progress_metadata["eta"] = args.eta
asyncio.run_coroutine_threadsafe(
self._progress_service.update_progress(
progress_id=progress_id,
current=args.progress,
total=100,
message=args.message or "Downloading...",
metadata=(
progress_metadata if progress_metadata else None
),
),
loop
)
elif args.status == "completed":
asyncio.run_coroutine_threadsafe(
self._progress_service.complete_progress(
progress_id=progress_id,
message="Download completed",
),
loop
)
elif args.status == "failed":
asyncio.run_coroutine_threadsafe(
self._progress_service.fail_progress(
progress_id=progress_id,
error_message=args.message or str(args.error),
),
loop
)
except Exception as exc: # pylint: disable=broad-except
logger.error(
"Error handling download status event error=%s",
str(exc)
)
def _on_scan_status(self, args) -> None:
"""Handle scan status events from SeriesApp.
Events include both 'key' (primary identifier) and 'folder'
(metadata for display purposes). Also broadcasts via WebSocket
for real-time UI updates.
Args:
args: ScanStatusEventArgs from SeriesApp containing key,
folder, current, total, status, and progress info
"""
import logging
_event_logger = logging.getLogger(__name__)
try:
scan_id = "library_scan"
_event_logger.info(
"Scan status event received status=%s current=%s total=%s folder=%s",
args.status, args.current, args.total, args.folder,
)
# Get event loop - try running loop first, then stored loop
loop = None
try:
loop = asyncio.get_running_loop()
_event_logger.debug("Using running event loop for scan status")
except RuntimeError:
# No running loop in this thread - use stored loop
loop = self._event_loop
_event_logger.debug(
"Using stored event loop for scan status has_loop=%s",
loop is not None
)
if not loop:
_event_logger.warning(
"No event loop available for scan status event status=%s",
args.status
)
return
_event_logger.info(
"Processing scan status event status=%s loop_id=%s",
args.status, id(loop),
)
# Map SeriesApp scan events to progress service
if args.status == "started":
# Track scan start time and reset counters
self._scan_start_time = time.time()
self._scan_directories_count = 0
self._scan_files_count = 0
self._scan_total_items = args.total
self._is_scanning = True
self._scan_current_directory = ""
asyncio.run_coroutine_threadsafe(
self._progress_service.start_progress(
progress_id=scan_id,
progress_type=ProgressType.SCAN,
title="Scanning anime library",
message=args.message or "Initializing scan...",
),
loop
)
# Broadcast scan started via WebSocket with total items
asyncio.run_coroutine_threadsafe(
self._broadcast_scan_started_safe(total_items=args.total),
loop
)
elif args.status == "progress":
# Update scan counters
self._scan_directories_count = args.current
self._scan_current_directory = args.folder or ""
# Estimate files found (use current as proxy since detailed
# file count isn't available from SerieScanner)
asyncio.run_coroutine_threadsafe(
self._progress_service.update_progress(
progress_id=scan_id,
current=args.current,
total=args.total,
message=args.message or f"Scanning: {args.folder}",
),
loop
)
# Broadcast scan progress via WebSocket
asyncio.run_coroutine_threadsafe(
self._broadcast_scan_progress_safe(
directories_scanned=args.current,
files_found=args.current, # Use folder count as proxy
current_directory=args.folder or "",
total_items=args.total,
),
loop
)
elif args.status == "completed":
# Calculate elapsed time
elapsed = 0.0
if self._scan_start_time:
elapsed = time.time() - self._scan_start_time
# Mark scan as complete
self._is_scanning = False
asyncio.run_coroutine_threadsafe(
self._progress_service.complete_progress(
progress_id=scan_id,
message=args.message or "Scan completed",
),
loop
)
# Broadcast scan completed via WebSocket
asyncio.run_coroutine_threadsafe(
self._broadcast_scan_completed_safe(
total_directories=args.total,
total_files=args.total, # Use folder count as proxy
elapsed_seconds=elapsed,
),
loop
)
elif args.status == "failed":
self._is_scanning = False
asyncio.run_coroutine_threadsafe(
self._progress_service.fail_progress(
progress_id=scan_id,
error_message=args.message or str(args.error),
),
loop
)
elif args.status == "cancelled":
self._is_scanning = False
asyncio.run_coroutine_threadsafe(
self._progress_service.fail_progress(
progress_id=scan_id,
error_message=args.message or "Scan cancelled",
),
loop
)
except Exception as exc: # pylint: disable=broad-except
logger.error("Error handling scan status event: %s", exc)
async def _broadcast_scan_started_safe(self, total_items: int = 0) -> None:
"""Safely broadcast scan started event via WebSocket.
Wraps the WebSocket broadcast in try/except to ensure scan
continues even if WebSocket fails.
Args:
total_items: Total number of items to scan
"""
try:
logger.info(
"Broadcasting scan_started via WebSocket",
directory=self._directory,
total_items=total_items,
)
await self._websocket_service.broadcast_scan_started(
directory=self._directory,
total_items=total_items,
)
logger.info("scan_started broadcast sent successfully")
except Exception as exc:
logger.warning(
"Failed to broadcast scan_started via WebSocket",
error=str(exc)
)
async def _broadcast_scan_progress_safe(
self,
directories_scanned: int,
files_found: int,
current_directory: str,
total_items: int = 0,
) -> None:
"""Safely broadcast scan progress event via WebSocket.
Wraps the WebSocket broadcast in try/except to ensure scan
continues even if WebSocket fails.
Args:
directories_scanned: Number of directories scanned so far
files_found: Number of files found so far
current_directory: Current directory being scanned
total_items: Total number of items to scan
"""
try:
await self._websocket_service.broadcast_scan_progress(
directories_scanned=directories_scanned,
files_found=files_found,
current_directory=current_directory,
total_items=total_items,
)
except Exception as exc:
logger.warning(
"Failed to broadcast scan_progress via WebSocket",
error=str(exc)
)
async def _broadcast_scan_completed_safe(
self,
total_directories: int,
total_files: int,
elapsed_seconds: float,
) -> None:
"""Safely broadcast scan completed event via WebSocket.
Wraps the WebSocket broadcast in try/except to ensure scan
cleanup continues even if WebSocket fails.
Args:
total_directories: Total directories scanned
total_files: Total files found
elapsed_seconds: Time taken for the scan
"""
try:
await self._websocket_service.broadcast_scan_completed(
total_directories=total_directories,
total_files=total_files,
elapsed_seconds=elapsed_seconds,
)
except Exception as exc:
logger.warning(
"Failed to broadcast scan_completed via WebSocket",
error=str(exc)
)
def get_scan_status(self) -> dict:
"""Get the current scan status.
Returns:
Dictionary with scan status information including:
- is_scanning: Whether a scan is currently in progress
- total_items: Total number of items to scan
- directories_scanned: Number of directories scanned so far
- current_directory: Current directory being scanned
- directory: Root directory being scanned
"""
status = {
"is_scanning": self._is_scanning,
"total_items": self._scan_total_items,
"directories_scanned": self._scan_directories_count,
"current_directory": self._scan_current_directory,
"directory": self._directory,
}
logger.debug(
"Scan status requested",
is_scanning=self._is_scanning,
total_items=self._scan_total_items,
directories_scanned=self._scan_directories_count,
)
return status
@lru_cache(maxsize=128)
def _cached_list_missing(self) -> list[dict]:
# Synchronous cached call - SeriesApp.series_list is populated
# during initialization
try:
series = self._app.series_list
# normalize to simple dicts
result: list[dict] = []
for s in series:
if hasattr(s, "to_dict"):
result.append(s.to_dict())
else:
result.append(s) # type: ignore
return result
except Exception as e:
_logger.error("Failed to get missing episodes list: %s", str(e))
raise
async def list_missing(self) -> list[dict]:
"""Return list of series with missing episodes.
Each series dictionary includes 'key' as the primary identifier
and 'folder' as metadata for display purposes.
Returns:
List of series dictionaries with 'key', 'name', 'site',
'folder', and 'episodeDict' fields
"""
try:
# series_list is already populated, just access it
return self._cached_list_missing()
except AnimeServiceError:
raise
except Exception as exc:
_logger.error("list_missing failed: %s", str(exc))
raise AnimeServiceError("Failed to list missing series") from exc
async def list_series_with_filters(
self,
filter_type: Optional[str] = None
) -> list[dict]:
"""Return all series with NFO metadata from database.
Retrieves series from SeriesApp and enriches them with NFO metadata
from the database. Supports filtering options like 'no_episodes'.
Args:
filter_type: Optional filter. Supported values:
- "no_episodes": Only series with no downloaded episodes
- None: All series
Returns:
List of series dictionaries with 'key', 'name', 'site', 'folder',
'episodeDict', and NFO metadata fields (has_nfo, nfo_created_at,
nfo_updated_at, tmdb_id, tvdb_id, series_id)
Raises:
AnimeServiceError: If operation fails
"""
try:
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
# Get all series from SeriesApp
if not hasattr(self._app, "list"):
logger.warning("SeriesApp has no list attribute")
return []
series = self._app.list.GetList()
if not series:
logger.info("No series found in SeriesApp")
return []
# Build NFO metadata map, episode dict, and filter data from database.
# Using DB as authoritative source for episodeDict ensures that
# episodes marked is_downloaded=True are never shown as missing,
# even if the in-memory state is stale.
nfo_map: dict = {}
db_episode_dict_map: dict[str, dict[int, list[int]]] = {}
series_with_no_episodes: set = set()
async with get_db_session() as db:
# Single query: load all series with their episodes eagerly
db_series_list = await AnimeSeriesService.get_all(
db, with_episodes=True
)
for db_series in db_series_list:
nfo_created = (
db_series.nfo_created_at.isoformat()
if db_series.nfo_created_at else None
)
nfo_updated = (
db_series.nfo_updated_at.isoformat()
if db_series.nfo_updated_at else None
)
nfo_map[db_series.folder] = {
"has_nfo": db_series.has_nfo or False,
"nfo_created_at": nfo_created,
"nfo_updated_at": nfo_updated,
"tmdb_id": db_series.tmdb_id,
"tvdb_id": db_series.tvdb_id,
"series_id": db_series.id,
"loading_status": db_series.loading_status,
"loading_error": db_series.loading_error,
}
# Build episodeDict from DB, skipping is_downloaded=True
# episodes so they are never shown as missing in the UI.
ep_dict: dict[int, list[int]] = {}
if db_series.episodes:
for ep in db_series.episodes:
if ep.is_downloaded:
continue
if ep.season not in ep_dict:
ep_dict[ep.season] = []
ep_dict[ep.season].append(ep.episode_number)
for s in ep_dict:
ep_dict[s].sort()
db_episode_dict_map[db_series.folder] = ep_dict
# If filter is "missing_episodes", get series with any missing episodes
if filter_type == "missing_episodes":
series_missing = (
await AnimeSeriesService.get_series_with_missing_episodes(db)
)
series_with_missing_episodes = {s.folder for s in series_missing}
# If filter is "no_episodes", get series with no downloaded episodes
if filter_type == "no_episodes":
series_no_downloads = (
await AnimeSeriesService.get_series_with_no_episodes(db)
)
series_with_no_episodes = {s.folder for s in series_no_downloads}
# Build result list with enriched metadata
result_list = []
for serie in series:
key = getattr(serie, "key", "")
name = getattr(serie, "name", "")
site = getattr(serie, "site", "")
folder = getattr(serie, "folder", "")
# Use DB-backed episodeDict (is_downloaded=True already filtered out)
# with in-memory episodeDict as fallback if the series isn't in DB yet.
episode_dict = db_episode_dict_map.get(
folder,
getattr(serie, "episodeDict", {}) or {}
)
# Apply filter if specified
if filter_type == "missing_episodes":
if folder not in series_with_missing_episodes:
continue
if filter_type == "no_episodes":
if folder not in series_with_no_episodes:
continue
# Get NFO data from map
nfo_data = nfo_map.get(folder, {})
# Build enriched series dict
series_dict = {
"key": key,
"name": name,
"site": site,
"folder": folder,
"episodeDict": episode_dict,
"has_nfo": nfo_data.get("has_nfo", False),
"nfo_created_at": nfo_data.get("nfo_created_at"),
"nfo_updated_at": nfo_data.get("nfo_updated_at"),
"tmdb_id": nfo_data.get("tmdb_id"),
"tvdb_id": nfo_data.get("tvdb_id"),
"series_id": nfo_data.get("series_id"),
"loading_status": nfo_data.get("loading_status"),
"loading_error": nfo_data.get("loading_error"),
}
result_list.append(series_dict)
logger.info(
"Listed series with filters total=%d filter_type=%s",
len(result_list), filter_type
)
return result_list
except AnimeServiceError:
raise
except Exception as exc:
logger.error("list_series_with_filters failed: %s", str(exc))
raise AnimeServiceError(
"Failed to list series with metadata"
) from exc
async def search(self, query: str) -> list[dict]:
"""Search for series using underlying provider.
Args:
query: Search query string
Returns:
List of search results as dictionaries, each containing 'key'
as the primary identifier and other metadata fields
"""
if not query:
return []
try:
# SeriesApp.search is now async
result = await self._app.search(query)
return result
except Exception as exc:
logger.error("search failed: %s", str(exc))
raise AnimeServiceError("Search failed") from exc
async def rescan(self) -> None:
"""Trigger a re-scan of the anime library directory.
Scans the filesystem for anime series and updates the series list.
The SeriesApp handles progress tracking via events which are
forwarded to the ProgressService through event handlers.
After scanning, results are persisted to the database.
All series are identified by their 'key' (provider identifier),
with 'folder' stored as metadata.
Note:
Only one scan can run at a time. If a scan is already in
progress, this method returns immediately without starting
a new scan.
"""
import logging
_rescan_logger = logging.getLogger(__name__)
# Check if a scan is already running (non-blocking)
if self._scan_lock.locked():
_rescan_logger.info("Rescan already in progress, ignoring request")
return
async with self._scan_lock:
try:
# Store event loop for event handlers
self._event_loop = asyncio.get_running_loop()
_rescan_logger.info(
"Rescan started, event loop stored. loop_id=%d series_app_id=%d",
id(self._event_loop),
id(self._app),
)
# SeriesApp.rescan returns scanned series list
_rescan_logger.info("Calling _app.rescan()")
scanned_series = await self._app.rescan()
_rescan_logger.info("Rescan completed, found %d series", len(scanned_series) if scanned_series else 0)
# Persist scan results to database
if scanned_series:
_rescan_logger.info("Saving %d series to database", len(scanned_series))
await self._save_scan_results_to_db(scanned_series)
# Reload series from database to ensure consistency
_rescan_logger.info("Loading series from database")
await self._load_series_from_db()
# invalidate cache
try:
self._cached_list_missing.cache_clear()
except Exception: # pylint: disable=broad-except
pass
except AnimeServiceError:
# Re-raise AnimeServiceError without wrapping
raise
except Exception as exc: # pylint: disable=broad-except
_rescan_logger.error("Rescan failed: %s", str(exc))
raise AnimeServiceError("Rescan failed") from exc
async def sync_single_series_after_scan(self, series_key: str) -> None:
"""Persist a single scanned series and refresh cached state.
Reuses the same save/reload/cache invalidation flow as `rescan`
to keep the database, in-memory list, and UI in sync.
Args:
series_key: Series key to persist and refresh.
"""
# Get serie from scanner's keyDict, not series_app.list.keyDict
# scan_single_series updates serie_scanner.keyDict with episodeDict
if not hasattr(self._app, "serie_scanner") or not hasattr(self._app.serie_scanner, "keyDict"):
logger.warning(
"Serie scanner not available for single-series sync: %s",
series_key,
)
return
serie = self._app.serie_scanner.keyDict.get(series_key)
if not serie:
logger.warning(
"Series not found in scanner keyDict for single-series sync: %s",
series_key,
)
return
total_episodes = sum(len(eps) for eps in (serie.episodeDict or {}).values())
logger.info(
"Syncing series %s with %d missing episodes. episodeDict: %s",
series_key,
total_episodes,
serie.episodeDict
)
await self._save_scan_results_to_db([serie])
await self._load_series_from_db()
try:
self._cached_list_missing.cache_clear()
except Exception: # pylint: disable=broad-except
pass
try:
await self._broadcast_series_updated(series_key)
except Exception as exc: # pylint: disable=broad-except
logger.warning(
"Failed to broadcast series update for %s: %s",
series_key,
exc,
)
async def _save_scan_results_to_db(self, series_list: list) -> int:
"""
Save scan results to the database.
Creates or updates series records in the database based on
scan results.
Args:
series_list: List of Serie objects from scan
Returns:
Number of series saved/updated
"""
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
saved_count = 0
async with get_db_session() as db:
for serie in series_list:
try:
# Check if series already exists
existing = await AnimeSeriesService.get_by_key(
db, serie.key
)
total_episodes = sum(len(eps) for eps in (serie.episodeDict or {}).values())
if existing:
# Update existing series
logger.info(
"Updating existing series %s with %d episodes. episodeDict: %s",
serie.key,
total_episodes,
serie.episodeDict
)
await self._update_series_in_db(
serie, existing, db
)
else:
# Create new series
logger.info(
"Creating new series %s with %d episodes. episodeDict: %s",
serie.key,
total_episodes,
serie.episodeDict
)
await self._create_series_in_db(serie, db)
saved_count += 1
except Exception as e: # pylint: disable=broad-except
logger.warning(
"Failed to save series to database: %s (key=%s) - %s",
serie.name,
serie.key,
str(e)
)
logger.info(
"Saved %d series to database from scan results",
saved_count
)
return saved_count
async def _create_series_in_db(self, serie, db) -> None:
"""Create a new series in the database."""
from src.server.database.service import AnimeSeriesService, EpisodeService
anime_series = await AnimeSeriesService.create(
db=db,
key=serie.key,
name=serie.name,
site=serie.site,
folder=serie.folder,
year=serie.year if hasattr(serie, 'year') else None,
)
# Create Episode records
if serie.episodeDict:
for season, episode_numbers in serie.episodeDict.items():
for ep_num in episode_numbers:
await EpisodeService.create(
db=db,
series_id=anime_series.id,
season=season,
episode_number=ep_num,
)
logger.debug(
"Created series in database: %s (key=%s, year=%s)",
serie.name,
serie.key,
serie.year if hasattr(serie, 'year') else None
)
async def _update_series_in_db(self, serie, existing, db) -> None:
"""Update an existing series in the database.
Syncs the database episodes with the current missing episodes from scan.
- Adds new missing episodes that are not in the database
- Removes episodes from database that are no longer missing
(i.e., the file has been added to the filesystem)
- Preserves episodes marked as downloaded (is_downloaded=True)
so download history is not lost
"""
from src.server.database.service import AnimeSeriesService, EpisodeService
# Get existing episodes from database (all episodes, including downloaded)
existing_episodes = await EpisodeService.get_by_series(db, existing.id)
# Build dict of existing episodes: {season: {ep_num: episode_id}}
# and track which ones are already downloaded
existing_dict: dict[int, dict[int, int]] = {}
downloaded_set: set[tuple[int, int]] = set()
for ep in existing_episodes:
if ep.season not in existing_dict:
existing_dict[ep.season] = {}
existing_dict[ep.season][ep.episode_number] = ep.id
if ep.is_downloaded:
downloaded_set.add((ep.season, ep.episode_number))
# Get new missing episodes from scan
new_dict = serie.episodeDict or {}
# Build set of new missing episodes for quick lookup
new_missing_set: set[tuple[int, int]] = set()
for season, episode_numbers in new_dict.items():
for ep_num in episode_numbers:
new_missing_set.add((season, ep_num))
# Add new missing episodes that are not in the database
for season, episode_numbers in new_dict.items():
existing_season_eps = existing_dict.get(season, {})
for ep_num in episode_numbers:
if ep_num not in existing_season_eps:
await EpisodeService.create(
db=db,
series_id=existing.id,
season=season,
episode_number=ep_num,
)
logger.debug(
"Added missing episode to database: %s S%02dE%02d",
serie.key,
season,
ep_num
)
# Remove episodes from database that are no longer missing
# (i.e., the episode file now exists on the filesystem)
# BUT: preserve episodes that are already downloaded (is_downloaded=True)
# so we don't lose download history
for season, eps_dict in existing_dict.items():
for ep_num, episode_id in eps_dict.items():
if (season, ep_num) not in new_missing_set:
# Skip already-downloaded episodes — they should stay in DB
# with is_downloaded=True to preserve download history
if (season, ep_num) in downloaded_set:
logger.debug(
"Preserving downloaded episode in database: "
"%s S%02dE%02d",
serie.key,
season,
ep_num
)
continue
await EpisodeService.delete(db, episode_id)
logger.info(
"Removed episode from database (no longer missing): "
"%s S%02dE%02d",
serie.key,
season,
ep_num
)
# Update folder if changed
if existing.folder != serie.folder:
await AnimeSeriesService.update(
db,
existing.id,
folder=serie.folder
)
logger.debug(
"Updated series in database: %s (key=%s)",
serie.name,
serie.key
)
async def _load_series_from_db(self) -> None:
"""
Load series from the database into SeriesApp.
This method is called during initialization and after rescans
to ensure the in-memory series list is in sync with the database.
Only episodes where is_downloaded=False are loaded into the
in-memory episodeDict, so downloaded episodes are not shown
as missing.
"""
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
async with get_db_session() as db:
anime_series_list = await AnimeSeriesService.get_all(
db, with_episodes=True
)
# Load AnimeSeries objects directly into SeriesApp
self._app.load_series_from_list(anime_series_list)
async def sync_episodes_to_db(self, series_key: str) -> int:
"""
Sync episodes from in-memory SeriesApp to database for a specific series.
This method reads the episodeDict from the in-memory series (populated
by scanner) and syncs it to the database. Called after scanning for
missing episodes.
Args:
series_key: The series key to sync episodes for
Returns:
Number of episodes synced to database
"""
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService, EpisodeService
# Get the serie from in-memory cache
if not hasattr(self._app, 'list') or not hasattr(self._app.list, 'keyDict'):
logger.warning("Series list not available for episode sync: %s", series_key)
return 0
serie = self._app.list.keyDict.get(series_key)
if not serie:
logger.warning("Series not found in memory for episode sync: %s", series_key)
return 0
episodes_added = 0
async with get_db_session() as db:
# Get series from database
series_db = await AnimeSeriesService.get_by_key(db, series_key)
if not series_db:
logger.warning("Series not found in database: %s", series_key)
return 0
# Get existing episodes from database (all, including downloaded)
existing_episodes = await EpisodeService.get_by_series(db, series_db.id)
# Build dict of existing episodes: {season: {ep_num: episode_id}}
# and track which ones are already downloaded
existing_dict: dict[int, dict[int, int]] = {}
downloaded_set: set[tuple[int, int]] = set()
for ep in existing_episodes:
if ep.season not in existing_dict:
existing_dict[ep.season] = {}
existing_dict[ep.season][ep.episode_number] = ep.id
if ep.is_downloaded:
downloaded_set.add((ep.season, ep.episode_number))
# Get new missing episodes from in-memory serie
new_dict = serie.episodeDict or {}
# Add new missing episodes that are not in the database
# Skip episodes that are already downloaded (is_downloaded=True)
# so we don't re-add them as missing after they've been downloaded
for season, episode_numbers in new_dict.items():
existing_season_eps = existing_dict.get(season, {})
for ep_num in episode_numbers:
# Skip if already downloaded — don't re-add as missing
if (season, ep_num) in downloaded_set:
logger.debug(
"Skipping already-downloaded episode: "
"%s S%02dE%02d",
series_key,
season,
ep_num,
)
continue
if ep_num not in existing_season_eps:
await EpisodeService.create(
db=db,
series_id=series_db.id,
season=season,
episode_number=ep_num,
)
episodes_added += 1
logger.debug(
f"Added missing episode to database: {series_key} S{season:02d}E{ep_num:02d}"
)
if episodes_added > 0:
logger.info(
f"Synced {episodes_added} missing episodes to database for {series_key}"
)
# Broadcast update to frontend to refresh series list
try:
await self._broadcast_series_updated(series_key)
except Exception as e:
logger.warning("Failed to broadcast series update: %s", e)
return episodes_added
async def _broadcast_series_updated(self, series_key: str) -> None:
"""Broadcast series update event to WebSocket clients with full data."""
if not self._websocket_service:
return
# Get updated series data to send to frontend
series_data = None
if hasattr(self._app, 'list') and hasattr(self._app.list, 'keyDict'):
serie = self._app.list.keyDict.get(series_key)
if serie:
# Fetch NFO metadata and episodes from database.
# Using DB as the authoritative source for missing_episodes
# ensures that episodes marked is_downloaded=True are never
# broadcast as missing, even if in-memory state is stale.
has_nfo = False
nfo_created_at = None
nfo_updated_at = None
tmdb_id = None
tvdb_id = None
missing_episodes: dict[str, list] = {}
try:
from src.server.database.connection import get_db_session
from src.server.database.service import (
AnimeSeriesService,
EpisodeService,
)
async with get_db_session() as db:
db_series = await AnimeSeriesService.get_by_key(db, series_key)
if db_series:
has_nfo = db_series.has_nfo or False
nfo_created_at = (
db_series.nfo_created_at.isoformat()
if db_series.nfo_created_at else None
)
nfo_updated_at = (
db_series.nfo_updated_at.isoformat()
if db_series.nfo_updated_at else None
)
tmdb_id = db_series.tmdb_id
tvdb_id = db_series.tvdb_id
# Build missing_episodes from DB, skipping is_downloaded=True
db_episodes = await EpisodeService.get_by_series(
db, db_series.id, only_missing=True
)
for ep in db_episodes:
key_str = str(ep.season)
if key_str not in missing_episodes:
missing_episodes[key_str] = []
missing_episodes[key_str].append(ep.episode_number)
for s in missing_episodes:
missing_episodes[s].sort()
except Exception as e:
logger.warning(
"Could not fetch series data for %s from DB: %s",
series_key,
str(e)
)
# Fallback to in-memory state
missing_episodes = {
str(k): v
for k, v in (serie.episodeDict or {}).items()
}
total_missing = sum(len(eps) for eps in missing_episodes.values())
series_data = {
"key": serie.key,
"name": serie.name,
"folder": serie.folder,
"site": serie.site,
"missing_episodes": missing_episodes,
"has_missing": total_missing > 0,
"has_nfo": has_nfo,
"nfo_created_at": nfo_created_at,
"nfo_updated_at": nfo_updated_at,
"tmdb_id": tmdb_id,
"tvdb_id": tvdb_id,
}
payload = {
"type": "series_updated",
"key": series_key,
"data": series_data,
"message": "Series episodes updated",
"timestamp": datetime.now(timezone.utc).isoformat()
}
logger.info(
"Broadcasting series update for %s with %d missing episodes",
series_key,
sum(len(eps) for eps in (series_data.get("missing_episodes", {}).values())) if series_data else 0
)
await self._websocket_service.broadcast(payload)
async def add_series_to_db(
self,
anime,
db
):
"""
Add a series to the database if it doesn't already exist.
Uses anime.key for identification. Creates a new AnimeSeries
record in the database if it doesn't already exist.
Args:
anime: The AnimeSeries instance to add
db: Database session for async operations
Returns:
Created AnimeSeries instance, or None if already exists
"""
from src.server.database.service import AnimeSeriesService, EpisodeService
# Check if series already exists in DB
existing = await AnimeSeriesService.get_by_key(db, anime.key)
if existing:
logger.debug(
"Series already exists in database: %s (key=%s)",
anime.name,
anime.key
)
return None
# Create new series in database
anime_series = await AnimeSeriesService.create(
db=db,
key=anime.key,
name=anime.name,
site=anime.site,
folder=anime.folder,
year=anime.year if hasattr(anime, 'year') else None,
)
# Create Episode records for each episode in episodes relationship
if anime.episodes:
for episode in anime.episodes:
await EpisodeService.create(
db=db,
series_id=anime_series.id,
season=episode.season,
episode_number=episode.episode_number,
)
logger.info(
"Added series to database: %s (key=%s, year=%s)",
anime.name,
anime.key,
anime.year if hasattr(anime, 'year') else None
)
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
"""
from pathlib import Path
if current_folder == target_folder:
logger.debug(
"Folder rename not needed for %s: same folder name",
key
)
return False
base_dir = Path(self._directory)
current_path = base_dir / current_folder
target_path = base_dir / 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():
# Target already exists — merge source into target instead of
# bailing. Without this, a bare folder ('Naruto') next to the
# year-suffixed one ('Naruto (2019)') would orphan the bare
# folder forever, producing the "series added twice" symptom.
try:
summary = self._merge_folder_into_target(
str(current_path), str(target_path)
)
except Exception as exc:
logger.error(
"Failed to merge %s -> %s for %s: %s",
current_folder, target_folder, key, exc,
)
return False
logger.info(
"Merged folder %s -> %s for series %s (moved=%d skipped=%d removed_source=%s)",
current_folder, target_folder, key,
summary["moved"], summary["skipped"], summary["removed_source"],
)
# 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
try:
# Rename folder on disk
import shutil
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.error(
"Failed to rename folder for %s: %s -> %s: %s",
key,
current_folder,
target_folder,
str(e)
)
return False
@staticmethod
def _merge_folder_into_target(source: str, target: str) -> dict:
"""Merge a source folder's contents into an existing target folder.
Walks the source tree and moves every file into the matching path
under the target. When a destination file already exists, the
source copy is removed (the target version wins; we don't keep
duplicates). When the source tree is fully consumed, the
(now-empty) source directory is removed.
Both paths must be absolute and ``target`` must already exist on
disk.
Returns a summary dict with ``moved`` (file count), ``skipped``
(file count where target already had a copy), and
``removed_source`` (bool).
"""
import os
import shutil
if not os.path.isdir(source):
return {"moved": 0, "skipped": 0, "removed_source": False}
if not os.path.isdir(target):
raise ValueError(f"target does not exist: {target}")
moved = 0
skipped = 0
for root, _dirs, files in os.walk(source):
rel_root = os.path.relpath(root, source)
dest_root = (
target if rel_root == "."
else os.path.join(target, rel_root)
)
os.makedirs(dest_root, exist_ok=True)
for name in files:
src_file = os.path.join(root, name)
dest_file = os.path.join(dest_root, name)
if os.path.exists(dest_file):
# Target wins — never overwrite existing content.
# Remove the orphaned source copy so cleanup below
# can rmdir it.
try:
os.remove(src_file)
except OSError as exc:
logger.warning(
"merge: could not remove duplicate %s: %s",
src_file, exc,
)
skipped += 1
logger.warning(
"merge: skipping %s (target already has %s)",
src_file, dest_file,
)
continue
shutil.move(src_file, dest_file)
moved += 1
# Try to remove the (now empty) source tree. Walk bottom-up so
# leaf directories are removed before their parents.
removed_source = False
for root, dirs, files in os.walk(source, topdown=False):
for d in dirs:
try:
os.rmdir(os.path.join(root, d))
except OSError:
pass
try:
os.rmdir(source)
removed_source = True
except OSError as exc:
logger.warning(
"merge: could not remove source directory %s: %s",
source, exc,
)
return {"moved": moved, "skipped": skipped, "removed_source": removed_source}
async def contains_in_db(self, key: str, db) -> bool:
"""
Check if a series with the given key exists in the database.
Args:
key: The unique provider identifier for the series
db: Database session for async operations
Returns:
True if the series exists in the database
"""
from src.server.database.service import AnimeSeriesService
existing = await AnimeSeriesService.get_by_key(db, key)
return existing is not None
async def download(
self,
serie_folder: str,
season: int,
episode: int,
key: str,
item_id: Optional[str] = None,
) -> bool:
"""Start a download for a specific episode.
The SeriesApp handles progress tracking via events which are
forwarded to the ProgressService through event handlers.
Args:
serie_folder: Serie folder name (metadata only, used for
filesystem operations and display)
season: Season number
episode: Episode number
key: Serie unique identifier (primary identifier for series
lookup, provider-assigned)
item_id: Optional download queue item ID for tracking
Returns:
True on success
Raises:
AnimeServiceError: If download fails
InterruptedError: If download was cancelled
Note:
The 'key' parameter is the primary identifier used for all
series lookups. The 'serie_folder' is only used for filesystem
path construction and display purposes.
"""
try:
# Store event loop for event handlers
self._event_loop = asyncio.get_running_loop()
# SeriesApp.download is now async and handles events internally
return await self._app.download(
serie_folder=serie_folder,
season=season,
episode=episode,
key=key,
item_id=item_id,
)
except InterruptedError:
# Download was cancelled - re-raise for proper handling
logger.info("Download cancelled, propagating cancellation")
raise
except Exception as exc:
logger.error("download failed: %s", str(exc))
raise AnimeServiceError("Download failed") from exc
async def update_nfo_status(
self,
key: str,
has_nfo: bool,
tmdb_id: Optional[int] = None,
tvdb_id: Optional[int] = None,
db=None
) -> None:
"""Update NFO status for a series in the database.
Args:
key: Serie unique identifier
has_nfo: Whether tvshow.nfo exists
tmdb_id: Optional TMDB ID
tvdb_id: Optional TVDB ID
db: Optional database session (will create if not provided)
Raises:
AnimeServiceError: If update fails
"""
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
try:
# Get or create database session
if db is None:
async with get_db_session() as db:
# Find series by key using service layer
series = await AnimeSeriesService.get_by_key(db, key)
if not series:
logger.warning(
"Series not found in database for NFO update",
key=key
)
return
# Prepare update fields
now = datetime.now(timezone.utc)
update_fields = {"has_nfo": has_nfo}
if has_nfo:
if series.nfo_created_at is None:
update_fields["nfo_created_at"] = now
update_fields["nfo_updated_at"] = now
if tmdb_id is not None:
update_fields["tmdb_id"] = tmdb_id
if tvdb_id is not None:
update_fields["tvdb_id"] = tvdb_id
# Use service layer for update
await AnimeSeriesService.update(db, series.id, **update_fields)
await db.commit()
logger.info(
"Updated NFO status in database",
key=key,
has_nfo=has_nfo,
tmdb_id=tmdb_id,
tvdb_id=tvdb_id
)
else:
# Use provided session
series = await AnimeSeriesService.get_by_key(db, key)
if not series:
logger.warning(
"Series not found in database for NFO update",
key=key
)
return
# Update fields directly on the ORM object
now = datetime.now(timezone.utc)
series.has_nfo = has_nfo
if has_nfo:
if series.nfo_created_at is None:
series.nfo_created_at = now
series.nfo_updated_at = now
if tmdb_id is not None:
series.tmdb_id = tmdb_id
if tvdb_id is not None:
series.tvdb_id = tvdb_id
await db.commit()
logger.info(
"Updated NFO status in database",
key=key,
has_nfo=has_nfo,
tmdb_id=tmdb_id,
tvdb_id=tvdb_id
)
except Exception as exc:
logger.error(
"Failed to update NFO status key=%s has_nfo=%s: %s",
key, has_nfo, str(exc)
)
raise AnimeServiceError("NFO status update failed") from exc
async def get_series_without_nfo(self, db=None) -> list[dict]:
"""Get list of series that don't have NFO files.
Args:
db: Optional database session
Returns:
List of series dictionaries with keys:
- key: Series unique identifier
- name: Series name
- folder: Series folder name
- has_nfo: Always False
- tmdb_id: TMDB ID if available
- tvdb_id: TVDB ID if available
Raises:
AnimeServiceError: If query fails
"""
from sqlalchemy import select
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
try:
# Get or create database session
if db is None:
async with get_db_session() as db:
# Query series without NFO using service layer
series_list = await AnimeSeriesService.get_series_without_nfo(db)
result = []
for series in series_list:
result.append({
"key": series.key,
"name": series.name,
"folder": series.folder,
"has_nfo": False,
"tmdb_id": series.tmdb_id,
"tvdb_id": series.tvdb_id,
"nfo_created_at": None,
"nfo_updated_at": None
})
logger.info(
"Retrieved series without NFO",
count=len(result)
)
return result
else:
# Use provided session
series_list = await AnimeSeriesService.get_series_without_nfo(db)
result = []
for series in series_list:
result.append({
"key": series.key,
"name": series.name,
"folder": series.folder,
"has_nfo": False,
"tmdb_id": series.tmdb_id,
"tvdb_id": series.tvdb_id,
"nfo_created_at": None,
"nfo_updated_at": None
})
logger.info(
"Retrieved series without NFO",
count=len(result)
)
return result
except Exception as exc:
logger.error("Failed to query series without NFO: %s", str(exc))
raise AnimeServiceError(
"Query for series without NFO failed"
) from exc
async def get_nfo_statistics(self, db=None) -> dict:
"""Get NFO statistics for all series.
Args:
db: Optional database session
Returns:
Dictionary with statistics:
- total: Total series count
- with_nfo: Series with NFO files
- without_nfo: Series without NFO files
- with_tmdb_id: Series with TMDB ID
- with_tvdb_id: Series with TVDB ID
Raises:
AnimeServiceError: If query fails
"""
from sqlalchemy import func, select
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
try:
# Get or create database session
if db is None:
async with get_db_session() as db:
# Use service layer count methods
total = await AnimeSeriesService.count_all(db)
with_nfo = await AnimeSeriesService.count_with_nfo(db)
with_tmdb = await AnimeSeriesService.count_with_tmdb_id(db)
with_tvdb = await AnimeSeriesService.count_with_tvdb_id(db)
stats = {
"total": total,
"with_nfo": with_nfo,
"without_nfo": total - with_nfo,
"with_tmdb_id": with_tmdb,
"with_tvdb_id": with_tvdb
}
logger.info("Retrieved NFO statistics total=%d with_nfo=%d without_nfo=%d with_tmdb_id=%d with_tvdb_id=%d",
total, with_nfo, total - with_nfo, with_tmdb, with_tvdb)
return stats
else:
# Use provided session and service layer count methods
total = await AnimeSeriesService.count_all(db)
with_nfo = await AnimeSeriesService.count_with_nfo(db)
with_tmdb = await AnimeSeriesService.count_with_tmdb_id(db)
with_tvdb = await AnimeSeriesService.count_with_tvdb_id(db)
stats = {
"total": total,
"with_nfo": with_nfo,
"without_nfo": total - with_nfo,
"with_tmdb_id": with_tmdb,
"with_tvdb_id": with_tvdb
}
logger.info("Retrieved NFO statistics total=%d with_nfo=%d without_nfo=%d with_tmdb_id=%d with_tvdb_id=%d",
total, with_nfo, total - with_nfo, with_tmdb, with_tvdb)
return stats
except Exception as exc:
logger.error("Failed to get NFO statistics: %s", str(exc))
raise AnimeServiceError("NFO statistics query failed") from exc
async def delete_series(
self,
key: str,
delete_database: bool = True,
delete_folder: bool = False,
) -> "DeleteSeriesResult":
"""Delete an anime series from database, filesystem, or both.
Args:
key: Series key (primary identifier)
delete_database: If True, remove from database (default True)
delete_folder: If True, remove folder from filesystem (default False)
Returns:
DeleteSeriesResult with success status, what was deleted, errors
Deletion order: filesystem first, database second.
This order matters: if the folder delete fails (e.g. permission
error, path outside the configured anime directory), the database
row is preserved so the user can retry the delete once the
underlying issue is resolved. If we deleted the database row
first, an orphan folder would be left on disk with no way to
clean it up through the normal delete flow.
Orphan folder recovery: when ``delete_folder=True`` is requested
for a series whose database row no longer exists, the configured
anime directory is scanned for a folder that uniquely matches
the key. This recovers the case where a previous delete with
``delete_database=True`` succeeded but ``delete_folder=True``
silently failed, leaving the folder on disk.
"""
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
from src.server.models.anime import DeleteSeriesResult
from src.server.utils.filesystem import is_safe_path
import os as _os
import re
import shutil
logger.info(
"Delete series requested: key=%s delete_database=%s delete_folder=%s",
key, delete_database, delete_folder,
)
# Guard: at least one deletion mode must be selected
if not delete_database and not delete_folder:
logger.warning(
"Delete series rejected - no options selected: key=%s", key
)
return DeleteSeriesResult(
success=False,
key=key,
name="",
folder_path=None,
deleted_from_database=False,
deleted_folder=False,
database_error=None,
folder_error=None,
message="At least one of delete_database or delete_folder must be True.",
)
# Look up the series in the DB to get its folder path
series = None
async with get_db_session() as db:
series = await AnimeSeriesService.get_by_key(db, key)
if not series:
logger.warning(
"Delete series - row not found in DB: key=%s delete_folder=%s",
key, delete_folder,
)
# Recovery path: if the user wants to delete the folder but the
# DB row is already gone (e.g. orphaned by a previous partial
# delete), scan the configured anime directory for a folder
# that uniquely matches this key and delete it.
if delete_folder:
folder_path = self._find_orphan_folder_for_key(key)
if folder_path:
logger.info(
"Orphan folder recovery: key=%s matched folder=%s",
key, folder_path,
)
result = DeleteSeriesResult(
success=True,
key=key,
name="",
folder_path=folder_path,
message="",
)
self._delete_folder_at_path(folder_path, key, result)
# No DB row to delete; build message and return
self._build_delete_message(result)
logger.info(
"Delete series completed (orphan recovery): key=%s "
"deleted_folder=%s folder_error=%s",
key, result.deleted_folder, result.folder_error,
)
return result
return DeleteSeriesResult(
success=False,
key=key,
name="",
folder_path=None,
deleted_from_database=False,
deleted_folder=False,
database_error=None,
folder_error=(
f"Series '{key}' not found in database, and no folder "
"matching this key was found in the anime directory. "
"Nothing to delete."
),
message=(
f"Series '{key}' not found. If the folder on disk is "
"still required to be removed, please specify its "
"exact name on the filesystem."
),
)
# No row, no folder requested — nothing to do
return DeleteSeriesResult(
success=False,
key=key,
name="",
folder_path=None,
deleted_from_database=False,
deleted_folder=False,
database_error=None,
folder_error=None,
message=f"Series '{key}' not found.",
)
series_id = series.id
series_name = series.name
folder_path = series.folder
result = DeleteSeriesResult(
success=True,
key=key,
name=series_name,
folder_path=folder_path,
message="",
)
# --- Filesystem deletion (do FIRST so a failure preserves the DB row) ---
if delete_folder and folder_path:
self._delete_folder_at_path(folder_path, key, result)
# If folder delete was requested but failed, abort before
# removing the DB row so the user can retry.
if not result.deleted_folder and result.folder_error:
result.success = False
self._build_delete_message(result)
logger.warning(
"Delete series aborted - folder delete failed; DB row preserved: "
"key=%s folder_error=%s",
key, result.folder_error,
)
return result
# --- Database deletion (do AFTER folder delete) ---
if delete_database:
try:
async with get_db_session() as db:
deleted = await AnimeSeriesService.delete(db, series_id)
if deleted:
logger.info(
"Deleted series from database: key=%s name=%s id=%d",
key, series_name, series_id,
)
result.deleted_from_database = True
else:
# Already gone is treated as success
result.deleted_from_database = True
except Exception as exc:
logger.error(
"Failed to delete series from database: key=%s error=%s",
key, str(exc),
)
result.database_error = str(exc)
result.success = False
# Invalidate list cache
try:
self._cached_list_missing.cache_clear()
except Exception: # pylint: disable=broad-except
pass
# Broadcast deletion via WebSocket
try:
await self._broadcast_series_deleted(key, series_name)
except Exception as exc:
logger.warning(
"Failed to broadcast series_deleted for %s: %s",
key, exc,
)
# --- Build message ---
self._build_delete_message(result)
logger.info(
"Delete series completed: key=%s deleted_db=%s deleted_folder=%s",
key, result.deleted_from_database, result.deleted_folder,
)
return result
def _delete_folder_at_path(self, folder_path, key, result):
"""Resolve ``folder_path`` against the configured anime directory
and attempt to remove it. Updates ``result`` in place.
Resolves relative paths against ``self._directory`` so the safety
check operates on the real intended target (the process's current
working directory is not used as the base; in containers CWD may
differ from the anime directory, e.g. /app vs /data).
"""
import os as _os
import shutil
# Resolve absolute path and validate it is within base directory.
#
# Important: `folder_path` stored in the database is the relative
# folder name (e.g. "Beyblade Burst (2016)"), not an absolute path.
# If we feed a relative path to os.path.abspath() it gets joined
# against the process's current working directory — which may be
# /app inside the container while the anime directory is /data,
# producing e.g. "/app/Beyblade Burst (2016)" and tripping the
# safe-path check below for what is actually a valid deletion.
# Resolve relative paths against the configured anime directory
# so the safety check operates on the real intended target.
from src.server.utils.filesystem import is_safe_path
base_dir = _os.path.abspath(self._directory)
if _os.path.isabs(folder_path):
abs_folder = _os.path.abspath(folder_path)
else:
abs_folder = _os.path.abspath(_os.path.join(base_dir, folder_path))
if not is_safe_path(base_dir, abs_folder):
logger.warning(
"Blocked unsafe folder delete attempt: key=%s path=%s base=%s",
key, abs_folder, base_dir,
)
result.folder_error = (
f"Path '{abs_folder}' is outside the anime directory "
f"'{base_dir}' and will not be deleted."
)
result.success = False
return
if not _os.path.isdir(abs_folder):
logger.warning(
"Delete folder skipped - path does not exist: key=%s path=%s",
key, abs_folder,
)
# Not an error; folder might never have existed
return
try:
logger.info(
"Deleting series folder: key=%s path=%s",
key, abs_folder,
)
shutil.rmtree(abs_folder)
logger.info(
"Deleted series folder: key=%s path=%s",
key, abs_folder,
)
result.deleted_folder = True
except Exception as exc:
logger.error(
"Failed to delete series folder: key=%s path=%s error=%s",
key, abs_folder, str(exc),
)
result.folder_error = str(exc)
result.success = False
def _find_orphan_folder_for_key(self, key: str):
"""Locate a folder under ``self._directory`` that uniquely matches
the given series ``key``.
Used as a recovery path when the DB row is gone but the on-disk
folder still exists (orphaned by a previous partial delete).
Matching strategy: for each immediate subdirectory of the anime
directory, strip a trailing ``(YYYY)`` year suffix if present and
then compare the normalized form (lowercased, non-alphanumerics
removed, key's hyphens treated as separators) against the key.
Returns the folder name (relative to the anime directory) of the
unique match, or ``None`` if zero or multiple folders match.
Returns:
The matching relative folder name, or None when no unique
match exists. Returning None is the safe default — it forces
the caller to surface an explicit error rather than risk
deleting the wrong folder.
"""
import os as _os
import re
if not self._directory or not _os.path.isdir(self._directory):
return None
def _normalize(value: str) -> str:
# Drop an optional trailing "(YYYY)" or "(YYYY)"-with-content
# suffix the user might have added for disambiguation. We only
# strip a single trailing parenthesised group to avoid eating
# legitimate parts of the title.
value = re.sub(r"\s*\([^)]*\)\s*$", "", value or "")
# Lowercase, replace hyphens/underscores with empty so they
# line up with the way the key is constructed.
lowered = value.lower().replace("-", "").replace("_", "")
# Keep only alphanumerics (which preserves CJK characters
# because \w in unicode mode includes them; using explicit
# alphanumerics is safer cross-platform).
return re.sub(r"[^0-9a-z\u00C0-\uFFFF]", "", lowered)
target = _normalize(key)
if not target:
return None
candidates = []
try:
entries = _os.listdir(self._directory)
except OSError:
return None
for entry in entries:
full = _os.path.join(self._directory, entry)
if not _os.path.isdir(full):
continue
if _normalize(entry) == target:
candidates.append(entry)
if len(candidates) == 1:
return candidates[0]
if len(candidates) > 1:
logger.warning(
"Orphan folder recovery: ambiguous match for key=%s "
"found %d candidate folders: %s",
key, len(candidates), candidates,
)
return None
@staticmethod
def _build_delete_message(result) -> None:
"""Assemble the human-readable ``result.message`` from flags/errors."""
parts = []
if result.deleted_from_database and not result.database_error:
parts.append("removed from database")
if result.deleted_folder and not result.folder_error:
parts.append("folder deleted from filesystem")
if result.database_error:
parts.append(f"database error: {result.database_error}")
if result.folder_error:
parts.append(f"folder error: {result.folder_error}")
if parts:
result.message = "; ".join(parts)
else:
result.message = "No action taken."
async def _broadcast_series_deleted(self, key: str, name: str) -> None:
"""Broadcast series_deleted event via WebSocket."""
try:
await self._websocket_service.broadcast_series_deleted(
key=key,
name=name,
)
logger.info(
"series_deleted broadcast sent: key=%s name=%s",
key, name,
)
except Exception as exc:
logger.warning(
"Failed to broadcast series_deleted: key=%s error=%s",
key, str(exc),
)
def get_anime_service(series_app: SeriesApp) -> AnimeService:
"""Factory used for creating AnimeService with a SeriesApp instance."""
return AnimeService(series_app)