Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de330dc146 | |||
| 4731fd644a | |||
| 9d52ff0c45 | |||
| ee5d719f37 | |||
| cbc44491e7 | |||
| e319cfecb8 | |||
| 4f61ded92a | |||
| d6082b5cf6 | |||
| e76cd3a708 | |||
| 08f7f7453c | |||
| 023ddd182f |
@@ -1 +1 @@
|
|||||||
v1.4.14
|
v1.4.17
|
||||||
|
|||||||
3
Docs/key
3
Docs/key
@@ -4,4 +4,5 @@ API key : 299ae8f630a31bda814263c551361448
|
|||||||
/setup
|
/setup
|
||||||
|
|
||||||
|
|
||||||
SeriesApp initialized for directory:
|
SeriesApp initialized for directory:
|
||||||
|
to remove:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aniworld-web",
|
"name": "aniworld-web",
|
||||||
"version": "1.4.14",
|
"version": "1.4.17",
|
||||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -279,30 +279,15 @@ async def update_directory(
|
|||||||
|
|
||||||
config_service.save_config(app_config)
|
config_service.save_config(app_config)
|
||||||
|
|
||||||
# Sync series from data files to database
|
# Series are now loaded directly from database, no sync needed
|
||||||
sync_count = 0
|
logger.info(
|
||||||
try:
|
"Directory updated successfully",
|
||||||
import structlog
|
directory=directory
|
||||||
|
)
|
||||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
sync_count = await sync_legacy_series_to_db(directory, logger)
|
|
||||||
logger.info(
|
|
||||||
"Directory updated: synced series from data files",
|
|
||||||
directory=directory,
|
|
||||||
count=sync_count
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
# Log but don't fail the directory update if sync fails
|
|
||||||
import structlog
|
|
||||||
structlog.get_logger(__name__).warning(
|
|
||||||
"Failed to sync series after directory update",
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
|
|
||||||
response: Dict[str, Any] = {
|
response: Dict[str, Any] = {
|
||||||
"message": "Anime directory updated successfully",
|
"message": "Anime directory updated successfully",
|
||||||
"synced_series": sync_count
|
"synced_series": 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ EXPECTED_TABLES = {
|
|||||||
"download_queue",
|
"download_queue",
|
||||||
"user_sessions",
|
"user_sessions",
|
||||||
"system_settings",
|
"system_settings",
|
||||||
|
"unresolved_folders",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Expected indexes for performance
|
# Expected indexes for performance
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||||
@@ -210,6 +210,15 @@ class AnimeSeries(Base, TimestampMixin):
|
|||||||
episode_dict[season].append(ep.episode_number or 0)
|
episode_dict[season].append(ep.episode_number or 0)
|
||||||
return episode_dict
|
return episode_dict
|
||||||
|
|
||||||
|
@episodeDict.setter
|
||||||
|
def episodeDict(self, value: dict[int, list[int]]) -> None:
|
||||||
|
"""Set the episode dictionary via private cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Dictionary mapping season numbers to lists of episode numbers
|
||||||
|
"""
|
||||||
|
self._episode_dict_cache = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name_with_year(self) -> str:
|
def name_with_year(self) -> str:
|
||||||
"""Get series name with year appended if available.
|
"""Get series name with year appended if available.
|
||||||
@@ -238,6 +247,21 @@ class AnimeSeries(Base, TimestampMixin):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return sanitize_folder_name(self.key)
|
return sanitize_folder_name(self.key)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary for cache serialization.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with series data including episodeDict for
|
||||||
|
auto-download functionality.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"key": self.key,
|
||||||
|
"name": self.name,
|
||||||
|
"site": self.site,
|
||||||
|
"folder": self.folder,
|
||||||
|
"episodeDict": self.episodeDict,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Episode(Base, TimestampMixin):
|
class Episode(Base, TimestampMixin):
|
||||||
"""SQLAlchemy model for anime episodes.
|
"""SQLAlchemy model for anime episodes.
|
||||||
|
|||||||
@@ -422,3 +422,32 @@ class TMDBClient:
|
|||||||
if expired_keys:
|
if expired_keys:
|
||||||
logger.debug("Removed %d expired negative cache entries", len(expired_keys))
|
logger.debug("Removed %d expired negative cache entries", len(expired_keys))
|
||||||
return len(expired_keys)
|
return len(expired_keys)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tmdb_client() -> TMDBClient:
|
||||||
|
"""Factory function to create a TMDBClient with settings configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TMDBClient instance configured with settings.tmdb_api_key
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If TMDB API key is not configured
|
||||||
|
"""
|
||||||
|
from src.config.settings import settings
|
||||||
|
|
||||||
|
if not settings.tmdb_api_key:
|
||||||
|
raise ValueError("TMDB API key is not configured")
|
||||||
|
|
||||||
|
return TMDBClient(api_key=settings.tmdb_api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tmdb_image_base_url(tmdb_id: int) -> str:
|
||||||
|
"""Get the base URL for TMDB images.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tmdb_id: TMDB show ID (used for account-specific URLs)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base URL string for TMDB images
|
||||||
|
"""
|
||||||
|
return "https://image.tmdb.org/t/p/"
|
||||||
|
|||||||
@@ -1618,139 +1618,3 @@ class AnimeService:
|
|||||||
def get_anime_service(series_app: SeriesApp) -> AnimeService:
|
def get_anime_service(series_app: SeriesApp) -> AnimeService:
|
||||||
"""Factory used for creating AnimeService with a SeriesApp instance."""
|
"""Factory used for creating AnimeService with a SeriesApp instance."""
|
||||||
return AnimeService(series_app)
|
return AnimeService(series_app)
|
||||||
|
|
||||||
|
|
||||||
async def sync_legacy_series_to_db(
|
|
||||||
anime_directory: str,
|
|
||||||
log_instance=None # pylint: disable=unused-argument
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
One-time legacy sync: import any series from 'data' files
|
|
||||||
not already in the database.
|
|
||||||
|
|
||||||
Deprecated: Series are now loaded directly from the database.
|
|
||||||
This function remains for backwards compatibility with legacy
|
|
||||||
file-based data during migration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
anime_directory: Path to the anime directory with data files
|
|
||||||
log_instance: Optional logger instance (unused, kept for API
|
|
||||||
compatibility). This function always uses structlog internally.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of new series added to the database
|
|
||||||
"""
|
|
||||||
# Always use structlog for structured logging with keyword arguments
|
|
||||||
log = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
log.warning(
|
|
||||||
"sync_legacy_series_to_db is deprecated. "
|
|
||||||
"Series are now loaded directly from database."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from src.server.database.connection import get_db_session
|
|
||||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"Starting data file to database sync",
|
|
||||||
directory=anime_directory
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get all series from data files using SeriesApp
|
|
||||||
series_app = SeriesApp(anime_directory)
|
|
||||||
all_series = await asyncio.to_thread(
|
|
||||||
series_app.get_all_series_from_data_files
|
|
||||||
)
|
|
||||||
|
|
||||||
if not all_series:
|
|
||||||
log.info("No series found in data files to sync")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"Found series in data files, syncing to database",
|
|
||||||
count=len(all_series)
|
|
||||||
)
|
|
||||||
|
|
||||||
async with get_db_session() as db:
|
|
||||||
added_count = 0
|
|
||||||
skipped_count = 0
|
|
||||||
for serie in all_series:
|
|
||||||
# Handle series with empty name - use folder as fallback
|
|
||||||
if not serie.name or not serie.name.strip():
|
|
||||||
if serie.folder and serie.folder.strip():
|
|
||||||
serie.name = serie.folder.strip()
|
|
||||||
log.debug(
|
|
||||||
"Using folder as name fallback",
|
|
||||||
key=serie.key,
|
|
||||||
folder=serie.folder
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
log.warning(
|
|
||||||
"Skipping series with empty name and folder",
|
|
||||||
key=serie.key
|
|
||||||
)
|
|
||||||
skipped_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check if series already exists in DB
|
|
||||||
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
|
||||||
if existing:
|
|
||||||
log.debug(
|
|
||||||
"Series already exists in database",
|
|
||||||
name=serie.name,
|
|
||||||
key=serie.key
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Create new series in database
|
|
||||||
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 for each episode in episodeDict
|
|
||||||
if serie.episodeDict:
|
|
||||||
for season, episode_numbers in serie.episodeDict.items():
|
|
||||||
for episode_number in episode_numbers:
|
|
||||||
await EpisodeService.create(
|
|
||||||
db=db,
|
|
||||||
series_id=anime_series.id,
|
|
||||||
season=season,
|
|
||||||
episode_number=episode_number,
|
|
||||||
)
|
|
||||||
|
|
||||||
added_count += 1
|
|
||||||
log.debug(
|
|
||||||
"Added series to database",
|
|
||||||
name=serie.name,
|
|
||||||
key=serie.key
|
|
||||||
)
|
|
||||||
except Exception as e: # pylint: disable=broad-except
|
|
||||||
log.warning(
|
|
||||||
"Failed to add series to database",
|
|
||||||
key=serie.key,
|
|
||||||
name=serie.name,
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
skipped_count += 1
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"Data file sync complete",
|
|
||||||
added=added_count,
|
|
||||||
skipped=len(all_series) - added_count
|
|
||||||
)
|
|
||||||
return added_count
|
|
||||||
|
|
||||||
except Exception as e: # pylint: disable=broad-except
|
|
||||||
log.warning(
|
|
||||||
"Failed to sync series to database",
|
|
||||||
error=str(e),
|
|
||||||
exc_info=True
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import structlog
|
|||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.server.database.service import AnimeSeriesService
|
from src.server.database.service import AnimeSeriesService
|
||||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
|
||||||
from src.server.services.setup_service import SetupService
|
from src.server.services.setup_service import SetupService
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
@@ -237,14 +236,15 @@ async def _sync_anime_folders(progress_service=None) -> int:
|
|||||||
metadata={"step_id": "series_sync"}
|
metadata={"step_id": "series_sync"}
|
||||||
)
|
)
|
||||||
|
|
||||||
sync_count = await sync_legacy_series_to_db(settings.anime_directory)
|
# Legacy sync removed - series are loaded directly from database via _load_series_into_memory
|
||||||
logger.info("Data file sync complete. Added %d series.", sync_count)
|
sync_count = 0
|
||||||
|
logger.info("Data file sync skipped - series loaded directly from database")
|
||||||
|
|
||||||
if progress_service:
|
if progress_service:
|
||||||
await progress_service.update_progress(
|
await progress_service.update_progress(
|
||||||
progress_id="series_sync",
|
progress_id="series_sync",
|
||||||
current=75,
|
current=75,
|
||||||
message=f"Synced {sync_count} series from data files",
|
message=f"Series loaded directly from database",
|
||||||
metadata={"step_id": "series_sync"}
|
metadata={"step_id": "series_sync"}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -478,13 +478,8 @@ async def _execute_nfo_scan(progress_service=None) -> None:
|
|||||||
key=data.get('key'),
|
key=data.get('key'),
|
||||||
folder=data.get('folder'),
|
folder=data.get('folder'),
|
||||||
)
|
)
|
||||||
elif event_data.get('type') == 'nfo_scan_completed':
|
# Note: nfo_scan_completed event is NOT handled here because
|
||||||
stats = event_data.get('statistics', {})
|
# perform_nfo_scan_phase handles completion after _execute_nfo_scan returns
|
||||||
if progress_service:
|
|
||||||
await progress_service.complete_progress(
|
|
||||||
progress_id="nfo_scan",
|
|
||||||
message=f"NFO scan complete: {stats.get('created', 0)} created, {stats.get('updated', 0)} updated",
|
|
||||||
)
|
|
||||||
|
|
||||||
nfo_service.subscribe_to_scan_events(nfo_event_handler)
|
nfo_service.subscribe_to_scan_events(nfo_event_handler)
|
||||||
|
|
||||||
@@ -546,16 +541,25 @@ async def perform_nfo_scan_if_needed(progress_service=None):
|
|||||||
|
|
||||||
# Execute the NFO scan
|
# Execute the NFO scan
|
||||||
try:
|
try:
|
||||||
|
# Ensure any newly created series are loaded from DB into SeriesApp memory
|
||||||
|
await _load_series_into_memory(progress_service=None)
|
||||||
await _execute_nfo_scan(progress_service)
|
await _execute_nfo_scan(progress_service)
|
||||||
await _mark_nfo_scan_completed()
|
await _mark_nfo_scan_completed()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to complete NFO scan: %s", e, exc_info=True)
|
logger.error("Failed to complete NFO scan: %s", e, exc_info=True)
|
||||||
if progress_service:
|
if progress_service:
|
||||||
await progress_service.fail_progress(
|
try:
|
||||||
progress_id="nfo_scan",
|
await progress_service.fail_progress(
|
||||||
error_message=f"NFO scan failed: {str(e)}",
|
progress_id="nfo_scan",
|
||||||
metadata={"step_id": "nfo_scan"}
|
error_message=f"NFO scan failed: {str(e)}",
|
||||||
)
|
metadata={"step_id": "nfo_scan"}
|
||||||
|
)
|
||||||
|
except Exception as fail_err:
|
||||||
|
logger.warning(
|
||||||
|
"Could not fail progress 'nfo_scan': %s",
|
||||||
|
fail_err,
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def perform_nfo_scan_phase(progress_service=None):
|
async def perform_nfo_scan_phase(progress_service=None):
|
||||||
@@ -613,6 +617,9 @@ async def perform_nfo_scan_phase(progress_service=None):
|
|||||||
|
|
||||||
# Execute the NFO scan
|
# Execute the NFO scan
|
||||||
try:
|
try:
|
||||||
|
# Ensure any newly created series (e.g., from resolving unresolved folders)
|
||||||
|
# are loaded from DB into SeriesApp memory before scanning
|
||||||
|
await _load_series_into_memory(progress_service=None)
|
||||||
await _execute_nfo_scan(progress_service)
|
await _execute_nfo_scan(progress_service)
|
||||||
await _mark_nfo_scan_completed()
|
await _mark_nfo_scan_completed()
|
||||||
|
|
||||||
@@ -627,11 +634,18 @@ async def perform_nfo_scan_phase(progress_service=None):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to complete NFO scan phase: %s", e, exc_info=True)
|
logger.error("Failed to complete NFO scan phase: %s", e, exc_info=True)
|
||||||
if progress_service:
|
if progress_service:
|
||||||
await progress_service.fail_progress(
|
try:
|
||||||
progress_id="nfo_scan",
|
await progress_service.fail_progress(
|
||||||
error_message=f"NFO scan failed: {str(e)}",
|
progress_id="nfo_scan",
|
||||||
metadata={"step_id": "nfo_scan", "phase": "nfo"}
|
error_message=f"NFO scan failed: {str(e)}",
|
||||||
)
|
metadata={"step_id": "nfo_scan", "phase": "nfo"}
|
||||||
|
)
|
||||||
|
except Exception as fail_err:
|
||||||
|
logger.warning(
|
||||||
|
"Could not fail progress 'nfo_scan': %s",
|
||||||
|
fail_err,
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _check_media_scan_status() -> bool:
|
async def _check_media_scan_status() -> bool:
|
||||||
|
|||||||
@@ -326,6 +326,22 @@ class NfoScanService:
|
|||||||
|
|
||||||
nfo_exists = os.path.isfile(nfo_path)
|
nfo_exists = os.path.isfile(nfo_path)
|
||||||
|
|
||||||
|
# If tmdb_id is missing, try to look it up by series name
|
||||||
|
if not series_data.get("tmdb_id"):
|
||||||
|
logger.debug("No tmdb_id for %s — attempting TMDB lookup", key)
|
||||||
|
name = series_data.get("name", "")
|
||||||
|
found_tmdb_id = await self._lookup_tmdb_id_by_name(name)
|
||||||
|
if found_tmdb_id:
|
||||||
|
series_data["tmdb_id"] = found_tmdb_id
|
||||||
|
await self._save_tmdb_id(key, found_tmdb_id)
|
||||||
|
logger.info("Found and saved tmdb_id %s for %s", found_tmdb_id, key)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Could not resolve tmdb_id for %s (%s)",
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
|
||||||
if not nfo_exists:
|
if not nfo_exists:
|
||||||
# Create new NFO
|
# Create new NFO
|
||||||
logger.info("Creating NFO for series: %s (%s)", key, folder)
|
logger.info("Creating NFO for series: %s (%s)", key, folder)
|
||||||
@@ -526,6 +542,53 @@ class NfoScanService:
|
|||||||
logger.info("Regenerated NFO for %s", key)
|
logger.info("Regenerated NFO for %s", key)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def _save_tmdb_id(self, key: str, tmdb_id: int) -> None:
|
||||||
|
"""Save tmdb_id to the database for a series.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Series key (primary identifier)
|
||||||
|
tmdb_id: TMDB series ID to save
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.server.database.connection import get_db_session
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
async with get_db_session() as db:
|
||||||
|
series = await AnimeSeriesService.get_by_key(db, key)
|
||||||
|
if series:
|
||||||
|
series.tmdb_id = tmdb_id
|
||||||
|
await db.flush()
|
||||||
|
logger.debug("Saved tmdb_id %s for series: %s", tmdb_id, key)
|
||||||
|
else:
|
||||||
|
logger.warning("Series not found for tmdb_id save: %s", key)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to save tmdb_id for %s: %s", key, exc)
|
||||||
|
|
||||||
|
async def _lookup_tmdb_id_by_name(self, name: str) -> Optional[int]:
|
||||||
|
"""Look up a TMDB series ID by series name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Series name to search for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TMDB series ID or None if not found.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.server.nfo.tmdb_client import get_tmdb_client
|
||||||
|
|
||||||
|
async with get_tmdb_client() as client:
|
||||||
|
results = await client.search_tv_show(name)
|
||||||
|
if results and results.get("results"):
|
||||||
|
first_result = results["results"][0]
|
||||||
|
return first_result.get("id")
|
||||||
|
return None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("TMDB lookup failed for %s: %s", name, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
async def _fetch_tmdb_data(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
async def _fetch_tmdb_data(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||||
"""Fetch series metadata from TMDB API.
|
"""Fetch series metadata from TMDB API.
|
||||||
|
|
||||||
@@ -538,8 +601,8 @@ class NfoScanService:
|
|||||||
try:
|
try:
|
||||||
from src.server.nfo.tmdb_client import get_tmdb_client
|
from src.server.nfo.tmdb_client import get_tmdb_client
|
||||||
|
|
||||||
client = get_tmdb_client()
|
async with get_tmdb_client() as client:
|
||||||
data = await client.get_series_details(tmdb_id)
|
data = await client.get_tv_show_details(tmdb_id)
|
||||||
return data
|
return data
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("TMDB fetch failed for TMDB ID %s: %s", tmdb_id, exc)
|
logger.warning("TMDB fetch failed for TMDB ID %s: %s", tmdb_id, exc)
|
||||||
|
|||||||
@@ -110,81 +110,6 @@ class TestGetAllSeriesFromDataFiles:
|
|||||||
assert len(result) == 0
|
assert len(result) == 0
|
||||||
|
|
||||||
|
|
||||||
class TestSyncSeriesToDatabase:
|
|
||||||
"""Test sync_legacy_series_to_db function from anime_service."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_with_empty_directory(self):
|
|
||||||
"""Test sync with empty anime directory."""
|
|
||||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
with patch('src.server.SeriesApp.Loaders'), \
|
|
||||||
patch('src.server.SeriesApp.SerieScanner'):
|
|
||||||
count = await sync_legacy_series_to_db(tmp_dir)
|
|
||||||
|
|
||||||
assert count == 0
|
|
||||||
# Function should complete successfully with no series
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_adds_new_series_to_database(self):
|
|
||||||
"""Test that sync adds new series to database.
|
|
||||||
|
|
||||||
This is a more realistic test that verifies series data is loaded
|
|
||||||
from files and the sync function attempts to add them to the DB.
|
|
||||||
The actual DB interaction is tested in test_add_to_db_creates_record.
|
|
||||||
"""
|
|
||||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
# Create test data files
|
|
||||||
_create_test_data_file(
|
|
||||||
tmp_dir,
|
|
||||||
folder="Sync Test Anime",
|
|
||||||
key="sync-test-anime",
|
|
||||||
name="Sync Test Anime",
|
|
||||||
episodes={1: [1, 2]}
|
|
||||||
)
|
|
||||||
|
|
||||||
# First verify that we can load the series from files
|
|
||||||
with patch('src.server.SeriesApp.Loaders'), \
|
|
||||||
patch('src.server.SeriesApp.SerieScanner'):
|
|
||||||
app = SeriesApp(tmp_dir)
|
|
||||||
series = app.get_all_series_from_data_files()
|
|
||||||
assert len(series) == 1
|
|
||||||
assert series[0].key == "sync-test-anime"
|
|
||||||
|
|
||||||
# Now test that the sync function loads series and handles DB
|
|
||||||
# gracefully (even if DB operations fail, it should not crash)
|
|
||||||
with patch('src.server.SeriesApp.Loaders'), \
|
|
||||||
patch('src.server.SeriesApp.SerieScanner'):
|
|
||||||
# The function should return 0 because DB isn't available
|
|
||||||
# but should not crash
|
|
||||||
count = await sync_legacy_series_to_db(tmp_dir)
|
|
||||||
|
|
||||||
# Since no real DB, it will fail gracefully
|
|
||||||
# Function returns 0 when DB operations fail
|
|
||||||
assert isinstance(count, int)
|
|
||||||
assert count == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_handles_exceptions_gracefully(self):
|
|
||||||
"""Test that sync handles exceptions without crashing."""
|
|
||||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
|
||||||
|
|
||||||
# Make SeriesApp raise an exception during initialization
|
|
||||||
with patch('src.server.SeriesApp.Loaders'), \
|
|
||||||
patch('src.server.SeriesApp.SerieScanner'), \
|
|
||||||
patch(
|
|
||||||
'src.server.SeriesApp.SerieList',
|
|
||||||
side_effect=Exception("Test error")
|
|
||||||
):
|
|
||||||
count = await sync_legacy_series_to_db("/fake/path")
|
|
||||||
|
|
||||||
assert count == 0
|
|
||||||
# Function should complete without crashing
|
|
||||||
|
|
||||||
|
|
||||||
class TestEndToEndSync:
|
class TestEndToEndSync:
|
||||||
"""End-to-end tests for the sync functionality."""
|
"""End-to-end tests for the sync functionality."""
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.server.services.anime_service import (
|
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||||
AnimeService,
|
|
||||||
AnimeServiceError,
|
|
||||||
sync_legacy_series_to_db,
|
|
||||||
)
|
|
||||||
from src.server.services.progress_service import ProgressService
|
from src.server.services.progress_service import ProgressService
|
||||||
|
|
||||||
|
|
||||||
@@ -1302,142 +1298,3 @@ class TestGetNFOStatisticsSelfManaged:
|
|||||||
assert result["with_tmdb_id"] == 40
|
assert result["with_tmdb_id"] == 40
|
||||||
|
|
||||||
|
|
||||||
class TestSyncSeriesFromDataFiles:
|
|
||||||
"""Test module-level sync_legacy_series_to_db function."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_adds_new_series(self, tmp_path):
|
|
||||||
"""Should create series for data files not in DB."""
|
|
||||||
mock_serie = MagicMock()
|
|
||||||
mock_serie.key = "new-series"
|
|
||||||
mock_serie.name = "New Series"
|
|
||||||
mock_serie.site = "aniworld.to"
|
|
||||||
mock_serie.folder = "New Series"
|
|
||||||
mock_serie.episodeDict = {1: [1]}
|
|
||||||
|
|
||||||
mock_session = AsyncMock()
|
|
||||||
mock_ctx = AsyncMock()
|
|
||||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
|
||||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.services.anime_service.SeriesApp"
|
|
||||||
) as MockApp, patch(
|
|
||||||
"src.server.database.connection.get_db_session",
|
|
||||||
return_value=mock_ctx,
|
|
||||||
), patch(
|
|
||||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=None,
|
|
||||||
), patch(
|
|
||||||
"src.server.database.service.AnimeSeriesService.create",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=MagicMock(id=1),
|
|
||||||
) as mock_create, patch(
|
|
||||||
"src.server.database.service.EpisodeService.create",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
):
|
|
||||||
mock_app_instance = MagicMock()
|
|
||||||
mock_app_instance.get_all_series_from_data_files.return_value = [
|
|
||||||
mock_serie
|
|
||||||
]
|
|
||||||
MockApp.return_value = mock_app_instance
|
|
||||||
|
|
||||||
count = await sync_legacy_series_to_db(str(tmp_path))
|
|
||||||
|
|
||||||
assert count == 1
|
|
||||||
mock_create.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_skips_existing(self, tmp_path):
|
|
||||||
"""Already-existing series should be skipped."""
|
|
||||||
mock_serie = MagicMock()
|
|
||||||
mock_serie.key = "exists"
|
|
||||||
mock_serie.name = "Exists"
|
|
||||||
mock_serie.site = "x"
|
|
||||||
mock_serie.folder = "Exists"
|
|
||||||
mock_serie.episodeDict = {}
|
|
||||||
|
|
||||||
mock_session = AsyncMock()
|
|
||||||
mock_ctx = AsyncMock()
|
|
||||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
|
||||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.services.anime_service.SeriesApp"
|
|
||||||
) as MockApp, patch(
|
|
||||||
"src.server.database.connection.get_db_session",
|
|
||||||
return_value=mock_ctx,
|
|
||||||
), patch(
|
|
||||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=MagicMock(),
|
|
||||||
), patch(
|
|
||||||
"src.server.database.service.AnimeSeriesService.create",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
) as mock_create:
|
|
||||||
mock_app_instance = MagicMock()
|
|
||||||
mock_app_instance.get_all_series_from_data_files.return_value = [
|
|
||||||
mock_serie
|
|
||||||
]
|
|
||||||
MockApp.return_value = mock_app_instance
|
|
||||||
|
|
||||||
count = await sync_legacy_series_to_db(str(tmp_path))
|
|
||||||
|
|
||||||
assert count == 0
|
|
||||||
mock_create.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_no_data_files(self, tmp_path):
|
|
||||||
"""Empty directory should return 0."""
|
|
||||||
with patch(
|
|
||||||
"src.server.services.anime_service.SeriesApp"
|
|
||||||
) as MockApp:
|
|
||||||
mock_app_instance = MagicMock()
|
|
||||||
mock_app_instance.get_all_series_from_data_files.return_value = []
|
|
||||||
MockApp.return_value = mock_app_instance
|
|
||||||
|
|
||||||
count = await sync_legacy_series_to_db(str(tmp_path))
|
|
||||||
|
|
||||||
assert count == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_handles_empty_name(self, tmp_path):
|
|
||||||
"""Series with empty name should use folder as fallback."""
|
|
||||||
mock_serie = MagicMock()
|
|
||||||
mock_serie.key = "no-name"
|
|
||||||
mock_serie.name = ""
|
|
||||||
mock_serie.site = "x"
|
|
||||||
mock_serie.folder = "FallbackFolder"
|
|
||||||
mock_serie.episodeDict = {}
|
|
||||||
|
|
||||||
mock_session = AsyncMock()
|
|
||||||
mock_ctx = AsyncMock()
|
|
||||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
|
||||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.services.anime_service.SeriesApp"
|
|
||||||
) as MockApp, patch(
|
|
||||||
"src.server.database.connection.get_db_session",
|
|
||||||
return_value=mock_ctx,
|
|
||||||
), patch(
|
|
||||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=None,
|
|
||||||
), patch(
|
|
||||||
"src.server.database.service.AnimeSeriesService.create",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=MagicMock(id=1),
|
|
||||||
) as mock_create:
|
|
||||||
mock_app_instance = MagicMock()
|
|
||||||
mock_app_instance.get_all_series_from_data_files.return_value = [
|
|
||||||
mock_serie
|
|
||||||
]
|
|
||||||
MockApp.return_value = mock_app_instance
|
|
||||||
|
|
||||||
count = await sync_legacy_series_to_db(str(tmp_path))
|
|
||||||
|
|
||||||
assert count == 1
|
|
||||||
# The name should have been set to folder
|
|
||||||
assert mock_serie.name == "FallbackFolder"
|
|
||||||
|
|||||||
@@ -473,12 +473,13 @@ async def test_validate_schema_with_inspection_error():
|
|||||||
def test_schema_constants():
|
def test_schema_constants():
|
||||||
"""Test that schema constants are properly defined."""
|
"""Test that schema constants are properly defined."""
|
||||||
assert CURRENT_SCHEMA_VERSION == "1.0.1"
|
assert CURRENT_SCHEMA_VERSION == "1.0.1"
|
||||||
assert len(EXPECTED_TABLES) == 5
|
assert len(EXPECTED_TABLES) == 6
|
||||||
assert "anime_series" in EXPECTED_TABLES
|
assert "anime_series" in EXPECTED_TABLES
|
||||||
assert "episodes" in EXPECTED_TABLES
|
assert "episodes" in EXPECTED_TABLES
|
||||||
assert "download_queue" in EXPECTED_TABLES
|
assert "download_queue" in EXPECTED_TABLES
|
||||||
assert "user_sessions" in EXPECTED_TABLES
|
assert "user_sessions" in EXPECTED_TABLES
|
||||||
assert "system_settings" in EXPECTED_TABLES
|
assert "system_settings" in EXPECTED_TABLES
|
||||||
|
assert "unresolved_folders" in EXPECTED_TABLES
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from src.server.api.health import (
|
|||||||
get_system_metrics,
|
get_system_metrics,
|
||||||
ready_check,
|
ready_check,
|
||||||
)
|
)
|
||||||
|
from src.server.utils.version import APP_VERSION
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -29,7 +30,7 @@ async def test_basic_health_check_no_startup_checks():
|
|||||||
|
|
||||||
assert isinstance(result, HealthStatus)
|
assert isinstance(result, HealthStatus)
|
||||||
assert result.status == "healthy"
|
assert result.status == "healthy"
|
||||||
assert result.version == "v1.3.6"
|
assert result.version == APP_VERSION
|
||||||
assert result.service == "aniworld-api"
|
assert result.service == "aniworld-api"
|
||||||
assert result.timestamp is not None
|
assert result.timestamp is not None
|
||||||
assert result.series_app_initialized is False
|
assert result.series_app_initialized is False
|
||||||
|
|||||||
@@ -161,14 +161,11 @@ class TestSyncAnimeFolders:
|
|||||||
async def test_sync_anime_folders_without_progress(self):
|
async def test_sync_anime_folders_without_progress(self):
|
||||||
"""Test syncing anime folders without progress service."""
|
"""Test syncing anime folders without progress service."""
|
||||||
with patch('src.server.services.initialization_service.settings') as mock_settings, \
|
with patch('src.server.services.initialization_service.settings') as mock_settings, \
|
||||||
patch('src.server.services.initialization_service.os.path.isdir', return_value=True), \
|
patch('src.server.services.initialization_service.os.path.isdir', return_value=True):
|
||||||
patch('src.server.services.initialization_service.sync_legacy_series_to_db',
|
|
||||||
new_callable=AsyncMock, return_value=42) as mock_sync:
|
|
||||||
mock_settings.anime_directory = "/path/to/anime"
|
mock_settings.anime_directory = "/path/to/anime"
|
||||||
result = await _sync_anime_folders()
|
result = await _sync_anime_folders()
|
||||||
|
|
||||||
assert result == 42
|
assert result == 0
|
||||||
mock_sync.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sync_anime_folders_with_progress(self):
|
async def test_sync_anime_folders_with_progress(self):
|
||||||
@@ -176,13 +173,11 @@ class TestSyncAnimeFolders:
|
|||||||
mock_progress = AsyncMock()
|
mock_progress = AsyncMock()
|
||||||
|
|
||||||
with patch('src.server.services.initialization_service.settings') as mock_settings, \
|
with patch('src.server.services.initialization_service.settings') as mock_settings, \
|
||||||
patch('src.server.services.initialization_service.os.path.isdir', return_value=True), \
|
patch('src.server.services.initialization_service.os.path.isdir', return_value=True):
|
||||||
patch('src.server.services.initialization_service.sync_legacy_series_to_db',
|
|
||||||
new_callable=AsyncMock, return_value=10) as mock_sync:
|
|
||||||
mock_settings.anime_directory = "/path/to/anime"
|
mock_settings.anime_directory = "/path/to/anime"
|
||||||
result = await _sync_anime_folders(progress_service=mock_progress)
|
result = await _sync_anime_folders(progress_service=mock_progress)
|
||||||
|
|
||||||
assert result == 10
|
assert result == 0
|
||||||
# Verify progress updates were called
|
# Verify progress updates were called
|
||||||
assert mock_progress.update_progress.call_count == 2
|
assert mock_progress.update_progress.call_count == 2
|
||||||
mock_progress.update_progress.assert_any_call(
|
mock_progress.update_progress.assert_any_call(
|
||||||
@@ -194,7 +189,7 @@ class TestSyncAnimeFolders:
|
|||||||
mock_progress.update_progress.assert_any_call(
|
mock_progress.update_progress.assert_any_call(
|
||||||
progress_id="series_sync",
|
progress_id="series_sync",
|
||||||
current=75,
|
current=75,
|
||||||
message="Synced 10 series from data files",
|
message="Series loaded directly from database",
|
||||||
metadata={"step_id": "series_sync"}
|
metadata={"step_id": "series_sync"}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -537,6 +532,8 @@ class TestPerformNFOScan:
|
|||||||
new_callable=AsyncMock, return_value=False), \
|
new_callable=AsyncMock, return_value=False), \
|
||||||
patch('src.server.services.initialization_service._is_nfo_scan_configured',
|
patch('src.server.services.initialization_service._is_nfo_scan_configured',
|
||||||
new_callable=AsyncMock, return_value=True), \
|
new_callable=AsyncMock, return_value=True), \
|
||||||
|
patch('src.server.services.initialization_service._load_series_into_memory',
|
||||||
|
new_callable=AsyncMock), \
|
||||||
patch('src.server.services.initialization_service._execute_nfo_scan',
|
patch('src.server.services.initialization_service._execute_nfo_scan',
|
||||||
new_callable=AsyncMock), \
|
new_callable=AsyncMock), \
|
||||||
patch('src.server.services.initialization_service._mark_nfo_scan_completed',
|
patch('src.server.services.initialization_service._mark_nfo_scan_completed',
|
||||||
@@ -554,6 +551,8 @@ class TestPerformNFOScan:
|
|||||||
new_callable=AsyncMock, return_value=False), \
|
new_callable=AsyncMock, return_value=False), \
|
||||||
patch('src.server.services.initialization_service._is_nfo_scan_configured',
|
patch('src.server.services.initialization_service._is_nfo_scan_configured',
|
||||||
new_callable=AsyncMock, return_value=True), \
|
new_callable=AsyncMock, return_value=True), \
|
||||||
|
patch('src.server.services.initialization_service._load_series_into_memory',
|
||||||
|
new_callable=AsyncMock), \
|
||||||
patch('src.server.services.initialization_service._execute_nfo_scan',
|
patch('src.server.services.initialization_service._execute_nfo_scan',
|
||||||
new_callable=AsyncMock), \
|
new_callable=AsyncMock), \
|
||||||
patch('src.server.services.initialization_service._mark_nfo_scan_completed',
|
patch('src.server.services.initialization_service._mark_nfo_scan_completed',
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ class TestTemplateHelpers:
|
|||||||
def test_get_base_context(self):
|
def test_get_base_context(self):
|
||||||
"""Test getting base context."""
|
"""Test getting base context."""
|
||||||
from src.server.utils.template_helpers import get_base_context
|
from src.server.utils.template_helpers import get_base_context
|
||||||
|
from src.server.utils.version import APP_VERSION
|
||||||
|
|
||||||
mock_request = MagicMock(spec=Request)
|
mock_request = MagicMock(spec=Request)
|
||||||
context = get_base_context(mock_request, "Test Title")
|
context = get_base_context(mock_request, "Test Title")
|
||||||
@@ -187,7 +188,7 @@ class TestTemplateHelpers:
|
|||||||
assert context["request"] == mock_request
|
assert context["request"] == mock_request
|
||||||
assert context["title"] == "Test Title"
|
assert context["title"] == "Test Title"
|
||||||
assert context["app_name"] == "Aniworld Download Manager"
|
assert context["app_name"] == "Aniworld Download Manager"
|
||||||
assert context["version"] == "v1.3.6"
|
assert context["version"] == APP_VERSION
|
||||||
|
|
||||||
def test_get_base_context_default_title(self):
|
def test_get_base_context_default_title(self):
|
||||||
"""Test getting base context with default title."""
|
"""Test getting base context with default title."""
|
||||||
|
|||||||
@@ -199,7 +199,9 @@ class TestSerieScannerSingleSeries:
|
|||||||
|
|
||||||
# Pre-populate keyDict
|
# Pre-populate keyDict
|
||||||
scanner.keyDict[sample_serie.key] = sample_serie
|
scanner.keyDict[sample_serie.key] = sample_serie
|
||||||
old_episode_dict = sample_serie.episodeDict.copy()
|
# Use deepcopy because episodeDict is modified in-place
|
||||||
|
import copy
|
||||||
|
old_episode_dict = copy.deepcopy(sample_serie.episodeDict)
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
scanner,
|
scanner,
|
||||||
@@ -211,9 +213,10 @@ class TestSerieScannerSingleSeries:
|
|||||||
folder=sample_serie.folder
|
folder=sample_serie.folder
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify existing entry was updated
|
# Verify existing entry was updated - episodeDict is merged (not replaced)
|
||||||
|
# Old episodes [2, 3, 4] + new episodes [10, 11, 12] = merged result
|
||||||
assert scanner.keyDict[sample_serie.key].episodeDict != old_episode_dict
|
assert scanner.keyDict[sample_serie.key].episodeDict != old_episode_dict
|
||||||
assert scanner.keyDict[sample_serie.key].episodeDict == {1: [10, 11, 12]}
|
assert scanner.keyDict[sample_serie.key].episodeDict == {1: [2, 3, 4, 10, 11, 12]}
|
||||||
|
|
||||||
def test_scan_single_series_empty_key_raises_error(
|
def test_scan_single_series_empty_key_raises_error(
|
||||||
self, temp_directory, mock_loader
|
self, temp_directory, mock_loader
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from src.server.utils.template_helpers import (
|
|||||||
prepare_series_context,
|
prepare_series_context,
|
||||||
validate_template_exists,
|
validate_template_exists,
|
||||||
)
|
)
|
||||||
|
from src.server.utils.version import APP_VERSION
|
||||||
|
|
||||||
|
|
||||||
class TestTemplateHelpers:
|
class TestTemplateHelpers:
|
||||||
@@ -30,7 +31,7 @@ class TestTemplateHelpers:
|
|||||||
assert context["request"] == request
|
assert context["request"] == request
|
||||||
assert context["title"] == "Test Title"
|
assert context["title"] == "Test Title"
|
||||||
assert context["app_name"] == "Aniworld Download Manager"
|
assert context["app_name"] == "Aniworld Download Manager"
|
||||||
assert context["version"] == "v1.3.6"
|
assert context["version"] == APP_VERSION
|
||||||
|
|
||||||
def test_get_base_context_default_title(self):
|
def test_get_base_context_default_title(self):
|
||||||
"""Test that default title is used."""
|
"""Test that default title is used."""
|
||||||
|
|||||||
Reference in New Issue
Block a user