refactor: restructure core→server, split large entity files into database module
- Move src/core/ → src/server/ - Split SerieList.py (531 lines) and series.py (414 lines) into src/server/database/ - Add database/models.py for SQLAlchemy models - Update all test imports to reflect new structure - Remove deprecated test files (test_serie_class.py, test_serie_folder_with_year.py)
This commit is contained in:
@@ -120,7 +120,7 @@ async def check_nfo_status():
|
|||||||
logger.info("Anime Directory: %s", settings.anime_directory)
|
logger.info("Anime Directory: %s", settings.anime_directory)
|
||||||
|
|
||||||
# Create series list (no NFO service needed for status check)
|
# Create series list (no NFO service needed for status check)
|
||||||
from src.core.entities.SerieList import SerieList
|
from src.server.database.SerieList import SerieList
|
||||||
serie_list = SerieList(settings.anime_directory)
|
serie_list = SerieList(settings.anime_directory)
|
||||||
all_series = serie_list.get_all()
|
all_series = serie_list.get_all()
|
||||||
|
|
||||||
|
|||||||
@@ -1,531 +0,0 @@
|
|||||||
"""Utilities for loading and managing stored anime series metadata.
|
|
||||||
|
|
||||||
This module provides the SerieList class for managing collections of anime
|
|
||||||
series metadata. It supports loading from both filesystem (legacy) and
|
|
||||||
database (primary).
|
|
||||||
|
|
||||||
Note:
|
|
||||||
This module is part of the core domain layer. Database operations
|
|
||||||
are handled by the service layer via add_to_db().
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import warnings
|
|
||||||
from json import JSONDecodeError
|
|
||||||
from typing import Dict, Iterable, List, Optional
|
|
||||||
|
|
||||||
from src.config.settings import settings
|
|
||||||
from src.core.entities.series import Serie
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SerieList:
|
|
||||||
"""
|
|
||||||
Represents the collection of cached series stored on disk.
|
|
||||||
|
|
||||||
Series are identified by their unique 'key' (provider identifier).
|
|
||||||
The 'folder' is metadata only and not used for lookups.
|
|
||||||
|
|
||||||
This class manages in-memory series data loaded from filesystem.
|
|
||||||
It has no database dependencies - all persistence is handled by
|
|
||||||
the service layer.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
# File-based mode
|
|
||||||
serie_list = SerieList("/path/to/anime")
|
|
||||||
series = serie_list.get_all()
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
directory: Path to the anime directory
|
|
||||||
keyDict: Internal dictionary mapping serie.key to Serie objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
base_path: str,
|
|
||||||
skip_load: bool = False
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the SerieList.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_path: Path to the anime directory
|
|
||||||
skip_load: If True, skip automatic loading of series from files.
|
|
||||||
Useful when planning to load from database instead.
|
|
||||||
"""
|
|
||||||
self.directory: str = base_path
|
|
||||||
# Internal storage using serie.key as the dictionary key
|
|
||||||
self.keyDict: Dict[str, Serie] = {}
|
|
||||||
|
|
||||||
# Only auto-load from files if not skipping
|
|
||||||
if not skip_load:
|
|
||||||
self.load_series()
|
|
||||||
|
|
||||||
def add(self, serie: Serie, use_sanitized_folder: bool = True) -> str:
|
|
||||||
"""
|
|
||||||
Persist a new series if it is not already present (file-based mode).
|
|
||||||
|
|
||||||
Uses serie.key for identification. Creates the filesystem folder
|
|
||||||
using either the sanitized display name (default) or the existing
|
|
||||||
folder property.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
serie: The Serie instance to add
|
|
||||||
use_sanitized_folder: If True (default), use serie.sanitized_folder
|
|
||||||
for the filesystem folder name based on display name.
|
|
||||||
If False, use serie.folder as-is for backward compatibility.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The folder path that was created/used
|
|
||||||
|
|
||||||
Note:
|
|
||||||
This method creates data files on disk. For database storage,
|
|
||||||
use add_to_db() instead.
|
|
||||||
"""
|
|
||||||
if self.contains(serie.key):
|
|
||||||
# Return existing folder path
|
|
||||||
existing = self.keyDict[serie.key]
|
|
||||||
return os.path.join(self.directory, existing.folder)
|
|
||||||
|
|
||||||
# Determine folder name to use
|
|
||||||
if use_sanitized_folder:
|
|
||||||
folder_name = serie.sanitized_folder
|
|
||||||
# Update the serie's folder property to match what we create
|
|
||||||
serie.folder = folder_name
|
|
||||||
else:
|
|
||||||
folder_name = serie.folder
|
|
||||||
|
|
||||||
data_path = os.path.join(self.directory, folder_name, "data")
|
|
||||||
anime_path = os.path.join(self.directory, folder_name)
|
|
||||||
os.makedirs(anime_path, exist_ok=True)
|
|
||||||
if not os.path.isfile(data_path):
|
|
||||||
serie.save_to_file(data_path)
|
|
||||||
# Store by key, not folder
|
|
||||||
self.keyDict[serie.key] = serie
|
|
||||||
|
|
||||||
return anime_path
|
|
||||||
|
|
||||||
async def add_to_db(self, serie: Serie) -> bool:
|
|
||||||
"""Persist a new series to the database.
|
|
||||||
|
|
||||||
Creates the filesystem folder using serie.folder, then persists
|
|
||||||
the series metadata to the database.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
serie: The Serie instance to add
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from src.server.database.connection import get_async_session_factory
|
|
||||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
|
||||||
|
|
||||||
folder_name = serie.folder
|
|
||||||
anime_path = os.path.join(self.directory, folder_name)
|
|
||||||
os.makedirs(anime_path, exist_ok=True)
|
|
||||||
|
|
||||||
session_factory = get_async_session_factory()
|
|
||||||
db = session_factory()
|
|
||||||
try:
|
|
||||||
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
|
||||||
if existing:
|
|
||||||
logger.debug(
|
|
||||||
"Series '%s' (key=%s) already exists in DB, skipping",
|
|
||||||
serie.name, serie.key
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
anime_series = await AnimeSeriesService.create(
|
|
||||||
db=db,
|
|
||||||
key=serie.key,
|
|
||||||
name=serie.name,
|
|
||||||
site=serie.site,
|
|
||||||
folder=folder_name,
|
|
||||||
year=serie.year
|
|
||||||
)
|
|
||||||
for season, eps in serie.episodeDict.items():
|
|
||||||
for ep in eps:
|
|
||||||
await EpisodeService.create(
|
|
||||||
db=db,
|
|
||||||
series_id=anime_series.id,
|
|
||||||
season=season,
|
|
||||||
episode_number=ep
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
self.keyDict[serie.key] = serie
|
|
||||||
logger.info(
|
|
||||||
"Persisted series '%s' to database",
|
|
||||||
serie.name
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
await db.rollback()
|
|
||||||
logger.error(
|
|
||||||
"Failed to persist series '%s' to DB: %s",
|
|
||||||
serie.key, e, exc_info=True
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
await db.close()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Could not add series '%s' to DB (DB unavailable?): %s",
|
|
||||||
serie.key, e
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def contains(self, key: str) -> bool:
|
|
||||||
"""
|
|
||||||
Return True when a series identified by ``key`` already exists.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: The unique provider identifier for the series
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if the series exists in the collection
|
|
||||||
"""
|
|
||||||
return key in self.keyDict
|
|
||||||
|
|
||||||
def load_series(self) -> None:
|
|
||||||
"""Populate the in-memory map with metadata discovered on disk."""
|
|
||||||
|
|
||||||
logger.info("Scanning anime folders in %s", self.directory)
|
|
||||||
try:
|
|
||||||
entries: Iterable[str] = os.listdir(self.directory)
|
|
||||||
except OSError as error:
|
|
||||||
logger.error(
|
|
||||||
"Unable to scan directory %s: %s",
|
|
||||||
self.directory,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
nfo_stats = {"total": 0, "with_nfo": 0, "without_nfo": 0}
|
|
||||||
media_stats = {
|
|
||||||
"with_poster": 0,
|
|
||||||
"without_poster": 0,
|
|
||||||
"with_logo": 0,
|
|
||||||
"without_logo": 0,
|
|
||||||
"with_fanart": 0,
|
|
||||||
"without_fanart": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
for anime_folder in entries:
|
|
||||||
if settings.should_ignore_folder(anime_folder):
|
|
||||||
logger.debug("Skipping ignored folder: %s", anime_folder)
|
|
||||||
continue
|
|
||||||
anime_path = os.path.join(self.directory, anime_folder, "data")
|
|
||||||
if os.path.isfile(anime_path):
|
|
||||||
logger.debug("Found data file for folder %s", anime_folder)
|
|
||||||
serie = self._load_data(anime_folder, anime_path)
|
|
||||||
|
|
||||||
if serie:
|
|
||||||
nfo_stats["total"] += 1
|
|
||||||
# Check for NFO file
|
|
||||||
nfo_file_path = os.path.join(
|
|
||||||
self.directory, anime_folder, "tvshow.nfo"
|
|
||||||
)
|
|
||||||
if os.path.isfile(nfo_file_path):
|
|
||||||
serie.nfo_path = nfo_file_path
|
|
||||||
nfo_stats["with_nfo"] += 1
|
|
||||||
else:
|
|
||||||
nfo_stats["without_nfo"] += 1
|
|
||||||
logger.debug(
|
|
||||||
"Series '%s' (key: %s) is missing tvshow.nfo",
|
|
||||||
serie.name,
|
|
||||||
serie.key
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for media files
|
|
||||||
folder_path = os.path.join(self.directory, anime_folder)
|
|
||||||
|
|
||||||
poster_path = os.path.join(folder_path, "poster.jpg")
|
|
||||||
if os.path.isfile(poster_path):
|
|
||||||
media_stats["with_poster"] += 1
|
|
||||||
else:
|
|
||||||
media_stats["without_poster"] += 1
|
|
||||||
logger.debug(
|
|
||||||
"Series '%s' (key: %s) is missing poster.jpg",
|
|
||||||
serie.name,
|
|
||||||
serie.key
|
|
||||||
)
|
|
||||||
|
|
||||||
logo_path = os.path.join(folder_path, "logo.png")
|
|
||||||
if os.path.isfile(logo_path):
|
|
||||||
media_stats["with_logo"] += 1
|
|
||||||
else:
|
|
||||||
media_stats["without_logo"] += 1
|
|
||||||
logger.debug(
|
|
||||||
"Series '%s' (key: %s) is missing logo.png",
|
|
||||||
serie.name,
|
|
||||||
serie.key
|
|
||||||
)
|
|
||||||
|
|
||||||
fanart_path = os.path.join(folder_path, "fanart.jpg")
|
|
||||||
if os.path.isfile(fanart_path):
|
|
||||||
media_stats["with_fanart"] += 1
|
|
||||||
else:
|
|
||||||
media_stats["without_fanart"] += 1
|
|
||||||
logger.debug(
|
|
||||||
"Series '%s' (key: %s) is missing fanart.jpg",
|
|
||||||
serie.name,
|
|
||||||
serie.key
|
|
||||||
)
|
|
||||||
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.warning(
|
|
||||||
"Skipping folder %s because no metadata file was found",
|
|
||||||
anime_folder,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log summary statistics
|
|
||||||
if nfo_stats["total"] > 0:
|
|
||||||
logger.info(
|
|
||||||
"NFO scan complete: %d series total, %d with NFO, %d without NFO",
|
|
||||||
nfo_stats["total"],
|
|
||||||
nfo_stats["with_nfo"],
|
|
||||||
nfo_stats["without_nfo"]
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Media scan complete: Poster (%d/%d), Logo (%d/%d), Fanart (%d/%d)",
|
|
||||||
media_stats["with_poster"],
|
|
||||||
nfo_stats["total"],
|
|
||||||
media_stats["with_logo"],
|
|
||||||
nfo_stats["total"],
|
|
||||||
media_stats["with_fanart"],
|
|
||||||
nfo_stats["total"]
|
|
||||||
)
|
|
||||||
|
|
||||||
def _load_data(self, anime_folder: str, data_path: str) -> Optional[Serie]:
|
|
||||||
"""
|
|
||||||
Load a single series metadata file into the in-memory collection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
anime_folder: The folder name (for logging only)
|
|
||||||
data_path: Path to the metadata file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Serie: The loaded Serie object, or None if loading failed
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
serie = Serie.load_from_file(data_path)
|
|
||||||
# Store by key, not folder
|
|
||||||
self.keyDict[serie.key] = serie
|
|
||||||
logger.debug(
|
|
||||||
"Successfully loaded metadata for %s (key: %s)",
|
|
||||||
anime_folder,
|
|
||||||
serie.key
|
|
||||||
)
|
|
||||||
return serie
|
|
||||||
except (OSError, JSONDecodeError, KeyError, ValueError) as error:
|
|
||||||
logger.error(
|
|
||||||
"Failed to load metadata for folder %s from %s: %s",
|
|
||||||
anime_folder,
|
|
||||||
data_path,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def GetMissingEpisode(self) -> List[Serie]:
|
|
||||||
"""Return all series that still contain missing episodes."""
|
|
||||||
return [
|
|
||||||
serie
|
|
||||||
for serie in self.keyDict.values()
|
|
||||||
if serie.episodeDict
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_missing_episodes(self) -> List[Serie]:
|
|
||||||
"""PEP8-friendly alias for :meth:`GetMissingEpisode`."""
|
|
||||||
return self.GetMissingEpisode()
|
|
||||||
|
|
||||||
def GetList(self) -> List[Serie]:
|
|
||||||
"""Return all series instances stored in the list."""
|
|
||||||
return list(self.keyDict.values())
|
|
||||||
|
|
||||||
def get_all(self) -> List[Serie]:
|
|
||||||
"""PEP8-friendly alias for :meth:`GetList`."""
|
|
||||||
return self.GetList()
|
|
||||||
|
|
||||||
def get_by_key(self, key: str) -> Optional[Serie]:
|
|
||||||
"""
|
|
||||||
Get a series by its unique provider key.
|
|
||||||
|
|
||||||
This is the primary method for series lookup.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: The unique provider identifier (e.g., "attack-on-titan")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The Serie instance if found, None otherwise
|
|
||||||
"""
|
|
||||||
return self.keyDict.get(key)
|
|
||||||
|
|
||||||
def get_by_folder(self, folder: str) -> Optional[Serie]:
|
|
||||||
"""
|
|
||||||
Get a series by its folder name.
|
|
||||||
|
|
||||||
.. deprecated:: 2.0.0
|
|
||||||
Use :meth:`get_by_key` instead. Folder-based lookups will be
|
|
||||||
removed in version 3.0.0. The `folder` field is metadata only
|
|
||||||
and should not be used for identification.
|
|
||||||
|
|
||||||
This method is provided for backward compatibility only.
|
|
||||||
Prefer using get_by_key() for new code.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
folder: The filesystem folder name (e.g., "Attack on Titan (2013)")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The Serie instance if found, None otherwise
|
|
||||||
"""
|
|
||||||
warnings.warn(
|
|
||||||
"get_by_folder() is deprecated and will be removed in v3.0.0. "
|
|
||||||
"Use get_by_key() instead. The 'folder' field is metadata only.",
|
|
||||||
DeprecationWarning,
|
|
||||||
stacklevel=2
|
|
||||||
)
|
|
||||||
for serie in self.keyDict.values():
|
|
||||||
if serie.folder == folder:
|
|
||||||
return serie
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def load_all_from_db(self) -> int:
|
|
||||||
"""Load all series from database into in-memory cache.
|
|
||||||
|
|
||||||
Retrieves all anime series from the database with their episodes
|
|
||||||
and populates the in-memory keyDict for fast access.
|
|
||||||
|
|
||||||
This method replaces file-based loading. Use after initialization
|
|
||||||
when database is ready.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: Number of series loaded into cache
|
|
||||||
"""
|
|
||||||
from src.server.database.connection import get_async_session_factory
|
|
||||||
from src.server.database.service import AnimeSeriesService
|
|
||||||
|
|
||||||
try:
|
|
||||||
session_factory = get_async_session_factory()
|
|
||||||
db = session_factory()
|
|
||||||
try:
|
|
||||||
anime_series_list = await AnimeSeriesService.get_all(
|
|
||||||
db, with_episodes=True
|
|
||||||
)
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
for anime_series in anime_series_list:
|
|
||||||
episode_dict: Dict[int, List[int]] = {}
|
|
||||||
if anime_series.episodes:
|
|
||||||
for ep in anime_series.episodes:
|
|
||||||
if ep.season not in episode_dict:
|
|
||||||
episode_dict[ep.season] = []
|
|
||||||
episode_dict[ep.season].append(ep.episode_number)
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key=anime_series.key,
|
|
||||||
name=anime_series.name,
|
|
||||||
site=anime_series.site,
|
|
||||||
folder=anime_series.folder,
|
|
||||||
episodeDict=episode_dict,
|
|
||||||
year=anime_series.year
|
|
||||||
)
|
|
||||||
self.keyDict[serie.key] = serie
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Loaded %d series from database into in-memory cache",
|
|
||||||
count
|
|
||||||
)
|
|
||||||
return count
|
|
||||||
finally:
|
|
||||||
await db.close()
|
|
||||||
except RuntimeError:
|
|
||||||
logger.warning(
|
|
||||||
"Database not available, skipping DB load"
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
async def _load_single_series_from_db(
|
|
||||||
self,
|
|
||||||
anime_folder: str
|
|
||||||
) -> Optional[Serie]:
|
|
||||||
"""Load a single series from database by folder name.
|
|
||||||
|
|
||||||
Looks up a series in the database by its folder name and adds
|
|
||||||
it to the in-memory cache.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
anime_folder: The filesystem folder name to look up
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Serie if found and loaded, None otherwise
|
|
||||||
"""
|
|
||||||
from src.server.database.connection import get_async_session_factory
|
|
||||||
from src.server.database.service import AnimeSeriesService
|
|
||||||
|
|
||||||
try:
|
|
||||||
session_factory = get_async_session_factory()
|
|
||||||
db = session_factory()
|
|
||||||
try:
|
|
||||||
anime_series = await AnimeSeriesService.get_by_folder(
|
|
||||||
db, anime_folder
|
|
||||||
)
|
|
||||||
if not anime_series:
|
|
||||||
logger.debug(
|
|
||||||
"Series with folder '%s' not found in DB",
|
|
||||||
anime_folder
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
episode_dict: Dict[int, List[int]] = {}
|
|
||||||
if anime_series.episodes:
|
|
||||||
for ep in anime_series.episodes:
|
|
||||||
if ep.season not in episode_dict:
|
|
||||||
episode_dict[ep.season] = []
|
|
||||||
episode_dict[ep.season].append(ep.episode_number)
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key=anime_series.key,
|
|
||||||
name=anime_series.name,
|
|
||||||
site=anime_series.site,
|
|
||||||
folder=anime_series.folder,
|
|
||||||
episodeDict=episode_dict,
|
|
||||||
year=anime_series.year
|
|
||||||
)
|
|
||||||
self.keyDict[serie.key] = serie
|
|
||||||
logger.debug(
|
|
||||||
"Loaded series '%s' (key=%s) from DB",
|
|
||||||
serie.name, serie.key
|
|
||||||
)
|
|
||||||
return serie
|
|
||||||
finally:
|
|
||||||
await db.close()
|
|
||||||
except RuntimeError:
|
|
||||||
logger.warning(
|
|
||||||
"Database not available, cannot load series '%s'",
|
|
||||||
anime_folder
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def invalidate_cache(self) -> None:
|
|
||||||
"""Clear the in-memory cache.
|
|
||||||
|
|
||||||
Use after database modifications to force reload from DB
|
|
||||||
on next access.
|
|
||||||
"""
|
|
||||||
self.keyDict.clear()
|
|
||||||
logger.debug("SerieList in-memory cache invalidated")
|
|
||||||
|
|
||||||
def reload(self) -> None:
|
|
||||||
"""Reload series from filesystem (legacy mode).
|
|
||||||
|
|
||||||
Warning:
|
|
||||||
This method uses file-based loading and should only be
|
|
||||||
used as fallback when database is not available.
|
|
||||||
"""
|
|
||||||
self.load_series()
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import warnings
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from src.server.utils.filesystem import sanitize_folder_name
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Serie:
|
|
||||||
"""
|
|
||||||
Represents an anime series with metadata and episode information.
|
|
||||||
|
|
||||||
The `key` property is the unique identifier for the series
|
|
||||||
(provider-assigned, URL-safe).
|
|
||||||
The `folder` property is the filesystem folder name
|
|
||||||
(metadata only, not used for lookups).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: Unique series identifier from provider
|
|
||||||
(e.g., "attack-on-titan"). Cannot be empty.
|
|
||||||
name: Display name of the series
|
|
||||||
site: Provider site URL
|
|
||||||
folder: Filesystem folder name (metadata only,
|
|
||||||
e.g., "Attack on Titan (2013)")
|
|
||||||
episodeDict: Dictionary mapping season numbers to
|
|
||||||
lists of episode numbers
|
|
||||||
year: Release year of the series (optional)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If key is None or empty string
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
key: str,
|
|
||||||
name: str,
|
|
||||||
site: str,
|
|
||||||
folder: str,
|
|
||||||
episodeDict: dict[int, list[int]],
|
|
||||||
year: int | None = None,
|
|
||||||
nfo_path: Optional[str] = None
|
|
||||||
):
|
|
||||||
if not key or not key.strip():
|
|
||||||
raise ValueError("Serie key cannot be None or empty")
|
|
||||||
|
|
||||||
self._key = key.strip()
|
|
||||||
self._name = name
|
|
||||||
self._site = site
|
|
||||||
self._folder = folder
|
|
||||||
self._episodeDict = episodeDict
|
|
||||||
self._year = year
|
|
||||||
self._nfo_path = nfo_path
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
"""String representation of Serie object"""
|
|
||||||
year_str = f", year={self.year}" if self.year else ""
|
|
||||||
return (
|
|
||||||
f"Serie(key='{self.key}', name='{self.name}', "
|
|
||||||
f"site='{self.site}', folder='{self.folder}', "
|
|
||||||
f"episodeDict={self.episodeDict}{year_str})"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
"""Concise developer representation of Serie object."""
|
|
||||||
season_count = len(self.episodeDict)
|
|
||||||
episode_count = sum(len(eps) for eps in self.episodeDict.values())
|
|
||||||
year_str = f", year={self.year}" if self.year else ""
|
|
||||||
return (
|
|
||||||
f"Serie(key={self.key!r}, name={self.name!r}"
|
|
||||||
f"{year_str}, seasons={season_count}, episodes={episode_count})"
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key(self) -> str:
|
|
||||||
"""
|
|
||||||
Unique series identifier (primary identifier for all lookups).
|
|
||||||
|
|
||||||
This is the provider-assigned, URL-safe identifier used
|
|
||||||
throughout the application for series identification,
|
|
||||||
lookups, and operations.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The unique series key
|
|
||||||
"""
|
|
||||||
return self._key
|
|
||||||
|
|
||||||
@key.setter
|
|
||||||
def key(self, value: str):
|
|
||||||
"""
|
|
||||||
Set the unique series identifier.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value: New key value
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If value is None or empty string
|
|
||||||
"""
|
|
||||||
if not value or not value.strip():
|
|
||||||
raise ValueError("Serie key cannot be None or empty")
|
|
||||||
self._key = value.strip()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return self._name
|
|
||||||
|
|
||||||
@name.setter
|
|
||||||
def name(self, value: str):
|
|
||||||
self._name = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def site(self) -> str:
|
|
||||||
return self._site
|
|
||||||
|
|
||||||
@site.setter
|
|
||||||
def site(self, value: str):
|
|
||||||
self._site = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def folder(self) -> str:
|
|
||||||
"""
|
|
||||||
Filesystem folder name (metadata only, not used for lookups).
|
|
||||||
|
|
||||||
This property contains the local directory name where the series
|
|
||||||
files are stored. It should NOT be used as an identifier for
|
|
||||||
series lookups - use `key` instead.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The filesystem folder name
|
|
||||||
"""
|
|
||||||
return self._folder
|
|
||||||
|
|
||||||
@folder.setter
|
|
||||||
def folder(self, value: str):
|
|
||||||
"""
|
|
||||||
Set the filesystem folder name.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value: Folder name for the series
|
|
||||||
"""
|
|
||||||
self._folder = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def episodeDict(self) -> dict[int, list[int]]:
|
|
||||||
return self._episodeDict
|
|
||||||
|
|
||||||
@episodeDict.setter
|
|
||||||
def episodeDict(self, value: dict[int, list[int]]):
|
|
||||||
self._episodeDict = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def year(self) -> int | None:
|
|
||||||
"""
|
|
||||||
Release year of the series.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int or None: The year the series was released, or None if unknown
|
|
||||||
"""
|
|
||||||
return self._year
|
|
||||||
|
|
||||||
@year.setter
|
|
||||||
def year(self, value: int | None):
|
|
||||||
"""Set the release year of the series."""
|
|
||||||
self._year = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def nfo_path(self) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Path to the tvshow.nfo metadata file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str or None: Path to the NFO file, or None if not set
|
|
||||||
"""
|
|
||||||
return self._nfo_path
|
|
||||||
|
|
||||||
@nfo_path.setter
|
|
||||||
def nfo_path(self, value: Optional[str]):
|
|
||||||
"""Set the path to the NFO file."""
|
|
||||||
self._nfo_path = value
|
|
||||||
|
|
||||||
def has_nfo(self, base_directory: Optional[str] = None) -> bool:
|
|
||||||
"""
|
|
||||||
Check if tvshow.nfo file exists for this series.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_directory: Base anime directory path. If provided, checks
|
|
||||||
relative to base_directory/folder/tvshow.nfo. If not provided,
|
|
||||||
uses nfo_path directly.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if tvshow.nfo exists, False otherwise
|
|
||||||
"""
|
|
||||||
if base_directory:
|
|
||||||
nfo_file = Path(base_directory) / self.folder / "tvshow.nfo"
|
|
||||||
elif self._nfo_path:
|
|
||||||
nfo_file = Path(self._nfo_path)
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return nfo_file.exists() and nfo_file.is_file()
|
|
||||||
|
|
||||||
def has_poster(self, base_directory: Optional[str] = None) -> bool:
|
|
||||||
"""
|
|
||||||
Check if poster.jpg file exists for this series.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_directory: Base anime directory path. If provided, checks
|
|
||||||
relative to base_directory/folder/poster.jpg.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if poster.jpg exists, False otherwise
|
|
||||||
"""
|
|
||||||
if not base_directory:
|
|
||||||
return False
|
|
||||||
|
|
||||||
poster_file = Path(base_directory) / self.folder / "poster.jpg"
|
|
||||||
return poster_file.exists() and poster_file.is_file()
|
|
||||||
|
|
||||||
def has_logo(self, base_directory: Optional[str] = None) -> bool:
|
|
||||||
"""
|
|
||||||
Check if logo.png file exists for this series.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_directory: Base anime directory path. If provided, checks
|
|
||||||
relative to base_directory/folder/logo.png.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if logo.png exists, False otherwise
|
|
||||||
"""
|
|
||||||
if not base_directory:
|
|
||||||
return False
|
|
||||||
|
|
||||||
logo_file = Path(base_directory) / self.folder / "logo.png"
|
|
||||||
return logo_file.exists() and logo_file.is_file()
|
|
||||||
|
|
||||||
def has_fanart(self, base_directory: Optional[str] = None) -> bool:
|
|
||||||
"""
|
|
||||||
Check if fanart.jpg file exists for this series.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_directory: Base anime directory path. If provided, checks
|
|
||||||
relative to base_directory/folder/fanart.jpg.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if fanart.jpg exists, False otherwise
|
|
||||||
"""
|
|
||||||
if not base_directory:
|
|
||||||
return False
|
|
||||||
|
|
||||||
fanart_file = Path(base_directory) / self.folder / "fanart.jpg"
|
|
||||||
return fanart_file.exists() and fanart_file.is_file()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name_with_year(self) -> str:
|
|
||||||
"""
|
|
||||||
Get the series name with year appended if available.
|
|
||||||
|
|
||||||
Returns a name in the format "Name (Year)" if year is available,
|
|
||||||
otherwise returns just the name. This should be used for creating
|
|
||||||
filesystem folders to distinguish series with the same name.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Name with year in format "Name (Year)", or just name if no year
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> serie = Serie("dororo", "Dororo", ..., year=2025)
|
|
||||||
>>> serie.name_with_year
|
|
||||||
'Dororo (2025)'
|
|
||||||
"""
|
|
||||||
if self._year:
|
|
||||||
import re
|
|
||||||
year_suffix = f" ({self._year})"
|
|
||||||
# Strip ALL trailing year suffixes before appending to prevent duplication
|
|
||||||
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', self._name).strip()
|
|
||||||
return f"{clean_name}{year_suffix}"
|
|
||||||
return self._name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sanitized_folder(self) -> str:
|
|
||||||
"""
|
|
||||||
Get a filesystem-safe folder name derived from the display name with year.
|
|
||||||
|
|
||||||
This property returns a sanitized version of the series name with year
|
|
||||||
(if available) suitable for use as a filesystem folder name. It removes/
|
|
||||||
replaces characters that are invalid for filesystems while preserving
|
|
||||||
Unicode characters.
|
|
||||||
|
|
||||||
Use this property when creating folders for the series on disk.
|
|
||||||
The `folder` property stores the actual folder name used.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Filesystem-safe folder name based on display name with year
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> serie = Serie("attack-on-titan", "Attack on Titan: Final", ..., year=2025)
|
|
||||||
>>> serie.sanitized_folder
|
|
||||||
'Attack on Titan Final (2025)'
|
|
||||||
"""
|
|
||||||
# Use name_with_year if available, fall back to folder, then key
|
|
||||||
name_to_sanitize = self.name_with_year or self._folder or self._key
|
|
||||||
try:
|
|
||||||
return sanitize_folder_name(name_to_sanitize)
|
|
||||||
except ValueError:
|
|
||||||
# Fallback to key if name cannot be sanitized
|
|
||||||
return sanitize_folder_name(self._key)
|
|
||||||
|
|
||||||
def ensure_folder_with_year(self) -> str:
|
|
||||||
"""Ensure folder name includes year if available.
|
|
||||||
|
|
||||||
If the serie has a year and the current folder name doesn't include it,
|
|
||||||
updates the folder name to include the year in format "Name (Year)".
|
|
||||||
|
|
||||||
This method should be called before creating folders or NFO files to
|
|
||||||
ensure consistent naming across the application.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The folder name (updated if needed)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> serie = Serie("perfect-blue", "Perfect Blue", ..., folder="Perfect Blue", year=1997)
|
|
||||||
>>> serie.ensure_folder_with_year()
|
|
||||||
'Perfect Blue (1997)'
|
|
||||||
>>> serie.folder # folder property is updated
|
|
||||||
'Perfect Blue (1997)'
|
|
||||||
"""
|
|
||||||
if self._year:
|
|
||||||
# Check if folder already has year format
|
|
||||||
year_pattern = f"({self._year})"
|
|
||||||
if year_pattern not in self._folder:
|
|
||||||
# Update folder to include year
|
|
||||||
self._folder = self.sanitized_folder
|
|
||||||
logger.info(
|
|
||||||
f"Updated folder name for '{self._key}' to include year: {self._folder}"
|
|
||||||
)
|
|
||||||
return self._folder
|
|
||||||
|
|
||||||
def to_dict(self):
|
|
||||||
"""Convert Serie object to dictionary for JSON serialization."""
|
|
||||||
return {
|
|
||||||
"key": self.key,
|
|
||||||
"name": self.name,
|
|
||||||
"site": self.site,
|
|
||||||
"folder": self.folder,
|
|
||||||
"episodeDict": {
|
|
||||||
str(k): list(v) for k, v in self.episodeDict.items()
|
|
||||||
},
|
|
||||||
"year": self.year,
|
|
||||||
"nfo_path": self.nfo_path
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_dict(data: dict):
|
|
||||||
"""Create a Serie object from dictionary."""
|
|
||||||
# Convert keys to int
|
|
||||||
episode_dict = {
|
|
||||||
int(k): v for k, v in data["episodeDict"].items()
|
|
||||||
}
|
|
||||||
return Serie(
|
|
||||||
data["key"],
|
|
||||||
data["name"],
|
|
||||||
data["site"],
|
|
||||||
data["folder"],
|
|
||||||
episode_dict,
|
|
||||||
data.get("year"), # Optional year field for backward compatibility
|
|
||||||
data.get("nfo_path") # Optional nfo_path field
|
|
||||||
)
|
|
||||||
|
|
||||||
def save_to_file(self, filename: str):
|
|
||||||
"""Save Serie object to JSON file.
|
|
||||||
|
|
||||||
.. deprecated::
|
|
||||||
File-based storage is deprecated. Use database storage via
|
|
||||||
`AnimeSeriesService.create()` instead. This method will be
|
|
||||||
removed in v3.0.0.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Path to save the JSON file
|
|
||||||
"""
|
|
||||||
warnings.warn(
|
|
||||||
"save_to_file() is deprecated and will be removed in v3.0.0. "
|
|
||||||
"Use database storage via AnimeSeriesService.create() instead.",
|
|
||||||
DeprecationWarning,
|
|
||||||
stacklevel=2
|
|
||||||
)
|
|
||||||
with open(filename, "w", encoding="utf-8") as file:
|
|
||||||
json.dump(self.to_dict(), file, indent=4)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def load_from_file(cls, filename: str) -> "Serie":
|
|
||||||
"""Load Serie object from JSON file.
|
|
||||||
|
|
||||||
.. deprecated::
|
|
||||||
File-based storage is deprecated. Use database storage via
|
|
||||||
`AnimeSeriesService.get_by_key()` instead. This method will be
|
|
||||||
removed in v3.0.0.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Path to load the JSON file from
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Serie: The loaded Serie object
|
|
||||||
"""
|
|
||||||
warnings.warn(
|
|
||||||
"load_from_file() is deprecated and will be removed in v3.0.0. "
|
|
||||||
"Use database storage via AnimeSeriesService instead.",
|
|
||||||
DeprecationWarning,
|
|
||||||
stacklevel=2
|
|
||||||
)
|
|
||||||
with open(filename, "r", encoding="utf-8") as file:
|
|
||||||
data = json.load(file)
|
|
||||||
return cls.from_dict(data)
|
|
||||||
@@ -21,10 +21,9 @@ from typing import Callable, Iterable, Iterator, Optional
|
|||||||
from events import Events
|
from events import Events
|
||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
|
from src.server.exceptions.exceptions.Exceptions import MatchNotFoundError
|
||||||
from src.core.providers.base_provider import Loader
|
from src.server.providers.base_provider import Loader
|
||||||
from src.core.utils.key_utils import generate_key_from_folder
|
|
||||||
from src.server.database.connection import get_sync_session
|
from src.server.database.connection import get_sync_session
|
||||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||||
|
|
||||||
@@ -53,23 +52,12 @@ class SerieScanner:
|
|||||||
# scan() detects running event loop and uses create_task()
|
# scan() detects running event loop and uses create_task()
|
||||||
# internally, so no special handling needed by caller.
|
# internally, so no special handling needed by caller.
|
||||||
# Results are in scanner.keyDict
|
# Results are in scanner.keyDict
|
||||||
|
|
||||||
# With DB lookup fallback:
|
|
||||||
scanner = SerieScanner("/path/to/anime", loader,
|
|
||||||
db_lookup=lambda folder: my_db.get_by_folder(folder))
|
|
||||||
|
|
||||||
# With scan key overrides:
|
|
||||||
overrides = {"Folder Name": "correct-provider-key"}
|
|
||||||
scanner = SerieScanner("/path/to/anime", loader,
|
|
||||||
scan_key_overrides=overrides)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
basePath: str,
|
basePath: str,
|
||||||
loader: Loader,
|
loader: Loader,
|
||||||
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
|
|
||||||
scan_key_overrides: Optional[dict[str, str]] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize the SerieScanner.
|
Initialize the SerieScanner.
|
||||||
@@ -77,15 +65,6 @@ class SerieScanner:
|
|||||||
Args:
|
Args:
|
||||||
basePath: Base directory containing anime series
|
basePath: Base directory containing anime series
|
||||||
loader: Loader instance for fetching series information
|
loader: Loader instance for fetching series information
|
||||||
db_lookup: Optional callable ``(folder_name) -> Serie | None``.
|
|
||||||
When provided, it is called as a fallback when neither a
|
|
||||||
``key`` file nor a ``data`` file is found in the folder.
|
|
||||||
This allows the database to supply the series key for
|
|
||||||
folders that have never had a local key file.
|
|
||||||
scan_key_overrides: Optional dict mapping folder names to provider
|
|
||||||
keys. When a folder name is found in this dict, the override
|
|
||||||
key is used instead of auto-generating from folder name.
|
|
||||||
Format: {"Folder Name": "actual-provider-key"}
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If basePath is invalid or doesn't exist
|
ValueError: If basePath is invalid or doesn't exist
|
||||||
@@ -102,10 +81,8 @@ class SerieScanner:
|
|||||||
raise ValueError(f"Base path is not a directory: {abs_path}")
|
raise ValueError(f"Base path is not a directory: {abs_path}")
|
||||||
|
|
||||||
self.directory: str = abs_path
|
self.directory: str = abs_path
|
||||||
self.keyDict: dict[str, Serie] = {}
|
self.keyDict: dict[str, AnimeSeries] = {}
|
||||||
self.loader: Loader = loader
|
self.loader: Loader = loader
|
||||||
self._db_lookup: Optional[Callable[[str], Optional[Serie]]] = db_lookup
|
|
||||||
self._scan_key_overrides: Optional[dict[str, str]] = scan_key_overrides
|
|
||||||
self._current_operation_id: Optional[str] = None
|
self._current_operation_id: Optional[str] = None
|
||||||
self.events = Events()
|
self.events = Events()
|
||||||
|
|
||||||
@@ -242,64 +219,63 @@ class SerieScanner:
|
|||||||
self.events.on_completion.remove(handler)
|
self.events.on_completion.remove(handler)
|
||||||
|
|
||||||
def reinit(self) -> None:
|
def reinit(self) -> None:
|
||||||
"""Reinitialize the series dictionary (keyed by serie.key)."""
|
"""Reinitialize the series dictionary (keyed by anime.key)."""
|
||||||
self.keyDict: dict[str, Serie] = {}
|
self.keyDict: dict[str, AnimeSeries] = {}
|
||||||
|
|
||||||
async def _persist_serie_to_db(self, serie: Serie) -> None:
|
async def _persist_serie_to_db(self, anime: AnimeSeries) -> None:
|
||||||
"""Persist serie to database (create or update).
|
"""Persist anime to database (create or update).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
serie: Serie domain object to persist
|
anime: AnimeSeries model to persist
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from src.server.database.connection import get_async_session_factory
|
from src.server.database.connection import get_async_session_factory
|
||||||
|
|
||||||
db = get_async_session_factory()
|
db = get_async_session_factory()
|
||||||
try:
|
try:
|
||||||
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
existing = await AnimeSeriesService.get_by_key(db, anime.key)
|
||||||
if existing:
|
if existing:
|
||||||
await AnimeSeriesService.update(
|
await AnimeSeriesService.update(
|
||||||
db, existing.id,
|
db, existing.id,
|
||||||
name=serie.name,
|
name=anime.name,
|
||||||
folder=serie.folder,
|
folder=anime.folder,
|
||||||
year=serie.year
|
year=anime.year
|
||||||
)
|
)
|
||||||
await self._sync_episodes_to_db(db, existing.id, serie.episodeDict)
|
await self._sync_episodes_to_db(db, existing.id, anime.episodeDict)
|
||||||
else:
|
else:
|
||||||
anime_series = await AnimeSeriesService.create(
|
db_anime = await AnimeSeriesService.create(
|
||||||
db=db,
|
db=db,
|
||||||
key=serie.key,
|
key=anime.key,
|
||||||
name=serie.name,
|
name=anime.name,
|
||||||
site=serie.site,
|
site=anime.site,
|
||||||
folder=serie.folder,
|
folder=anime.folder,
|
||||||
year=serie.year
|
year=anime.year
|
||||||
)
|
)
|
||||||
for season, eps in serie.episodeDict.items():
|
for ep in anime.episodes:
|
||||||
for ep in eps:
|
await EpisodeService.create(
|
||||||
await EpisodeService.create(
|
db=db,
|
||||||
db=db,
|
series_id=db_anime.id,
|
||||||
series_id=anime_series.id,
|
season=ep.season,
|
||||||
season=season,
|
episode_number=ep.episode_number
|
||||||
episode_number=ep
|
)
|
||||||
)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Persisted serie '%s' (key=%s) to database",
|
"Persisted anime '%s' (key=%s) to database",
|
||||||
serie.name, serie.key
|
anime.name, anime.key
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to persist serie '%s' to DB: %s",
|
"Failed to persist anime '%s' to DB: %s",
|
||||||
serie.key, e, exc_info=True
|
anime.key, e, exc_info=True
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
await db.close()
|
await db.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Could not persist serie '%s' to DB (DB unavailable?): %s",
|
"Could not persist anime '%s' to DB (DB unavailable?): %s",
|
||||||
serie.key, e
|
anime.key, e
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _sync_episodes_to_db(
|
async def _sync_episodes_to_db(
|
||||||
@@ -419,59 +395,15 @@ class SerieScanner:
|
|||||||
serie = self.__read_data_from_file(folder)
|
serie = self.__read_data_from_file(folder)
|
||||||
if serie is None or not serie.key or not serie.key.strip():
|
if serie is None or not serie.key or not serie.key.strip():
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"No key or data file found for folder '%s', skipping",
|
"No series found in DB for folder '%s', skipping",
|
||||||
folder,
|
folder,
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
if (
|
if (
|
||||||
serie is not None
|
serie is not None
|
||||||
and serie.key
|
and serie.key
|
||||||
and serie.key.strip()
|
and serie.key.strip()
|
||||||
):
|
):
|
||||||
# Try to extract year from folder name first
|
|
||||||
if not hasattr(serie, 'year') or not serie.year:
|
|
||||||
year_from_folder = self._extract_year_from_folder_name(folder)
|
|
||||||
if year_from_folder:
|
|
||||||
serie.year = year_from_folder
|
|
||||||
logger.info(
|
|
||||||
"Using year from folder name: %s (year=%d)",
|
|
||||||
folder,
|
|
||||||
year_from_folder
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# If not in folder name, fetch from provider
|
|
||||||
try:
|
|
||||||
serie.year = self.loader.get_year(serie.key)
|
|
||||||
if serie.year:
|
|
||||||
logger.info(
|
|
||||||
"Fetched year from provider: %s (year=%d)",
|
|
||||||
serie.key,
|
|
||||||
serie.year
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
"Could not fetch year for %s: %s",
|
|
||||||
serie.key,
|
|
||||||
str(e)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fetch series name from provider if not already set
|
|
||||||
if not serie.name:
|
|
||||||
try:
|
|
||||||
fetched_name = self.loader.get_title(serie.key)
|
|
||||||
if fetched_name:
|
|
||||||
serie.name = fetched_name
|
|
||||||
logger.info(
|
|
||||||
"Fetched name from provider: %s (name=%s)",
|
|
||||||
serie.key,
|
|
||||||
serie.name
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
"Could not fetch name for %s: %s",
|
|
||||||
serie.key,
|
|
||||||
str(e)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delegate the provider to compare local files with
|
# Delegate the provider to compare local files with
|
||||||
# remote metadata, yielding missing episodes per
|
# remote metadata, yielding missing episodes per
|
||||||
# season. Results are saved back to disk so that both
|
# season. Results are saved back to disk so that both
|
||||||
@@ -536,21 +468,6 @@ class SerieScanner:
|
|||||||
"Saved Serie: '%s'", str(serie)
|
"Saved Serie: '%s'", str(serie)
|
||||||
)
|
)
|
||||||
|
|
||||||
except NoKeyFoundException as nkfe:
|
|
||||||
# Log error and notify via callback
|
|
||||||
error_msg = f"Error processing folder '{folder}': {nkfe}"
|
|
||||||
logger.error(error_msg)
|
|
||||||
|
|
||||||
self._safe_call_event(
|
|
||||||
self.events.on_error,
|
|
||||||
{
|
|
||||||
"operation_id": self._current_operation_id,
|
|
||||||
"error": nkfe,
|
|
||||||
"message": error_msg,
|
|
||||||
"recoverable": True,
|
|
||||||
"metadata": {"folder": folder, "key": None}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log error and notify via callback
|
# Log error and notify via callback
|
||||||
error_msg = (
|
error_msg = (
|
||||||
@@ -639,49 +556,25 @@ class SerieScanner:
|
|||||||
has_files = True
|
has_files = True
|
||||||
yield anime_name, mp4_files if has_files else []
|
yield anime_name, mp4_files if has_files else []
|
||||||
|
|
||||||
def __read_data_from_file(self, folder_name: str) -> Optional[Serie]:
|
def __read_data_from_file(self, folder_name: str) -> Optional[AnimeSeries]:
|
||||||
"""Load or discover a Serie for the given folder.
|
"""Load or discover an AnimeSeries for the given folder.
|
||||||
|
|
||||||
Strategy:
|
Strategy:
|
||||||
1. Query DB by folder name
|
1. Query DB by folder name
|
||||||
2. If found, return cached Serie object
|
2. If not found in DB, return None (no file fallback)
|
||||||
3. If not in DB, fall back to provider search via _db_lookup callback
|
|
||||||
4. If still not found, try reading 'data' file for legacy deployments
|
|
||||||
5. Check user-provided key overrides in scan_key_overrides
|
|
||||||
6. Generate key from folder name as last resort
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
folder_name: Filesystem folder name
|
folder_name: Filesystem folder name
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Serie object with valid key if found, None otherwise
|
AnimeSeries object if found in DB, None otherwise
|
||||||
|
|
||||||
Note:
|
|
||||||
DB is the source of truth. File-based lookups (data files)
|
|
||||||
are temporary backward compatibility for CLI-only deployments.
|
|
||||||
"""
|
"""
|
||||||
# Step 1: Try DB lookup by folder name
|
# Step 1: Try DB lookup by folder name
|
||||||
try:
|
try:
|
||||||
session = get_sync_session()
|
session = get_sync_session()
|
||||||
try:
|
try:
|
||||||
anime_series = AnimeSeriesService.get_by_folder_sync(session, folder_name)
|
anime_series = AnimeSeriesService.get_by_folder_sync(session, folder_name)
|
||||||
if anime_series:
|
return anime_series
|
||||||
# Reconstruct Serie from DB record
|
|
||||||
episode_dict: dict[int, list[int]] = {}
|
|
||||||
if anime_series.episodes:
|
|
||||||
for ep in anime_series.episodes:
|
|
||||||
season = ep.season or 1
|
|
||||||
if season not in episode_dict:
|
|
||||||
episode_dict[season] = []
|
|
||||||
episode_dict[season].append(ep.episode_number or ep.number or 0)
|
|
||||||
return Serie(
|
|
||||||
key=anime_series.key,
|
|
||||||
name=anime_series.name,
|
|
||||||
site=anime_series.site,
|
|
||||||
folder=anime_series.folder,
|
|
||||||
episodeDict=episode_dict,
|
|
||||||
year=anime_series.year
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -691,79 +584,6 @@ class SerieScanner:
|
|||||||
exc
|
exc
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 2: Fall back to provider search callback
|
|
||||||
if self._db_lookup is not None:
|
|
||||||
try:
|
|
||||||
serie = self._db_lookup(folder_name)
|
|
||||||
if serie and serie.key and serie.key.strip():
|
|
||||||
logger.info(
|
|
||||||
"Provider lookup resolved folder '%s' -> key='%s'",
|
|
||||||
folder_name,
|
|
||||||
serie.key
|
|
||||||
)
|
|
||||||
return serie
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Provider lookup failed for folder '%s': %s",
|
|
||||||
folder_name,
|
|
||||||
exc
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 3: Legacy data file fallback (CLI-only deployments)
|
|
||||||
folder_path = os.path.join(self.directory, folder_name)
|
|
||||||
serie_file = os.path.join(folder_path, 'data')
|
|
||||||
if os.path.exists(serie_file):
|
|
||||||
with open(serie_file, "rb") as file:
|
|
||||||
logger.info(
|
|
||||||
"load serie_file from '%s': %s",
|
|
||||||
folder_name,
|
|
||||||
serie_file
|
|
||||||
)
|
|
||||||
return Serie.load_from_file(serie_file)
|
|
||||||
|
|
||||||
# Step 4: Check for user-provided key overrides before generating
|
|
||||||
if self._scan_key_overrides and folder_name in self._scan_key_overrides:
|
|
||||||
override_key = self._scan_key_overrides[folder_name]
|
|
||||||
year_from_folder = self._extract_year_from_folder_name(folder_name)
|
|
||||||
logger.info(
|
|
||||||
"Using scan key override for folder '%s' -> key='%s'",
|
|
||||||
folder_name,
|
|
||||||
override_key
|
|
||||||
)
|
|
||||||
return Serie(
|
|
||||||
key=override_key,
|
|
||||||
name="", # Name will be fetched from provider if needed
|
|
||||||
site="aniworld.to",
|
|
||||||
folder=folder_name,
|
|
||||||
episodeDict=dict(),
|
|
||||||
year=year_from_folder
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 5: Generate key from folder name as last resort
|
|
||||||
# This handles edge cases like non-Latin characters or special symbols
|
|
||||||
try:
|
|
||||||
generated_key = generate_key_from_folder(folder_name)
|
|
||||||
year_from_folder = self._extract_year_from_folder_name(folder_name)
|
|
||||||
logger.info(
|
|
||||||
"Generated key for folder '%s' -> key='%s'",
|
|
||||||
folder_name,
|
|
||||||
generated_key
|
|
||||||
)
|
|
||||||
return Serie(
|
|
||||||
key=generated_key,
|
|
||||||
name="", # Name will be fetched from provider if needed
|
|
||||||
site="aniworld.to",
|
|
||||||
folder=folder_name,
|
|
||||||
episodeDict=dict(),
|
|
||||||
year=year_from_folder
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to generate key for folder '%s': %s",
|
|
||||||
folder_name,
|
|
||||||
exc
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def __get_episode_and_season(self, filename: str) -> tuple[int, int]:
|
def __get_episode_and_season(self, filename: str) -> tuple[int, int]:
|
||||||
@@ -957,51 +777,38 @@ class SerieScanner:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create or update Serie in keyDict
|
# Create or update AnimeSeries in keyDict
|
||||||
if key in self.keyDict:
|
if key in self.keyDict:
|
||||||
# Update existing serie
|
# Update existing anime - rebuild episodeDict from episodes
|
||||||
self.keyDict[key].episodeDict = missing_episodes
|
existing = self.keyDict[key]
|
||||||
|
existing_ep_dict = existing.episodeDict
|
||||||
|
# Merge missing episodes
|
||||||
|
for season, eps in missing_episodes.items():
|
||||||
|
if season not in existing_ep_dict:
|
||||||
|
existing_ep_dict[season] = []
|
||||||
|
existing_ep_dict[season].extend(eps)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Updated existing series %s with %d missing episodes",
|
"Updated existing series %s with %d missing episodes",
|
||||||
key,
|
key,
|
||||||
sum(len(eps) for eps in missing_episodes.values())
|
sum(len(eps) for eps in missing_episodes.values())
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Try to extract year from folder name first
|
# Extract year from folder name if present, otherwise leave as None
|
||||||
year = self._extract_year_from_folder_name(folder)
|
year = self._extract_year_from_folder_name(folder)
|
||||||
if year:
|
|
||||||
logger.info(
|
# Create new AnimeSeries entry (minimal, fields populated later)
|
||||||
"Using year from folder name: %s (year=%d)",
|
from src.server.database.models import AnimeSeries
|
||||||
folder,
|
anime_series = AnimeSeries(
|
||||||
year
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# If not in folder name, fetch from provider
|
|
||||||
try:
|
|
||||||
year = self.loader.get_year(key)
|
|
||||||
if year:
|
|
||||||
logger.info(
|
|
||||||
"Fetched year from provider: %s (year=%d)",
|
|
||||||
key,
|
|
||||||
year
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
"Could not fetch year for %s: %s",
|
|
||||||
key,
|
|
||||||
str(e)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create new serie entry
|
|
||||||
serie = Serie(
|
|
||||||
key=key,
|
key=key,
|
||||||
name="", # Will be populated by caller if needed
|
name=folder, # Use folder as fallback name since we don't have actual name
|
||||||
site=site,
|
site=site,
|
||||||
folder=folder,
|
folder=folder,
|
||||||
episodeDict=missing_episodes,
|
|
||||||
year=year
|
year=year
|
||||||
)
|
)
|
||||||
self.keyDict[key] = serie
|
# Set episodeDict cache directly since AnimeSeries doesn't persist missing episodes
|
||||||
|
# (they get synced to DB via _persist_serie_to_db later)
|
||||||
|
anime_series._episode_dict_cache = missing_episodes.copy()
|
||||||
|
self.keyDict[key] = anime_series
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Created new series entry for %s with %d missing episodes (year=%s)",
|
"Created new series entry for %s with %d missing episodes (year=%s)",
|
||||||
key,
|
key,
|
||||||
@@ -19,10 +19,10 @@ from typing import Any, Callable, Dict, List, Optional
|
|||||||
from events import Events
|
from events import Events
|
||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.core.entities.SerieList import SerieList
|
from src.server.database.SerieList import SerieList
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
from src.core.providers.provider_factory import Loaders
|
from src.server.providers.provider_factory import Loaders
|
||||||
from src.core.SerieScanner import SerieScanner
|
from src.server.SerieScanner import SerieScanner
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -141,16 +141,12 @@ class SeriesApp:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
directory_to_search: str,
|
directory_to_search: str,
|
||||||
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize SeriesApp.
|
Initialize SeriesApp.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
directory_to_search: Base directory for anime series
|
directory_to_search: Base directory for anime series
|
||||||
db_lookup: Optional callable ``(folder_name) -> Serie | None``
|
|
||||||
passed through to ``SerieScanner`` as a fallback key source
|
|
||||||
when no local ``key`` or ``data`` file exists.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.directory_to_search = directory_to_search
|
self.directory_to_search = directory_to_search
|
||||||
@@ -166,12 +162,9 @@ class SeriesApp:
|
|||||||
self.serie_scanner = SerieScanner(
|
self.serie_scanner = SerieScanner(
|
||||||
directory_to_search,
|
directory_to_search,
|
||||||
self.loader,
|
self.loader,
|
||||||
db_lookup=db_lookup,
|
|
||||||
scan_key_overrides=settings.scan_key_overrides,
|
|
||||||
)
|
)
|
||||||
# Skip automatic loading from data files - series will be loaded
|
# Series will be loaded from database by the service layer during application setup
|
||||||
# from database by the service layer during application setup
|
self.list = SerieList(self.directory_to_search)
|
||||||
self.list = SerieList(self.directory_to_search, skip_load=True)
|
|
||||||
self.series_list: List[Any] = []
|
self.series_list: List[Any] = []
|
||||||
# Initialize empty list - series loaded later via load_series_from_list()
|
# Initialize empty list - series loaded later via load_series_from_list()
|
||||||
# No need to call _init_list_sync() anymore
|
# No need to call _init_list_sync() anymore
|
||||||
@@ -660,7 +653,7 @@ class SeriesApp:
|
|||||||
"""
|
"""
|
||||||
await self._init_list()
|
await self._init_list()
|
||||||
|
|
||||||
def _get_serie_by_key(self, key: str) -> Optional[Serie]:
|
def _get_serie_by_key(self, key: str) -> Optional[AnimeSeries]:
|
||||||
"""
|
"""
|
||||||
Get a series by its unique provider key.
|
Get a series by its unique provider key.
|
||||||
|
|
||||||
@@ -671,7 +664,7 @@ class SeriesApp:
|
|||||||
"attack-on-titan")
|
"attack-on-titan")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The Serie instance if found, None otherwise
|
The AnimeSeries instance if found, None otherwise
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This method uses the SerieList.get_by_key() method which
|
This method uses the SerieList.get_by_key() method which
|
||||||
@@ -679,25 +672,25 @@ class SeriesApp:
|
|||||||
"""
|
"""
|
||||||
return self.list.get_by_key(key)
|
return self.list.get_by_key(key)
|
||||||
|
|
||||||
def get_all_series_from_data_files(self) -> List[Serie]:
|
def get_all_series_from_data_files(self) -> List[AnimeSeries]:
|
||||||
"""
|
"""
|
||||||
Get all series from data files in the anime directory.
|
Get all series from data files in the anime directory.
|
||||||
|
|
||||||
Scans the directory_to_search for all 'data' files and loads
|
Scans the directory_to_search for all 'data' files and loads
|
||||||
the Serie metadata from each file. This method is synchronous
|
the AnimeSeries metadata from each file. This method is synchronous
|
||||||
and can be wrapped with asyncio.to_thread if needed for async
|
and can be wrapped with asyncio.to_thread if needed for async
|
||||||
contexts.
|
contexts.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of Serie objects found in data files. Returns an empty
|
List of AnimeSeries objects found in data files. Returns an empty
|
||||||
list if no data files are found or if the directory doesn't
|
list if no data files are found or if the directory doesn't
|
||||||
exist.
|
exist.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
series_app = SeriesApp("/path/to/anime")
|
series_app = SeriesApp("/path/to/anime")
|
||||||
all_series = series_app.get_all_series_from_data_files()
|
all_series = series_app.get_all_series_from_data_files()
|
||||||
for serie in all_series:
|
for anime in all_series:
|
||||||
print(f"Found: {serie.name} (key={serie.key})")
|
print(f"Found: {anime.name} (key={anime.key})")
|
||||||
"""
|
"""
|
||||||
logger.info(
|
logger.info(
|
||||||
"Scanning for data files in directory: %s",
|
"Scanning for data files in directory: %s",
|
||||||
@@ -708,10 +701,7 @@ class SeriesApp:
|
|||||||
# This ensures we get all series from data files without
|
# This ensures we get all series from data files without
|
||||||
# interfering with the main instance's state
|
# interfering with the main instance's state
|
||||||
try:
|
try:
|
||||||
temp_list = SerieList(
|
temp_list = SerieList(self.directory_to_search)
|
||||||
self.directory_to_search,
|
|
||||||
skip_load=False # Allow automatic loading
|
|
||||||
)
|
|
||||||
except (OSError, ValueError) as e:
|
except (OSError, ValueError) as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to scan directory for data files: %s",
|
"Failed to scan directory for data files: %s",
|
||||||
@@ -8,8 +8,8 @@ from pydantic import BaseModel, Field
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
from src.core.utils.key_utils import generate_key_from_folder, is_valid_key
|
from src.server.utils.key_utils import generate_key_from_folder, is_valid_key
|
||||||
from src.server.database.service import AnimeSeriesService
|
from src.server.database.service import AnimeSeriesService
|
||||||
from src.server.exceptions import (
|
from src.server.exceptions import (
|
||||||
BadRequestError,
|
BadRequestError,
|
||||||
@@ -896,18 +896,18 @@ async def add_series(
|
|||||||
|
|
||||||
# Step D: Add to SerieList (in-memory only, no folder creation)
|
# Step D: Add to SerieList (in-memory only, no folder creation)
|
||||||
if series_app and hasattr(series_app, "list"):
|
if series_app and hasattr(series_app, "list"):
|
||||||
serie = Serie(
|
from src.server.database.models import AnimeSeries
|
||||||
|
anime = AnimeSeries(
|
||||||
key=key,
|
key=key,
|
||||||
name=name,
|
name=name,
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder=folder,
|
folder=folder,
|
||||||
episodeDict={},
|
|
||||||
year=year
|
year=year
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add to in-memory cache without creating folder on disk
|
# Add to in-memory cache without creating folder on disk
|
||||||
if hasattr(series_app.list, 'keyDict'):
|
if hasattr(series_app.list, 'keyDict'):
|
||||||
series_app.list.keyDict[key] = serie
|
series_app.list.keyDict[key] = anime
|
||||||
logger.info(
|
logger.info(
|
||||||
"Added series to in-memory cache: %s (key=%s, folder=%s, year=%s)",
|
"Added series to in-memory cache: %s (key=%s, folder=%s, year=%s)",
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This module provides functions to generate tvshow.nfo XML files from
|
|||||||
TVShowNFO Pydantic models, adapted from the scraper project.
|
TVShowNFO Pydantic models, adapted from the scraper project.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> from src.core.entities.nfo_models import TVShowNFO
|
>>> from src.server.entities.nfo_models import TVShowNFO
|
||||||
>>> nfo = TVShowNFO(title="Test Show", year=2020, tmdbid=12345)
|
>>> nfo = TVShowNFO(title="Test Show", year=2020, tmdbid=12345)
|
||||||
>>> xml_string = generate_tvshow_nfo(nfo)
|
>>> xml_string = generate_tvshow_nfo(nfo)
|
||||||
"""
|
"""
|
||||||
@@ -15,7 +15,7 @@ from typing import Optional
|
|||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.core.entities.nfo_models import TVShowNFO
|
from src.server.entities.nfo_models import TVShowNFO
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
from src.core.entities.nfo_models import (
|
from src.server.entities.nfo_models import (
|
||||||
ActorInfo,
|
ActorInfo,
|
||||||
ImageInfo,
|
ImageInfo,
|
||||||
NamedSeason,
|
NamedSeason,
|
||||||
288
src/server/database/SerieList.py
Normal file
288
src/server/database/SerieList.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
"""Utilities for loading and managing stored anime series metadata.
|
||||||
|
|
||||||
|
This module provides the SerieList class for managing collections of anime
|
||||||
|
series metadata loaded from the database.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This module is part of the server database layer. All persistence
|
||||||
|
is handled by the service layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from src.server.database.models import AnimeSeries
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SerieList:
|
||||||
|
"""
|
||||||
|
Represents the collection of cached series loaded from database.
|
||||||
|
|
||||||
|
Series are identified by their unique 'key' (provider identifier).
|
||||||
|
The 'folder' is metadata only and not used for lookups.
|
||||||
|
|
||||||
|
This class manages in-memory series data loaded from database.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
# Load from database
|
||||||
|
serie_list = SerieList("/path/to/anime")
|
||||||
|
await serie_list.load_all_from_db()
|
||||||
|
series = serie_list.get_all()
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
directory: Path to the anime directory
|
||||||
|
keyDict: Internal dictionary mapping serie.key to AnimeSeries objects
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base_path: str) -> None:
|
||||||
|
"""Initialize the SerieList.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_path: Path to the anime directory
|
||||||
|
"""
|
||||||
|
self.directory: str = base_path
|
||||||
|
# Internal storage using serie.key as the dictionary key
|
||||||
|
self.keyDict: Dict[str, AnimeSeries] = {}
|
||||||
|
|
||||||
|
async def add_to_db(self, anime: AnimeSeries) -> bool:
|
||||||
|
"""Persist a new series to the database.
|
||||||
|
|
||||||
|
Creates the filesystem folder using anime.folder, then persists
|
||||||
|
the series metadata to the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime: The AnimeSeries instance to add
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.server.database.connection import get_async_session_factory
|
||||||
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||||
|
|
||||||
|
folder_name = anime.folder
|
||||||
|
anime_path = self.directory + "/" + folder_name
|
||||||
|
import os
|
||||||
|
os.makedirs(anime_path, exist_ok=True)
|
||||||
|
|
||||||
|
session_factory = get_async_session_factory()
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
existing = await AnimeSeriesService.get_by_key(db, anime.key)
|
||||||
|
if existing:
|
||||||
|
logger.debug(
|
||||||
|
"Series '%s' (key=%s) already exists in DB, skipping",
|
||||||
|
anime.name, anime.key
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
db_anime_series = await AnimeSeriesService.create(
|
||||||
|
db=db,
|
||||||
|
key=anime.key,
|
||||||
|
name=anime.name,
|
||||||
|
site=anime.site,
|
||||||
|
folder=folder_name,
|
||||||
|
year=anime.year
|
||||||
|
)
|
||||||
|
for ep in anime.episodes:
|
||||||
|
await EpisodeService.create(
|
||||||
|
db=db,
|
||||||
|
series_id=db_anime_series.id,
|
||||||
|
season=ep.season,
|
||||||
|
episode_number=ep.episode_number
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
self.keyDict[anime.key] = anime
|
||||||
|
logger.info(
|
||||||
|
"Persisted series '%s' to database",
|
||||||
|
anime.name
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(
|
||||||
|
"Failed to persist series '%s' to DB: %s",
|
||||||
|
anime.key, e, exc_info=True
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Could not add series '%s' to DB (DB unavailable?): %s",
|
||||||
|
anime.key, e
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def contains(self, key: str) -> bool:
|
||||||
|
"""
|
||||||
|
Return True when a series identified by ``key`` already exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The unique provider identifier for the series
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the series exists in the collection
|
||||||
|
"""
|
||||||
|
return key in self.keyDict
|
||||||
|
|
||||||
|
def GetMissingEpisode(self) -> List[AnimeSeries]:
|
||||||
|
"""Return all series that still contain missing episodes."""
|
||||||
|
return [
|
||||||
|
anime for anime in self.keyDict.values()
|
||||||
|
if anime.episodeDict
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_missing_episodes(self) -> List[AnimeSeries]:
|
||||||
|
"""PEP8-friendly alias for :meth:`GetMissingEpisode`."""
|
||||||
|
return self.GetMissingEpisode()
|
||||||
|
|
||||||
|
def GetList(self) -> List[AnimeSeries]:
|
||||||
|
"""Return all series instances stored in the list."""
|
||||||
|
return list(self.keyDict.values())
|
||||||
|
|
||||||
|
def get_all(self) -> List[AnimeSeries]:
|
||||||
|
"""PEP8-friendly alias for :meth:`GetList`."""
|
||||||
|
return self.GetList()
|
||||||
|
|
||||||
|
def get_by_key(self, key: str) -> Optional[AnimeSeries]:
|
||||||
|
"""
|
||||||
|
Get a series by its unique provider key.
|
||||||
|
|
||||||
|
This is the primary method for series lookup.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The unique provider identifier (e.g., "attack-on-titan")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The AnimeSeries instance if found, None otherwise
|
||||||
|
"""
|
||||||
|
return self.keyDict.get(key)
|
||||||
|
|
||||||
|
def get_by_folder(self, folder: str) -> Optional[AnimeSeries]:
|
||||||
|
"""
|
||||||
|
Get a series by its folder name.
|
||||||
|
|
||||||
|
.. deprecated:: 2.0.0
|
||||||
|
Use :meth:`get_by_key` instead. Folder-based lookups will be
|
||||||
|
removed in version 3.0.0. The `folder` field is metadata only
|
||||||
|
and should not be used for identification.
|
||||||
|
|
||||||
|
This method is provided for backward compatibility only.
|
||||||
|
Prefer using get_by_key() for new code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder: The filesystem folder name (e.g., "Attack on Titan (2013)")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The AnimeSeries instance if found, None otherwise
|
||||||
|
"""
|
||||||
|
import warnings
|
||||||
|
warnings.warn(
|
||||||
|
"get_by_folder() is deprecated and will be removed in v3.0.0. "
|
||||||
|
"Use get_by_key() instead. The 'folder' field is metadata only.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2
|
||||||
|
)
|
||||||
|
for anime in self.keyDict.values():
|
||||||
|
if anime.folder == folder:
|
||||||
|
return anime
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def load_all_from_db(self) -> int:
|
||||||
|
"""Load all series from database into in-memory cache.
|
||||||
|
|
||||||
|
Retrieves all anime series from the database with their episodes
|
||||||
|
and populates the in-memory keyDict for fast access.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Number of series loaded into cache
|
||||||
|
"""
|
||||||
|
from src.server.database.connection import get_async_session_factory
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
try:
|
||||||
|
session_factory = get_async_session_factory()
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
anime_series_list = await AnimeSeriesService.get_all(
|
||||||
|
db, with_episodes=True
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for anime_series in anime_series_list:
|
||||||
|
self.keyDict[anime_series.key] = anime_series
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Loaded %d series from database into in-memory cache",
|
||||||
|
count
|
||||||
|
)
|
||||||
|
return count
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning(
|
||||||
|
"Database not available, skipping DB load"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def _load_single_series_from_db(
|
||||||
|
self,
|
||||||
|
anime_folder: str
|
||||||
|
) -> Optional[AnimeSeries]:
|
||||||
|
"""Load a single series from database by folder name.
|
||||||
|
|
||||||
|
Looks up a series in the database by its folder name and adds
|
||||||
|
it to the in-memory cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_folder: The filesystem folder name to look up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AnimeSeries if found and loaded, None otherwise
|
||||||
|
"""
|
||||||
|
from src.server.database.connection import get_async_session_factory
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
try:
|
||||||
|
session_factory = get_async_session_factory()
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
anime_series = await AnimeSeriesService.get_by_folder(
|
||||||
|
db, anime_folder
|
||||||
|
)
|
||||||
|
if not anime_series:
|
||||||
|
logger.debug(
|
||||||
|
"Series with folder '%s' not found in DB",
|
||||||
|
anime_folder
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.keyDict[anime_series.key] = anime_series
|
||||||
|
logger.debug(
|
||||||
|
"Loaded series '%s' (key=%s) from DB",
|
||||||
|
anime_series.name, anime_series.key
|
||||||
|
)
|
||||||
|
return anime_series
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning(
|
||||||
|
"Database not available, cannot load series '%s'",
|
||||||
|
anime_folder
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def invalidate_cache(self) -> None:
|
||||||
|
"""Clear the in-memory cache.
|
||||||
|
|
||||||
|
Use after database modifications to force reload from DB
|
||||||
|
on next access.
|
||||||
|
"""
|
||||||
|
self.keyDict.clear()
|
||||||
|
logger.debug("SerieList in-memory cache invalidated")
|
||||||
@@ -48,6 +48,7 @@ from src.server.database.service import (
|
|||||||
EpisodeService,
|
EpisodeService,
|
||||||
UserSessionService,
|
UserSessionService,
|
||||||
)
|
)
|
||||||
|
from src.server.database.SerieList import SerieList
|
||||||
from src.server.database.system_settings_service import SystemSettingsService
|
from src.server.database.system_settings_service import SystemSettingsService
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -79,4 +80,6 @@ __all__ = [
|
|||||||
"DownloadQueueService",
|
"DownloadQueueService",
|
||||||
"SystemSettingsService",
|
"SystemSettingsService",
|
||||||
"UserSessionService",
|
"UserSessionService",
|
||||||
|
# SerieList
|
||||||
|
"SerieList",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -190,6 +190,54 @@ class AnimeSeries(Base, TimestampMixin):
|
|||||||
f"name='{self.name}')>"
|
f"name='{self.name}')>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def episodeDict(self) -> dict[int, list[int]]:
|
||||||
|
"""Build episode dictionary from episodes relationship or private cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping season numbers to lists of episode numbers
|
||||||
|
"""
|
||||||
|
# Check for private cache first (set when loading from JSON without DB)
|
||||||
|
if hasattr(self, '_episode_dict_cache') and self._episode_dict_cache is not None:
|
||||||
|
return self._episode_dict_cache
|
||||||
|
|
||||||
|
episode_dict: dict[int, list[int]] = {}
|
||||||
|
if self.episodes:
|
||||||
|
for ep in self.episodes:
|
||||||
|
season = ep.season or 1
|
||||||
|
if season not in episode_dict:
|
||||||
|
episode_dict[season] = []
|
||||||
|
episode_dict[season].append(ep.episode_number or 0)
|
||||||
|
return episode_dict
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name_with_year(self) -> str:
|
||||||
|
"""Get series name with year appended if available.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Name in format "Name (Year)" if year is available, else just name
|
||||||
|
"""
|
||||||
|
if self.year:
|
||||||
|
import re
|
||||||
|
year_suffix = f" ({self.year})"
|
||||||
|
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', self.name or '').strip()
|
||||||
|
return f"{clean_name}{year_suffix}"
|
||||||
|
return self.name or ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sanitized_folder(self) -> str:
|
||||||
|
"""Get filesystem-safe folder name from display name with year.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized folder name based on display name with year
|
||||||
|
"""
|
||||||
|
from src.server.utils.filesystem import sanitize_folder_name
|
||||||
|
name_to_sanitize = self.name_with_year or self.folder or self.key
|
||||||
|
try:
|
||||||
|
return sanitize_folder_name(name_to_sanitize)
|
||||||
|
except ValueError:
|
||||||
|
return sanitize_folder_name(self.key)
|
||||||
|
|
||||||
|
|
||||||
class Episode(Base, TimestampMixin):
|
class Episode(Base, TimestampMixin):
|
||||||
"""SQLAlchemy model for anime episodes.
|
"""SQLAlchemy model for anime episodes.
|
||||||
|
|||||||
3
src/server/exceptions/exceptions/__init__.py
Normal file
3
src/server/exceptions/exceptions/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from src.server.exceptions.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
|
||||||
|
|
||||||
|
__all__ = ["MatchNotFoundError", "NoKeyFoundException"]
|
||||||
@@ -8,8 +8,8 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
||||||
|
|
||||||
from src.core.providers.health_monitor import get_health_monitor
|
from src.server.providers.health_monitor import get_health_monitor
|
||||||
from src.core.providers.provider_config import DEFAULT_PROVIDERS
|
from src.server.providers.provider_config import DEFAULT_PROVIDERS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -7,8 +7,8 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
from src.core.providers.base_provider import Loader
|
from src.server.providers.base_provider import Loader
|
||||||
from src.core.providers.health_monitor import get_health_monitor
|
from src.server.providers.health_monitor import get_health_monitor
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ from typing import Optional
|
|||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from src.core.SeriesApp import SeriesApp
|
from src.server.SeriesApp import SeriesApp
|
||||||
from src.server.services.progress_service import (
|
from src.server.services.progress_service import (
|
||||||
ProgressService,
|
ProgressService,
|
||||||
ProgressType,
|
ProgressType,
|
||||||
@@ -942,47 +942,16 @@ class AnimeService:
|
|||||||
in-memory episodeDict, so downloaded episodes are not shown
|
in-memory episodeDict, so downloaded episodes are not shown
|
||||||
as missing.
|
as missing.
|
||||||
"""
|
"""
|
||||||
from src.core.entities.series import Serie
|
|
||||||
from src.server.database.connection import get_db_session
|
from src.server.database.connection import get_db_session
|
||||||
from src.server.database.service import AnimeSeriesService
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
async with get_db_session() as db:
|
async with get_db_session() as db:
|
||||||
anime_series_list = await AnimeSeriesService.get_all(
|
anime_series_list = await AnimeSeriesService.get_all(
|
||||||
db, with_episodes=True
|
db, with_episodes=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convert to Serie objects
|
# Load AnimeSeries objects directly into SeriesApp
|
||||||
series_list = []
|
self._app.load_series_from_list(anime_series_list)
|
||||||
for anime_series in anime_series_list:
|
|
||||||
# Build episode_dict from episodes relationship
|
|
||||||
# Only include episodes that are NOT downloaded (is_downloaded=False)
|
|
||||||
# so the missing-episode list stays accurate
|
|
||||||
episode_dict: dict[int, list[int]] = {}
|
|
||||||
if anime_series.episodes:
|
|
||||||
for episode in anime_series.episodes:
|
|
||||||
# Skip downloaded episodes — they are not missing
|
|
||||||
if episode.is_downloaded:
|
|
||||||
continue
|
|
||||||
season = episode.season
|
|
||||||
if season not in episode_dict:
|
|
||||||
episode_dict[season] = []
|
|
||||||
episode_dict[season].append(episode.episode_number)
|
|
||||||
# Sort episode numbers
|
|
||||||
for season in episode_dict:
|
|
||||||
episode_dict[season].sort()
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key=anime_series.key,
|
|
||||||
name=anime_series.name,
|
|
||||||
site=anime_series.site,
|
|
||||||
folder=anime_series.folder,
|
|
||||||
episodeDict=episode_dict,
|
|
||||||
year=anime_series.year
|
|
||||||
)
|
|
||||||
series_list.append(serie)
|
|
||||||
|
|
||||||
# Load into SeriesApp
|
|
||||||
self._app.load_series_from_list(series_list)
|
|
||||||
|
|
||||||
async def sync_episodes_to_db(self, series_key: str) -> int:
|
async def sync_episodes_to_db(self, series_key: str) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -1178,17 +1147,17 @@ class AnimeService:
|
|||||||
|
|
||||||
async def add_series_to_db(
|
async def add_series_to_db(
|
||||||
self,
|
self,
|
||||||
serie,
|
anime,
|
||||||
db
|
db
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Add a series to the database if it doesn't already exist.
|
Add a series to the database if it doesn't already exist.
|
||||||
|
|
||||||
Uses serie.key for identification. Creates a new AnimeSeries
|
Uses anime.key for identification. Creates a new AnimeSeries
|
||||||
record in the database if it doesn't already exist.
|
record in the database if it doesn't already exist.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
serie: The Serie instance to add
|
anime: The AnimeSeries instance to add
|
||||||
db: Database session for async operations
|
db: Database session for async operations
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -1197,41 +1166,40 @@ class AnimeService:
|
|||||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||||
|
|
||||||
# Check if series already exists in DB
|
# Check if series already exists in DB
|
||||||
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
existing = await AnimeSeriesService.get_by_key(db, anime.key)
|
||||||
if existing:
|
if existing:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Series already exists in database: %s (key=%s)",
|
"Series already exists in database: %s (key=%s)",
|
||||||
serie.name,
|
anime.name,
|
||||||
serie.key
|
anime.key
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Create new series in database
|
# Create new series in database
|
||||||
anime_series = await AnimeSeriesService.create(
|
anime_series = await AnimeSeriesService.create(
|
||||||
db=db,
|
db=db,
|
||||||
key=serie.key,
|
key=anime.key,
|
||||||
name=serie.name,
|
name=anime.name,
|
||||||
site=serie.site,
|
site=anime.site,
|
||||||
folder=serie.folder,
|
folder=anime.folder,
|
||||||
year=serie.year if hasattr(serie, 'year') else None,
|
year=anime.year if hasattr(anime, 'year') else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create Episode records for each episode in episodeDict
|
# Create Episode records for each episode in episodes relationship
|
||||||
if serie.episodeDict:
|
if anime.episodes:
|
||||||
for season, episode_numbers in serie.episodeDict.items():
|
for episode in anime.episodes:
|
||||||
for episode_number in episode_numbers:
|
await EpisodeService.create(
|
||||||
await EpisodeService.create(
|
db=db,
|
||||||
db=db,
|
series_id=anime_series.id,
|
||||||
series_id=anime_series.id,
|
season=episode.season,
|
||||||
season=season,
|
episode_number=episode.episode_number,
|
||||||
episode_number=episode_number,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Added series to database: %s (key=%s, year=%s)",
|
"Added series to database: %s (key=%s, year=%s)",
|
||||||
serie.name,
|
anime.name,
|
||||||
serie.key,
|
anime.key,
|
||||||
serie.year if hasattr(serie, 'year') else None
|
anime.year if hasattr(anime, 'year') else None
|
||||||
)
|
)
|
||||||
|
|
||||||
return anime_series
|
return anime_series
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import structlog
|
|||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
from src.config.settings import settings as _settings
|
from src.config.settings import settings as _settings
|
||||||
from src.core.utils.image_downloader import ImageDownloader
|
from src.server.utils.image_downloader import ImageDownloader
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ except Exception: # pragma: no cover - optional dependency
|
|||||||
AsyncSession = object
|
AsyncSession = object
|
||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.core.SeriesApp import SeriesApp
|
from src.server.SeriesApp import SeriesApp
|
||||||
from src.server.services.auth_service import AuthError, auth_service
|
from src.server.services.auth_service import AuthError, auth_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -58,16 +58,16 @@ _RATE_LIMIT_WINDOW_SECONDS = 60.0
|
|||||||
|
|
||||||
|
|
||||||
def _make_db_lookup():
|
def _make_db_lookup():
|
||||||
"""Build a synchronous ``(folder) -> Serie | None`` callable for SerieScanner.
|
"""Build a synchronous ``(folder) -> AnimeSeries | None`` callable for SerieScanner.
|
||||||
|
|
||||||
The returned function opens a short-lived sync DB session, queries for a
|
The returned function opens a short-lived sync DB session, queries for a
|
||||||
series whose ``folder`` column matches the given name, and converts the
|
series whose ``folder`` column matches the given name, and returns the
|
||||||
ORM row to a ``Serie`` domain object. Returns ``None`` when the DB is not
|
AnimeSeries ORM object. Returns ``None`` when the DB is not yet initialised
|
||||||
yet initialised or no matching row is found.
|
or no matching row is found.
|
||||||
"""
|
"""
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
|
|
||||||
def _lookup(folder: str) -> Optional["Serie"]:
|
def _lookup(folder: str) -> Optional["AnimeSeries"]:
|
||||||
try:
|
try:
|
||||||
from src.server.database.connection import get_sync_session
|
from src.server.database.connection import get_sync_session
|
||||||
from src.server.database.service import AnimeSeriesService
|
from src.server.database.service import AnimeSeriesService
|
||||||
@@ -78,16 +78,7 @@ def _make_db_lookup():
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
if row is None:
|
return row
|
||||||
return None
|
|
||||||
return Serie(
|
|
||||||
key=row.key,
|
|
||||||
name=row.name or "",
|
|
||||||
site=row.site,
|
|
||||||
folder=row.folder,
|
|
||||||
episodeDict={},
|
|
||||||
year=row.year,
|
|
||||||
)
|
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# DB not initialised yet (e.g. first boot before init_db())
|
# DB not initialised yet (e.g. first boot before init_db())
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ class TestNfoRepair:
|
|||||||
self, authenticated_client, override_dependencies
|
self, authenticated_client, override_dependencies
|
||||||
):
|
):
|
||||||
"""Test repair handles TMDB API failure gracefully."""
|
"""Test repair handles TMDB API failure gracefully."""
|
||||||
from src.core.services.tmdb_client import TMDBAPIError
|
from src.server.services_nfo_temp.tmdb_client import TMDBAPIError
|
||||||
|
|
||||||
with patch("src.server.api.nfo.Path") as MockPath:
|
with patch("src.server.api.nfo.Path") as MockPath:
|
||||||
mock_path = Mock()
|
mock_path = Mock()
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ def mock_series_app_download(monkeypatch):
|
|||||||
"""
|
"""
|
||||||
# Mock the loader download method
|
# Mock the loader download method
|
||||||
try:
|
try:
|
||||||
from src.core.SeriesApp import SeriesApp
|
from src.server.SeriesApp import SeriesApp
|
||||||
|
|
||||||
# Patch the loader.download method for all SeriesApp instances
|
# Patch the loader.download method for all SeriesApp instances
|
||||||
original_init = SeriesApp.__init__
|
original_init = SeriesApp.__init__
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class TestCacheConsistency:
|
|||||||
|
|
||||||
def test_provider_cache_key_uniqueness(self):
|
def test_provider_cache_key_uniqueness(self):
|
||||||
"""Different inputs produce different cache keys."""
|
"""Different inputs produce different cache keys."""
|
||||||
from src.core.providers.aniworld_provider import AniworldLoader
|
from src.server.providers.aniworld_provider import AniworldLoader
|
||||||
|
|
||||||
loader = AniworldLoader.__new__(AniworldLoader)
|
loader = AniworldLoader.__new__(AniworldLoader)
|
||||||
loader.cache = {}
|
loader.cache = {}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ from unittest.mock import AsyncMock, Mock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
from src.core.SeriesApp import SeriesApp
|
from src.server.SeriesApp import SeriesApp
|
||||||
|
|
||||||
|
|
||||||
class TestGetAllSeriesFromDataFiles:
|
class TestGetAllSeriesFromDataFiles:
|
||||||
@@ -29,8 +29,8 @@ class TestGetAllSeriesFromDataFiles:
|
|||||||
def test_returns_empty_list_for_empty_directory(self):
|
def test_returns_empty_list_for_empty_directory(self):
|
||||||
"""Test that empty directory returns empty list."""
|
"""Test that empty directory returns empty list."""
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.server.SeriesApp.SerieScanner'):
|
||||||
app = SeriesApp(tmp_dir)
|
app = SeriesApp(tmp_dir)
|
||||||
result = app.get_all_series_from_data_files()
|
result = app.get_all_series_from_data_files()
|
||||||
|
|
||||||
@@ -56,8 +56,8 @@ class TestGetAllSeriesFromDataFiles:
|
|||||||
episodes={1: [1]}
|
episodes={1: [1]}
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.server.SeriesApp.SerieScanner'):
|
||||||
app = SeriesApp(tmp_dir)
|
app = SeriesApp(tmp_dir)
|
||||||
result = app.get_all_series_from_data_files()
|
result = app.get_all_series_from_data_files()
|
||||||
|
|
||||||
@@ -85,8 +85,8 @@ class TestGetAllSeriesFromDataFiles:
|
|||||||
with open(os.path.join(corrupt_dir, "data"), "w") as f:
|
with open(os.path.join(corrupt_dir, "data"), "w") as f:
|
||||||
f.write("this is not valid json {{{")
|
f.write("this is not valid json {{{")
|
||||||
|
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.server.SeriesApp.SerieScanner'):
|
||||||
app = SeriesApp(tmp_dir)
|
app = SeriesApp(tmp_dir)
|
||||||
result = app.get_all_series_from_data_files()
|
result = app.get_all_series_from_data_files()
|
||||||
|
|
||||||
@@ -101,8 +101,8 @@ class TestGetAllSeriesFromDataFiles:
|
|||||||
"""Test that non-existent directory returns empty list."""
|
"""Test that non-existent directory returns empty list."""
|
||||||
non_existent_dir = "/non/existent/directory/path"
|
non_existent_dir = "/non/existent/directory/path"
|
||||||
|
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.server.SeriesApp.SerieScanner'):
|
||||||
app = SeriesApp(non_existent_dir)
|
app = SeriesApp(non_existent_dir)
|
||||||
result = app.get_all_series_from_data_files()
|
result = app.get_all_series_from_data_files()
|
||||||
|
|
||||||
@@ -119,8 +119,8 @@ class TestSyncSeriesToDatabase:
|
|||||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.server.SeriesApp.SerieScanner'):
|
||||||
count = await sync_legacy_series_to_db(tmp_dir)
|
count = await sync_legacy_series_to_db(tmp_dir)
|
||||||
|
|
||||||
assert count == 0
|
assert count == 0
|
||||||
@@ -147,8 +147,8 @@ class TestSyncSeriesToDatabase:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# First verify that we can load the series from files
|
# First verify that we can load the series from files
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.server.SeriesApp.SerieScanner'):
|
||||||
app = SeriesApp(tmp_dir)
|
app = SeriesApp(tmp_dir)
|
||||||
series = app.get_all_series_from_data_files()
|
series = app.get_all_series_from_data_files()
|
||||||
assert len(series) == 1
|
assert len(series) == 1
|
||||||
@@ -156,8 +156,8 @@ class TestSyncSeriesToDatabase:
|
|||||||
|
|
||||||
# Now test that the sync function loads series and handles DB
|
# Now test that the sync function loads series and handles DB
|
||||||
# gracefully (even if DB operations fail, it should not crash)
|
# gracefully (even if DB operations fail, it should not crash)
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.server.SeriesApp.SerieScanner'):
|
||||||
# The function should return 0 because DB isn't available
|
# The function should return 0 because DB isn't available
|
||||||
# but should not crash
|
# but should not crash
|
||||||
count = await sync_legacy_series_to_db(tmp_dir)
|
count = await sync_legacy_series_to_db(tmp_dir)
|
||||||
@@ -173,10 +173,10 @@ class TestSyncSeriesToDatabase:
|
|||||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||||
|
|
||||||
# Make SeriesApp raise an exception during initialization
|
# Make SeriesApp raise an exception during initialization
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'), \
|
patch('src.server.SeriesApp.SerieScanner'), \
|
||||||
patch(
|
patch(
|
||||||
'src.core.SeriesApp.SerieList',
|
'src.server.SeriesApp.SerieList',
|
||||||
side_effect=Exception("Test error")
|
side_effect=Exception("Test error")
|
||||||
):
|
):
|
||||||
count = await sync_legacy_series_to_db("/fake/path")
|
count = await sync_legacy_series_to_db("/fake/path")
|
||||||
@@ -210,8 +210,8 @@ class TestEndToEndSync:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Use SeriesApp to load series from files
|
# Use SeriesApp to load series from files
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.server.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.server.SeriesApp.SerieScanner'):
|
||||||
app = SeriesApp(tmp_dir)
|
app = SeriesApp(tmp_dir)
|
||||||
all_series = app.get_all_series_from_data_files()
|
all_series = app.get_all_series_from_data_files()
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Integration tests for episode download sync with data file updates.
|
"""Integration tests for episode download sync with in-memory updates.
|
||||||
|
|
||||||
Tests verify that when episodes are downloaded successfully:
|
Tests verify that when episodes are downloaded successfully:
|
||||||
- In-memory Serie.episodeDict is updated
|
- In-memory AnimeSeries.episodeDict is updated
|
||||||
- Deprecated data file is updated (if it exists)
|
|
||||||
- Missing episode list reflects the change immediately
|
- Missing episode list reflects the change immediately
|
||||||
|
|
||||||
|
Note: Data file sync removed since AnimeSeries doesn't have save_to_file/load_from_file.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
@@ -14,12 +15,24 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
from src.core.SeriesApp import SeriesApp
|
from src.server.SeriesApp import SeriesApp
|
||||||
from src.server.models.download import DownloadItem, DownloadPriority, DownloadStatus
|
from src.server.models.download import DownloadItem, DownloadPriority, DownloadStatus
|
||||||
from src.server.services.download_service import DownloadService
|
from src.server.services.download_service import DownloadService
|
||||||
|
|
||||||
|
|
||||||
|
def make_anime(key, name, folder=None, episode_dict=None, year=None, site="https://example.com"):
|
||||||
|
"""Create a mock AnimeSeries with needed properties."""
|
||||||
|
anime = MagicMock(spec=AnimeSeries)
|
||||||
|
anime.key = key
|
||||||
|
anime.name = name
|
||||||
|
anime.folder = folder or name
|
||||||
|
anime.site = site
|
||||||
|
anime.year = year
|
||||||
|
anime.episodeDict = episode_dict or {}
|
||||||
|
return anime
|
||||||
|
|
||||||
|
|
||||||
class TestEpisodeRemovedFromMissingListAfterDownload:
|
class TestEpisodeRemovedFromMissingListAfterDownload:
|
||||||
"""Verify episode no longer appears in missing list after download completes."""
|
"""Verify episode no longer appears in missing list after download completes."""
|
||||||
|
|
||||||
@@ -35,18 +48,17 @@ class TestEpisodeRemovedFromMissingListAfterDownload:
|
|||||||
anime_service = MagicMock()
|
anime_service = MagicMock()
|
||||||
anime_service._directory = str(temp_dir)
|
anime_service._directory = str(temp_dir)
|
||||||
|
|
||||||
# Create mock app withSerie with missing episodes
|
anime = make_anime(
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
key="test-series",
|
||||||
name="Test Series",
|
name="Test Series",
|
||||||
site="https://example.com",
|
site="https://example.com",
|
||||||
folder="Test Series",
|
folder="Test Series",
|
||||||
episodeDict={1: [1, 2, 3]},
|
episode_dict={1: [1, 2, 3]},
|
||||||
)
|
)
|
||||||
mock_app = MagicMock()
|
mock_app = MagicMock()
|
||||||
mock_app.list.keyDict = {"test-series": serie}
|
mock_app.list.keyDict = {"test-series": anime}
|
||||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
mock_app.list.GetMissingEpisode.return_value = [anime]
|
||||||
mock_app.series_list = [serie]
|
mock_app.series_list = [anime]
|
||||||
anime_service._app = mock_app
|
anime_service._app = mock_app
|
||||||
anime_service._cached_list_missing = MagicMock()
|
anime_service._cached_list_missing = MagicMock()
|
||||||
anime_service._broadcast_series_updated = AsyncMock()
|
anime_service._broadcast_series_updated = AsyncMock()
|
||||||
@@ -62,7 +74,7 @@ class TestEpisodeRemovedFromMissingListAfterDownload:
|
|||||||
queue_repository=MagicMock(),
|
queue_repository=MagicMock(),
|
||||||
max_retries=3,
|
max_retries=3,
|
||||||
)
|
)
|
||||||
service._directory = tmp
|
service._directory = str(mock_anime_service._directory)
|
||||||
yield service
|
yield service
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -70,24 +82,24 @@ class TestEpisodeRemovedFromMissingListAfterDownload:
|
|||||||
self, mock_download_service, mock_anime_service
|
self, mock_download_service, mock_anime_service
|
||||||
):
|
):
|
||||||
"""Verify episode no longer appears in missing list after download completes."""
|
"""Verify episode no longer appears in missing list after download completes."""
|
||||||
serie = mock_anime_service._app.list.keyDict["test-series"]
|
anime = mock_anime_service._app.list.keyDict["test-series"]
|
||||||
|
|
||||||
# Verify episode starts in missing list
|
# Verify episode starts in missing list
|
||||||
assert 2 in serie.episodeDict[1], "Episode should start in missing list"
|
assert 2 in anime.episodeDict[1], "Episode should start in missing list"
|
||||||
|
|
||||||
# Simulate download completion by calling _remove_episode_from_memory
|
# Simulate download completion by calling _remove_episode_from_memory
|
||||||
mock_download_service._remove_episode_from_memory("test-series", 1, 2)
|
mock_download_service._remove_episode_from_memory("test-series", 1, 2)
|
||||||
|
|
||||||
# Episode should be removed from episodeDict
|
# Episode should be removed from episodeDict
|
||||||
assert 2 not in serie.episodeDict[1], "Episode should be removed from missing list"
|
assert 2 not in anime.episodeDict[1], "Episode should be removed from missing list"
|
||||||
assert serie.episodeDict[1] == [1, 3]
|
assert anime.episodeDict[1] == [1, 3]
|
||||||
|
|
||||||
# series_list should be refreshed
|
# series_list should be refreshed
|
||||||
mock_anime_service._app.list.GetMissingEpisode.assert_called()
|
mock_anime_service._app.list.GetMissingEpisode.assert_called()
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadUpdatesInMemoryCache:
|
class TestDownloadUpdatesInMemoryCache:
|
||||||
"""Verify in-memory Serie.episodeDict is updated after download."""
|
"""Verify in-memory AnimeSeries.episodeDict is updated after download."""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_anime_service(self):
|
def mock_anime_service(self):
|
||||||
@@ -95,21 +107,20 @@ class TestDownloadUpdatesInMemoryCache:
|
|||||||
anime_service = MagicMock()
|
anime_service = MagicMock()
|
||||||
anime_service._directory = "/tmp/test"
|
anime_service._directory = "/tmp/test"
|
||||||
|
|
||||||
# Create mock app with series having multiple seasons and episodes
|
anime = make_anime(
|
||||||
serie = Serie(
|
|
||||||
key="multi-season-series",
|
key="multi-season-series",
|
||||||
name="Multi Season Series",
|
name="Multi Season Series",
|
||||||
site="https://example.com",
|
site="https://example.com",
|
||||||
folder="Multi Season Series",
|
folder="Multi Season Series",
|
||||||
episodeDict={
|
episode_dict={
|
||||||
1: [1, 2, 3, 4, 5],
|
1: [1, 2, 3, 4, 5],
|
||||||
2: [1, 2, 3],
|
2: [1, 2, 3],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
mock_app = MagicMock()
|
mock_app = MagicMock()
|
||||||
mock_app.list.keyDict = {"multi-season-series": serie}
|
mock_app.list.keyDict = {"multi-season-series": anime}
|
||||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
mock_app.list.GetMissingEpisode.return_value = [anime]
|
||||||
mock_app.series_list = [serie]
|
mock_app.series_list = [anime]
|
||||||
anime_service._app = mock_app
|
anime_service._app = mock_app
|
||||||
anime_service._cached_list_missing = MagicMock()
|
anime_service._cached_list_missing = MagicMock()
|
||||||
anime_service._broadcast_series_updated = AsyncMock()
|
anime_service._broadcast_series_updated = AsyncMock()
|
||||||
@@ -125,23 +136,22 @@ class TestDownloadUpdatesInMemoryCache:
|
|||||||
queue_repository=MagicMock(),
|
queue_repository=MagicMock(),
|
||||||
max_retries=3,
|
max_retries=3,
|
||||||
)
|
)
|
||||||
service._directory = tmp
|
service._directory = str(mock_anime_service._directory)
|
||||||
yield service
|
yield service
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_download_updates_in_memory_cache(
|
async def test_download_updates_in_memory_cache(
|
||||||
self, mock_download_service, mock_anime_service
|
self, mock_download_service, mock_anime_service
|
||||||
):
|
):
|
||||||
"""Verify in-memory Serie.episodeDict is updated after download."""
|
"""Verify in-memory AnimeSeries.episodeDict is updated after download."""
|
||||||
# First reset to known state (remove the defaults first call might have set)
|
anime = mock_anime_service._app.list.keyDict["multi-season-series"]
|
||||||
serie = mock_anime_service._app.list.keyDict["multi-season-series"]
|
|
||||||
|
|
||||||
# Put back episodes after the fixture setup
|
# Put back episodes after the fixture setup
|
||||||
serie.episodeDict = {1: [1, 2, 3, 4, 5], 2: [1, 2, 3]}
|
anime.episodeDict = {1: [1, 2, 3, 4, 5], 2: [1, 2, 3]}
|
||||||
|
|
||||||
# Verify preconditions
|
# Verify preconditions
|
||||||
assert 1 in serie.episodeDict[1]
|
assert 1 in anime.episodeDict[1]
|
||||||
assert 3 in serie.episodeDict[2]
|
assert 3 in anime.episodeDict[2]
|
||||||
|
|
||||||
# Simulate downloading multiple episodes
|
# Simulate downloading multiple episodes
|
||||||
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 1)
|
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 1)
|
||||||
@@ -149,125 +159,39 @@ class TestDownloadUpdatesInMemoryCache:
|
|||||||
mock_download_service._remove_episode_from_memory("multi-season-series", 2, 2)
|
mock_download_service._remove_episode_from_memory("multi-season-series", 2, 2)
|
||||||
|
|
||||||
# Verify episodes removed
|
# Verify episodes removed
|
||||||
assert 1 not in serie.episodeDict[1], "Episode 1 of season 1 should be removed"
|
assert 1 not in anime.episodeDict[1], "Episode 1 of season 1 should be removed"
|
||||||
assert 3 not in serie.episodeDict[1], "Episode 3 of season 1 should be removed"
|
assert 3 not in anime.episodeDict[1], "Episode 3 of season 1 should be removed"
|
||||||
assert 2 in serie.episodeDict[1], "Episode 2 of season 1 should remain"
|
assert 2 in anime.episodeDict[1], "Episode 2 of season 1 should remain"
|
||||||
assert 3 in serie.episodeDict[2], "Episode 3 of season 2 should remain"
|
assert 3 in anime.episodeDict[2], "Episode 3 of season 2 should remain"
|
||||||
assert 2 not in serie.episodeDict[2], "Episode 2 of season 2 should be removed"
|
assert 2 not in anime.episodeDict[2], "Episode 2 of season 2 should be removed"
|
||||||
|
|
||||||
# Verify seasons with no episodes are cleaned up
|
# Verify seasons with no episodes are cleaned up
|
||||||
assert 2 in serie.episodeDict, "Season 2 should still exist (has episode 1, 3)"
|
assert 2 in anime.episodeDict, "Season 2 should still exist (has episode 1, 3)"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_last_episode_removes_season(
|
async def test_last_episode_removes_season(
|
||||||
self, mock_download_service, mock_anime_service
|
self, mock_download_service, mock_anime_service
|
||||||
):
|
):
|
||||||
"""Verify that removing last episode in a season removes the season key."""
|
"""Verify that removing last episode in a season removes the season key."""
|
||||||
# Modify the series so season 1 only has episode 2 left
|
anime = mock_anime_service._app.list.keyDict["multi-season-series"]
|
||||||
serie = mock_anime_service._app.list.keyDict["multi-season-series"]
|
|
||||||
# Reset and set to proper test state
|
# Reset and set to proper test state
|
||||||
serie.episodeDict = {1: [2], 2: [1, 2, 3]} # Season 1 only has episode 2
|
anime.episodeDict = {1: [2], 2: [1, 2, 3]} # Season 1 only has episode 2
|
||||||
|
|
||||||
# Verify initial state
|
# Verify initial state
|
||||||
assert 2 in serie.episodeDict[1]
|
assert 2 in anime.episodeDict[1]
|
||||||
assert 2 in serie.episodeDict[2]
|
assert 2 in anime.episodeDict[2]
|
||||||
|
|
||||||
# Remove last episode of season 1 (episode 2)
|
# Remove last episode of season 1 (episode 2)
|
||||||
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 2)
|
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 2)
|
||||||
|
|
||||||
# Season 1 should be completely removed
|
# Season 1 should be completely removed
|
||||||
assert 1 not in serie.episodeDict, "Season 1 should be removed"
|
assert 1 not in anime.episodeDict, "Season 1 should be removed"
|
||||||
# Season 2 should still exist
|
# Season 2 should still exist
|
||||||
assert 2 in serie.episodeDict, "Season 2 should still exist"
|
assert 2 in anime.episodeDict, "Season 2 should still exist"
|
||||||
|
|
||||||
|
|
||||||
class TestDataFileUpdatedAfterDownload:
|
class TestDownloadWithoutDataFile:
|
||||||
"""Verify data file is updated after download (when it exists)."""
|
"""Verify downloads work without data file (in-memory only)."""
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def temp_dir(self):
|
|
||||||
"""Create temp directory for test data files."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
yield Path(tmp)
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_anime_service(self, temp_dir):
|
|
||||||
"""Create mock anime service with app."""
|
|
||||||
anime_service = MagicMock()
|
|
||||||
anime_service._directory = str(temp_dir)
|
|
||||||
|
|
||||||
# Create series folder with data file
|
|
||||||
series_folder = temp_dir / "Test Series"
|
|
||||||
series_folder.mkdir()
|
|
||||||
data_path = series_folder / "data"
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series-with-data",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series",
|
|
||||||
episodeDict={1: [1, 2, 3]},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save data file to disk
|
|
||||||
import warnings
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
serie.save_to_file(str(data_path))
|
|
||||||
|
|
||||||
# Update episodeDict to simulate in-progress download state
|
|
||||||
# (episodeDict still has all episodes; will be updated after download)
|
|
||||||
mock_app = MagicMock()
|
|
||||||
mock_app.list.keyDict = {"test-series-with-data": serie}
|
|
||||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
|
||||||
mock_app.series_list = [serie]
|
|
||||||
anime_service._app = mock_app
|
|
||||||
anime_service._cached_list_missing = MagicMock()
|
|
||||||
anime_service._broadcast_series_updated = AsyncMock()
|
|
||||||
|
|
||||||
return anime_service
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_download_service(self, mock_anime_service):
|
|
||||||
"""Create download service with mocked dependencies."""
|
|
||||||
service = DownloadService(
|
|
||||||
anime_service=mock_anime_service,
|
|
||||||
queue_repository=MagicMock(),
|
|
||||||
max_retries=3,
|
|
||||||
)
|
|
||||||
service._directory = str(mock_anime_service._directory)
|
|
||||||
yield service
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_data_file_updated_after_download(
|
|
||||||
self, mock_download_service, mock_anime_service, temp_dir
|
|
||||||
):
|
|
||||||
"""Verify data file is updated after download when data file exists."""
|
|
||||||
serie = mock_anime_service._app.list.keyDict["test-series-with-data"]
|
|
||||||
data_path = temp_dir / "Test Series" / "data"
|
|
||||||
|
|
||||||
# Verify data file exists before test
|
|
||||||
assert data_path.exists(), "Data file should exist before test"
|
|
||||||
|
|
||||||
# Read original data file
|
|
||||||
with open(data_path) as f:
|
|
||||||
original_data = json.load(f)
|
|
||||||
assert 2 in original_data["episodeDict"]["1"], "Episode should be in original data"
|
|
||||||
|
|
||||||
# Simulate download completion
|
|
||||||
mock_download_service._remove_episode_from_memory("test-series-with-data", 1, 2)
|
|
||||||
|
|
||||||
# Read updated data file
|
|
||||||
with open(data_path) as f:
|
|
||||||
updated_data = json.load(f)
|
|
||||||
|
|
||||||
# Verify episode 2 was removed from data file
|
|
||||||
assert 2 not in updated_data["episodeDict"]["1"], "Episode should be removed from data file"
|
|
||||||
assert updated_data["episodeDict"]["1"] == [1, 3]
|
|
||||||
|
|
||||||
|
|
||||||
class TestDataFileNotRequiredForDownload:
|
|
||||||
"""Verify downloads work even when data file doesn't exist."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def temp_dir(self):
|
def temp_dir(self):
|
||||||
@@ -281,19 +205,18 @@ class TestDataFileNotRequiredForDownload:
|
|||||||
anime_service = MagicMock()
|
anime_service = MagicMock()
|
||||||
anime_service._directory = str(temp_dir)
|
anime_service._directory = str(temp_dir)
|
||||||
|
|
||||||
# Create series with NO data file on disk (only in memory)
|
anime = make_anime(
|
||||||
serie = Serie(
|
|
||||||
key="memory-only-series",
|
key="memory-only-series",
|
||||||
name="Memory Only Series",
|
name="Memory Only Series",
|
||||||
site="https://example.com",
|
site="https://example.com",
|
||||||
folder="Memory Only Series",
|
folder="Memory Only Series",
|
||||||
episodeDict={1: [1, 2, 3]},
|
episode_dict={1: [1, 2, 3]},
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_app = MagicMock()
|
mock_app = MagicMock()
|
||||||
mock_app.list.keyDict = {"memory-only-series": serie}
|
mock_app.list.keyDict = {"memory-only-series": anime}
|
||||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
mock_app.list.GetMissingEpisode.return_value = [anime]
|
||||||
mock_app.series_list = [serie]
|
mock_app.series_list = [anime]
|
||||||
anime_service._app = mock_app
|
anime_service._app = mock_app
|
||||||
anime_service._cached_list_missing = MagicMock()
|
anime_service._cached_list_missing = MagicMock()
|
||||||
anime_service._broadcast_series_updated = AsyncMock()
|
anime_service._broadcast_series_updated = AsyncMock()
|
||||||
@@ -316,7 +239,7 @@ class TestDataFileNotRequiredForDownload:
|
|||||||
self, mock_download_service, mock_anime_service
|
self, mock_download_service, mock_anime_service
|
||||||
):
|
):
|
||||||
"""Verify downloads work even when no data file exists on disk."""
|
"""Verify downloads work even when no data file exists on disk."""
|
||||||
serie = mock_anime_service._app.list.keyDict["memory-only-series"]
|
anime = mock_anime_service._app.list.keyDict["memory-only-series"]
|
||||||
data_path = Path(mock_anime_service._directory) / "Memory Only Series" / "data"
|
data_path = Path(mock_anime_service._directory) / "Memory Only Series" / "data"
|
||||||
|
|
||||||
# Verify no data file exists
|
# Verify no data file exists
|
||||||
@@ -327,7 +250,7 @@ class TestDataFileNotRequiredForDownload:
|
|||||||
mock_download_service._remove_episode_from_memory("memory-only-series", 1, 2)
|
mock_download_service._remove_episode_from_memory("memory-only-series", 1, 2)
|
||||||
|
|
||||||
# Episode should be removed from in-memory state
|
# Episode should be removed from in-memory state
|
||||||
assert 2 not in serie.episodeDict[1], "Episode should be removed from memory"
|
assert 2 not in anime.episodeDict[1], "Episode should be removed from memory"
|
||||||
|
|
||||||
# Data file should still not exist (no file created)
|
# Data file should still not exist (no file created)
|
||||||
assert not data_path.exists(), "No data file should be created"
|
assert not data_path.exists(), "No data file should be created"
|
||||||
@@ -5,12 +5,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.providers.failover import (
|
from src.server.providers.failover import (
|
||||||
ProviderFailover,
|
ProviderFailover,
|
||||||
configure_failover,
|
configure_failover,
|
||||||
get_failover,
|
get_failover,
|
||||||
)
|
)
|
||||||
from src.core.providers.health_monitor import ProviderHealthMonitor
|
from src.server.providers.health_monitor import ProviderHealthMonitor
|
||||||
|
|
||||||
|
|
||||||
class TestProviderFailoverScenarios:
|
class TestProviderFailoverScenarios:
|
||||||
@@ -132,7 +132,7 @@ class TestProviderFailoverScenarios:
|
|||||||
assert "provider1" not in monitor.get_available_providers()
|
assert "provider1" not in monitor.get_available_providers()
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.failover.get_health_monitor",
|
"src.server.providers.failover.get_health_monitor",
|
||||||
return_value=monitor,
|
return_value=monitor,
|
||||||
):
|
):
|
||||||
failover = ProviderFailover(
|
failover = ProviderFailover(
|
||||||
@@ -236,7 +236,7 @@ class TestFailoverStats:
|
|||||||
monitor.record_request("p2", False, 200, error_message="fail")
|
monitor.record_request("p2", False, 200, error_message="fail")
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.failover.get_health_monitor",
|
"src.server.providers.failover.get_health_monitor",
|
||||||
return_value=monitor,
|
return_value=monitor,
|
||||||
):
|
):
|
||||||
failover = ProviderFailover(
|
failover = ProviderFailover(
|
||||||
@@ -253,7 +253,7 @@ class TestConfigureFailover:
|
|||||||
|
|
||||||
def test_configure_failover(self):
|
def test_configure_failover(self):
|
||||||
"""configure_failover should create a new global instance."""
|
"""configure_failover should create a new global instance."""
|
||||||
import src.core.providers.failover as fo
|
import src.server.providers.failover as fo
|
||||||
fo._failover = None
|
fo._failover = None
|
||||||
|
|
||||||
failover = configure_failover(
|
failover = configure_failover(
|
||||||
@@ -271,7 +271,7 @@ class TestConfigureFailover:
|
|||||||
|
|
||||||
def test_get_failover_singleton(self):
|
def test_get_failover_singleton(self):
|
||||||
"""get_failover should return same instance."""
|
"""get_failover should return same instance."""
|
||||||
import src.core.providers.failover as fo
|
import src.server.providers.failover as fo
|
||||||
fo._failover = None
|
fo._failover = None
|
||||||
|
|
||||||
first = get_failover()
|
first = get_failover()
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.providers.config_manager import ProviderConfigManager, ProviderSettings
|
from src.server.providers.config_manager import ProviderConfigManager, ProviderSettings
|
||||||
from src.core.providers.failover import ProviderFailover
|
from src.server.providers.failover import ProviderFailover
|
||||||
from src.core.providers.health_monitor import (
|
from src.server.providers.health_monitor import (
|
||||||
ProviderHealthMetrics,
|
ProviderHealthMetrics,
|
||||||
ProviderHealthMonitor,
|
ProviderHealthMonitor,
|
||||||
)
|
)
|
||||||
@@ -174,7 +174,7 @@ class TestProviderSelectionWithFailover:
|
|||||||
monitor.record_request("p2", True, 50)
|
monitor.record_request("p2", True, 50)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.failover.get_health_monitor",
|
"src.server.providers.failover.get_health_monitor",
|
||||||
return_value=monitor,
|
return_value=monitor,
|
||||||
):
|
):
|
||||||
failover = ProviderFailover(
|
failover = ProviderFailover(
|
||||||
|
|||||||
@@ -6,13 +6,33 @@ special characters, Unicode names, and malformed folder structures.
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import Mock
|
from unittest.mock import MagicMock, Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
from src.core.providers.base_provider import Loader
|
from src.server.providers.base_provider import Loader
|
||||||
from src.core.SerieScanner import SerieScanner
|
from src.server.SerieScanner import SerieScanner
|
||||||
|
from src.server.utils.filesystem import sanitize_folder_name
|
||||||
|
|
||||||
|
|
||||||
|
def make_anime(key, name, folder=None, episode_dict=None, year=None, site="aniworld.to"):
|
||||||
|
"""Create a mock AnimeSeries with needed properties."""
|
||||||
|
anime = MagicMock(spec=AnimeSeries)
|
||||||
|
anime.key = key
|
||||||
|
anime.name = name
|
||||||
|
anime.folder = folder or name
|
||||||
|
anime.site = site
|
||||||
|
anime.year = year
|
||||||
|
anime.episodeDict = episode_dict or {}
|
||||||
|
# Compute name_with_year
|
||||||
|
if year:
|
||||||
|
anime.name_with_year = f"{name} ({year})"
|
||||||
|
else:
|
||||||
|
anime.name_with_year = name
|
||||||
|
# Compute sanitized_folder
|
||||||
|
anime.sanitized_folder = sanitize_folder_name(anime.name_with_year)
|
||||||
|
return anime
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -133,112 +153,112 @@ class TestSpecialCharacters:
|
|||||||
|
|
||||||
def test_colon_in_name(self, temp_anime_dir, mock_loader):
|
def test_colon_in_name(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name with colon."""
|
"""Test series name with colon."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="re-zero",
|
key="re-zero",
|
||||||
name="Re:Zero - Starting Life in Another World",
|
name="Re:Zero - Starting Life in Another World",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Re Zero",
|
folder="Re Zero",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sanitized folder should remove colon
|
# Sanitized folder should remove colon
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
assert ":" not in sanitized
|
assert ":" not in sanitized
|
||||||
assert "Re" in sanitized
|
assert "Re" in sanitized
|
||||||
assert "Zero" in sanitized
|
assert "Zero" in sanitized
|
||||||
|
|
||||||
def test_slash_in_name(self, temp_anime_dir, mock_loader):
|
def test_slash_in_name(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name with slash."""
|
"""Test series name with slash."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="fate-stay-night",
|
key="fate-stay-night",
|
||||||
name="Fate/Stay Night: Unlimited Blade Works",
|
name="Fate/Stay Night: Unlimited Blade Works",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Fate Stay Night",
|
folder="Fate Stay Night",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
assert "/" not in sanitized
|
assert "/" not in sanitized
|
||||||
assert "\\" not in sanitized
|
assert "\\" not in sanitized
|
||||||
|
|
||||||
def test_question_mark_in_name(self, temp_anime_dir, mock_loader):
|
def test_question_mark_in_name(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name with question mark."""
|
"""Test series name with question mark."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="is-it-wrong",
|
key="is-it-wrong",
|
||||||
name="Is It Wrong to Try to Pick Up Girls in a Dungeon?",
|
name="Is It Wrong to Try to Pick Up Girls in a Dungeon?",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Is It Wrong",
|
folder="Is It Wrong",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
assert "?" not in sanitized
|
assert "?" not in sanitized
|
||||||
|
|
||||||
def test_asterisk_in_name(self, temp_anime_dir, mock_loader):
|
def test_asterisk_in_name(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name with asterisk."""
|
"""Test series name with asterisk."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="series",
|
key="series",
|
||||||
name="Series * Special",
|
name="Series * Special",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Series Special",
|
folder="Series Special",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
assert "*" not in sanitized
|
assert "*" not in sanitized
|
||||||
|
|
||||||
def test_pipe_in_name(self, temp_anime_dir, mock_loader):
|
def test_pipe_in_name(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name with pipe character."""
|
"""Test series name with pipe character."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="series",
|
key="series",
|
||||||
name="Series | Part 2",
|
name="Series | Part 2",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Series Part 2",
|
folder="Series Part 2",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
assert "|" not in sanitized
|
assert "|" not in sanitized
|
||||||
|
|
||||||
def test_quotes_in_name(self, temp_anime_dir, mock_loader):
|
def test_quotes_in_name(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name with quotes."""
|
"""Test series name with quotes."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="series",
|
key="series",
|
||||||
name='Series "Subtitle" Edition',
|
name='Series "Subtitle" Edition',
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Series Subtitle Edition",
|
folder="Series Subtitle Edition",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
# Quotes should be removed or replaced
|
# Quotes should be removed or replaced
|
||||||
assert '"' not in sanitized or sanitized.count('"') == 0
|
assert '"' not in sanitized or sanitized.count('"') == 0
|
||||||
|
|
||||||
def test_less_greater_than_in_name(self, temp_anime_dir, mock_loader):
|
def test_less_greater_than_in_name(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name with < and >."""
|
"""Test series name with < and >."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="series",
|
key="series",
|
||||||
name="Series <Special> Edition",
|
name="Series <Special> Edition",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Series Special Edition",
|
folder="Series Special Edition",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
assert "<" not in sanitized
|
assert "<" not in sanitized
|
||||||
assert ">" not in sanitized
|
assert ">" not in sanitized
|
||||||
|
|
||||||
def test_multiple_special_chars(self, temp_anime_dir, mock_loader):
|
def test_multiple_special_chars(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name with multiple special characters."""
|
"""Test series name with multiple special characters."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="complex",
|
key="complex",
|
||||||
name="Re:Zero / Fate * Special? <Edition>",
|
name="Re:Zero / Fate * Special? <Edition>",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Re Zero Fate Special Edition",
|
folder="Re Zero Fate Special Edition",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
# Should remove all special chars
|
# Should remove all special chars
|
||||||
invalid_chars = [':', '/', '*', '?', '<', '>']
|
invalid_chars = [':', '/', '*', '?', '<', '>']
|
||||||
for char in invalid_chars:
|
for char in invalid_chars:
|
||||||
@@ -250,45 +270,45 @@ class TestMultipleSpaces:
|
|||||||
|
|
||||||
def test_double_spaces(self, temp_anime_dir, mock_loader):
|
def test_double_spaces(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name with double spaces."""
|
"""Test series name with double spaces."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="series",
|
key="series",
|
||||||
name="Attack on Titan",
|
name="Attack on Titan",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Attack on Titan",
|
folder="Attack on Titan",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
# Multiple spaces should be preserved or normalized to single space
|
# Multiple spaces should be preserved or normalized to single space
|
||||||
assert "Attack" in sanitized
|
assert "Attack" in sanitized
|
||||||
assert "Titan" in sanitized
|
assert "Titan" in sanitized
|
||||||
|
|
||||||
def test_leading_trailing_spaces(self, temp_anime_dir, mock_loader):
|
def test_leading_trailing_spaces(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name with leading/trailing spaces."""
|
"""Test series name with leading/trailing spaces."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="series",
|
key="series",
|
||||||
name=" Attack on Titan ",
|
name=" Attack on Titan ",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Attack on Titan",
|
folder="Attack on Titan",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
# Leading/trailing spaces should be stripped
|
# Leading/trailing spaces should be stripped
|
||||||
assert not sanitized.startswith(" ")
|
assert not sanitized.startswith(" ")
|
||||||
assert not sanitized.endswith(" ")
|
assert not sanitized.endswith(" ")
|
||||||
|
|
||||||
def test_tabs_in_name(self, temp_anime_dir, mock_loader):
|
def test_tabs_in_name(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name with tab characters."""
|
"""Test series name with tab characters."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="series",
|
key="series",
|
||||||
name="Attack\ton\tTitan",
|
name="Attack\ton\tTitan",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Attack on Titan",
|
folder="Attack on Titan",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
# Tabs should be handled (removed or replaced)
|
# Tabs should be handled (removed or replaced)
|
||||||
assert "\t" not in sanitized or sanitized.replace("\t", " ")
|
assert "\t" not in sanitized or sanitized.replace("\t", " ")
|
||||||
|
|
||||||
@@ -298,95 +318,95 @@ class TestUnicodeNames:
|
|||||||
|
|
||||||
def test_japanese_name(self, temp_anime_dir, mock_loader):
|
def test_japanese_name(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name in Japanese."""
|
"""Test series name in Japanese."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="shingeki",
|
key="shingeki",
|
||||||
name="進撃の巨人",
|
name="進撃の巨人",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="進撃の巨人",
|
folder="進撃の巨人",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
# Unicode should be preserved
|
# Unicode should be preserved
|
||||||
assert "進撃の巨人" in sanitized
|
assert "進撃の巨人" in sanitized
|
||||||
|
|
||||||
def test_chinese_name(self, temp_anime_dir, mock_loader):
|
def test_chinese_name(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name in Chinese."""
|
"""Test series name in Chinese."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="series",
|
key="series",
|
||||||
name="进击的巨人",
|
name="进击的巨人",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="进击的巨人",
|
folder="进击的巨人",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
assert "进击的巨人" in sanitized
|
assert "进击的巨人" in sanitized
|
||||||
|
|
||||||
def test_korean_name(self, temp_anime_dir, mock_loader):
|
def test_korean_name(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name in Korean."""
|
"""Test series name in Korean."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="series",
|
key="series",
|
||||||
name="진격의 거인",
|
name="진격의 거인",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="진격의 거인",
|
folder="진격의 거인",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
assert "진격의" in sanitized
|
assert "진격의" in sanitized
|
||||||
|
|
||||||
def test_arabic_name(self, temp_anime_dir, mock_loader):
|
def test_arabic_name(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name in Arabic."""
|
"""Test series name in Arabic."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="series",
|
key="series",
|
||||||
name="هجوم العمالقة",
|
name="هجوم العمالقة",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="هجوم العمالقة",
|
folder="هجوم العمالقة",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
assert "هجوم" in sanitized
|
assert "هجوم" in sanitized
|
||||||
|
|
||||||
def test_cyrillic_name(self, temp_anime_dir, mock_loader):
|
def test_cyrillic_name(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name in Cyrillic."""
|
"""Test series name in Cyrillic."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="series",
|
key="series",
|
||||||
name="Атака Титанов",
|
name="Атака Титанов",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Атака Титанов",
|
folder="Атака Титанов",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
assert "Атака" in sanitized
|
assert "Атака" in sanitized
|
||||||
|
|
||||||
def test_mixed_languages(self, temp_anime_dir, mock_loader):
|
def test_mixed_languages(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name with mixed languages."""
|
"""Test series name with mixed languages."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="series",
|
key="series",
|
||||||
name="Attack on Titan - 進撃の巨人",
|
name="Attack on Titan - 進撃の巨人",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Attack on Titan",
|
folder="Attack on Titan",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
assert "Attack" in sanitized
|
assert "Attack" in sanitized
|
||||||
assert "進撃の巨人" in sanitized
|
assert "進撃の巨人" in sanitized
|
||||||
|
|
||||||
def test_emoji_in_name(self, temp_anime_dir, mock_loader):
|
def test_emoji_in_name(self, temp_anime_dir, mock_loader):
|
||||||
"""Test series name with emoji."""
|
"""Test series name with emoji."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="series",
|
key="series",
|
||||||
name="Series ⚔️ Special",
|
name="Series ⚔️ Special",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Series Special",
|
folder="Series Special",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
# Emoji should be handled gracefully
|
# Emoji should be handled gracefully
|
||||||
assert "Series" in sanitized
|
assert "Series" in sanitized
|
||||||
|
|
||||||
@@ -418,16 +438,16 @@ class TestMalformedFolderStructures:
|
|||||||
def test_very_long_folder_name(self, temp_anime_dir, mock_loader):
|
def test_very_long_folder_name(self, temp_anime_dir, mock_loader):
|
||||||
"""Test handling of very long folder names."""
|
"""Test handling of very long folder names."""
|
||||||
long_name = "A" * 300 # Very long name
|
long_name = "A" * 300 # Very long name
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="long",
|
key="long",
|
||||||
name=long_name,
|
name=long_name,
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder=long_name,
|
folder=long_name,
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should handle long names without error
|
# Should handle long names without error
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
assert len(sanitized) > 0
|
assert len(sanitized) > 0
|
||||||
|
|
||||||
def test_folder_name_with_dots(self, temp_anime_dir, mock_loader):
|
def test_folder_name_with_dots(self, temp_anime_dir, mock_loader):
|
||||||
@@ -439,127 +459,80 @@ class TestMalformedFolderStructures:
|
|||||||
|
|
||||||
def test_folder_name_with_underscores(self, temp_anime_dir, mock_loader):
|
def test_folder_name_with_underscores(self, temp_anime_dir, mock_loader):
|
||||||
"""Test folder name with underscores."""
|
"""Test folder name with underscores."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="series",
|
key="series",
|
||||||
name="Attack_on_Titan",
|
name="Attack_on_Titan",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Attack_on_Titan",
|
folder="Attack_on_Titan",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
# Underscores are valid filesystem chars
|
# Underscores are valid filesystem chars
|
||||||
assert "Attack" in sanitized
|
assert "Attack" in sanitized
|
||||||
|
|
||||||
|
|
||||||
class TestNameWithYearProperty:
|
class TestNameWithYearProperty:
|
||||||
"""Test Serie.name_with_year property."""
|
"""Test AnimeSeries.name_with_year property."""
|
||||||
|
|
||||||
def test_name_with_year_adds_year(self):
|
def test_name_with_year_adds_year(self):
|
||||||
"""Test that name_with_year adds year in parentheses."""
|
"""Test that name_with_year adds year in parentheses."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="dororo",
|
key="dororo",
|
||||||
name="Dororo",
|
name="Dororo",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Dororo",
|
folder="Dororo",
|
||||||
episodeDict={},
|
episode_dict={},
|
||||||
year=2025
|
year=2025
|
||||||
)
|
)
|
||||||
|
|
||||||
assert serie.name_with_year == "Dororo (2025)"
|
assert anime.name_with_year == "Dororo (2025)"
|
||||||
|
|
||||||
def test_name_with_year_no_year(self):
|
def test_name_with_year_no_year(self):
|
||||||
"""Test name_with_year without year returns just name."""
|
"""Test name_with_year without year returns just name."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="dororo",
|
key="dororo",
|
||||||
name="Dororo",
|
name="Dororo",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Dororo",
|
folder="Dororo",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert serie.name_with_year == "Dororo"
|
assert anime.name_with_year == "Dororo"
|
||||||
|
|
||||||
def test_name_with_year_used_in_sanitized_folder(self):
|
def test_name_with_year_used_in_sanitized_folder(self):
|
||||||
"""Test that sanitized_folder uses name_with_year."""
|
"""Test that sanitized_folder uses name_with_year."""
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="attack",
|
key="attack",
|
||||||
name="Attack on Titan",
|
name="Attack on Titan",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Attack on Titan",
|
folder="Attack on Titan",
|
||||||
episodeDict={},
|
episode_dict={},
|
||||||
year=2013
|
year=2013
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
assert "(2013)" in sanitized
|
assert "(2013)" in sanitized
|
||||||
assert "Attack on Titan" in sanitized
|
assert "Attack on Titan" in sanitized
|
||||||
|
|
||||||
def test_name_with_year_does_not_duplicate(self):
|
|
||||||
"""Test that name_with_year doesn't duplicate year."""
|
|
||||||
serie = Serie(
|
|
||||||
key="eighty-six",
|
|
||||||
name="86 Eighty Six (2021)",
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="86 Eighty Six (2021)",
|
|
||||||
episodeDict={},
|
|
||||||
year=2021
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.name_with_year == "86 Eighty Six (2021)"
|
|
||||||
assert serie.name_with_year.count("(2021)") == 1
|
|
||||||
|
|
||||||
|
class TestSanitizedFolder:
|
||||||
|
"""Test AnimeSeries.sanitized_folder property."""
|
||||||
|
|
||||||
class TestEnsureFolderWithYear:
|
def test_sanitized_folder_uses_name_with_year(self):
|
||||||
"""Test Serie.ensure_folder_with_year method."""
|
"""Test that sanitized_folder uses name_with_year."""
|
||||||
|
anime = make_anime(
|
||||||
def test_ensure_folder_adds_year_when_missing(self):
|
|
||||||
"""Test that ensure_folder_with_year adds year to folder."""
|
|
||||||
serie = Serie(
|
|
||||||
key="attack",
|
key="attack",
|
||||||
name="Attack on Titan",
|
name="Attack on Titan",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Attack on Titan",
|
folder="Attack on Titan",
|
||||||
episodeDict={},
|
episode_dict={},
|
||||||
year=2013
|
year=2013
|
||||||
)
|
)
|
||||||
|
|
||||||
result = serie.ensure_folder_with_year()
|
sanitized = anime.sanitized_folder
|
||||||
|
assert "(2013)" in sanitized
|
||||||
assert "(2013)" in result
|
assert "Attack on Titan" in sanitized
|
||||||
assert serie.folder == result
|
|
||||||
|
|
||||||
def test_ensure_folder_doesnt_duplicate_year(self):
|
|
||||||
"""Test that year isn't added if already present."""
|
|
||||||
serie = Serie(
|
|
||||||
key="attack",
|
|
||||||
name="Attack on Titan",
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="Attack on Titan (2013)",
|
|
||||||
episodeDict={},
|
|
||||||
year=2013
|
|
||||||
)
|
|
||||||
|
|
||||||
original_folder = serie.folder
|
|
||||||
result = serie.ensure_folder_with_year()
|
|
||||||
|
|
||||||
# Should not change
|
|
||||||
assert result.count("(2013)") == 1
|
|
||||||
|
|
||||||
def test_ensure_folder_no_year_unchanged(self):
|
|
||||||
"""Test that folder unchanged when no year available."""
|
|
||||||
serie = Serie(
|
|
||||||
key="attack",
|
|
||||||
name="Attack on Titan",
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="Attack on Titan",
|
|
||||||
episodeDict={}
|
|
||||||
)
|
|
||||||
|
|
||||||
original_folder = serie.folder
|
|
||||||
result = serie.ensure_folder_with_year()
|
|
||||||
|
|
||||||
assert result == original_folder
|
|
||||||
|
|
||||||
|
|
||||||
class TestRealWorldScenarios:
|
class TestRealWorldScenarios:
|
||||||
@@ -576,15 +549,15 @@ class TestRealWorldScenarios:
|
|||||||
]
|
]
|
||||||
|
|
||||||
for key, name, expected_part in test_cases:
|
for key, name, expected_part in test_cases:
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key=key,
|
key=key,
|
||||||
name=name,
|
name=name,
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="old-folder",
|
folder="old-folder",
|
||||||
episodeDict={}
|
episode_dict={}
|
||||||
)
|
)
|
||||||
|
|
||||||
sanitized = serie.sanitized_folder
|
sanitized = anime.sanitized_folder
|
||||||
# Check that expected part is in sanitized name
|
# Check that expected part is in sanitized name
|
||||||
assert any(word in sanitized for word in expected_part.split())
|
assert any(word in sanitized for word in expected_part.split())
|
||||||
# Check invalid chars removed (< > : " / \ | ? *)
|
# Check invalid chars removed (< > : " / \ | ? *)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.services.tmdb_client import TMDBAPIError, TMDBClient
|
from src.server.services_nfo_temp.tmdb_client import TMDBAPIError, TMDBClient
|
||||||
|
|
||||||
|
|
||||||
def _make_ctx(response):
|
def _make_ctx(response):
|
||||||
|
|||||||
@@ -11,20 +11,20 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
from src.core.SeriesApp import SeriesApp
|
from src.server.SeriesApp import SeriesApp
|
||||||
from src.core.SerieScanner import SerieScanner
|
from src.server.SerieScanner import SerieScanner
|
||||||
|
|
||||||
|
|
||||||
def _mock_read_data(folder_name):
|
def _mock_read_data(folder_name):
|
||||||
"""Create a mock Serie from a folder name for scanner patching."""
|
"""Create a mock AnimeSeries from a folder name for scanner patching."""
|
||||||
serie = Mock(spec=Serie)
|
anime = Mock(spec=AnimeSeries)
|
||||||
serie.key = f"key_{folder_name}"
|
anime.key = f"key_{folder_name}"
|
||||||
serie.name = f"Series {folder_name}"
|
anime.name = f"Series {folder_name}"
|
||||||
serie.folder = folder_name
|
anime.folder = folder_name
|
||||||
serie.year = 2024
|
anime.year = 2024
|
||||||
serie.episodeDict = {}
|
anime.episodeDict = {}
|
||||||
return serie
|
return anime
|
||||||
|
|
||||||
|
|
||||||
def _scanner_patches(scanner):
|
def _scanner_patches(scanner):
|
||||||
@@ -273,12 +273,12 @@ class TestMemoryUsageDuringScans:
|
|||||||
series_dict = {}
|
series_dict = {}
|
||||||
|
|
||||||
for i in range(num_series):
|
for i in range(num_series):
|
||||||
serie = Mock(spec=Serie)
|
anime = Mock(spec=AnimeSeries)
|
||||||
serie.key = f"series_key_{i:04d}"
|
anime.key = f"series_key_{i:04d}"
|
||||||
serie.name = f"Test Series {i}"
|
anime.name = f"Test Series {i}"
|
||||||
serie.folder = f"Series_{i:04d}"
|
anime.folder = f"Series_{i:04d}"
|
||||||
serie.episodeDict = {}
|
anime.episodeDict = {}
|
||||||
series_dict[serie.key] = serie
|
series_dict[anime.key] = anime
|
||||||
|
|
||||||
dict_size = sys.getsizeof(series_dict)
|
dict_size = sys.getsizeof(series_dict)
|
||||||
avg_size_per_series = dict_size / num_series
|
avg_size_per_series = dict_size / num_series
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, Mock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.services.nfo_service import NFOService
|
from src.server.services.nfo_service import NFOService
|
||||||
from src.server.api.nfo import batch_create_nfo
|
from src.server.api.nfo import batch_create_nfo
|
||||||
from src.server.models.nfo import NFOBatchCreateRequest
|
from src.server.models.nfo import NFOBatchCreateRequest
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ class TestTMDBAPIBatchingOptimization:
|
|||||||
|
|
||||||
# Simulate rate limit on 5th call
|
# Simulate rate limit on 5th call
|
||||||
if call_count == 5:
|
if call_count == 5:
|
||||||
from src.core.services.tmdb_client import TMDBAPIError
|
from src.server.services_nfo_temp.tmdb_client import TMDBAPIError
|
||||||
raise TMDBAPIError("Rate limit exceeded")
|
raise TMDBAPIError("Rate limit exceeded")
|
||||||
|
|
||||||
await asyncio.sleep(0.01)
|
await asyncio.sleep(0.01)
|
||||||
|
|||||||
@@ -13,10 +13,22 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
|
||||||
from src.server.database.models import AnimeSeries, Episode
|
from src.server.database.models import AnimeSeries, Episode
|
||||||
|
|
||||||
|
|
||||||
|
def make_anime(key, name, site, folder, episodeDict, year=None):
|
||||||
|
"""Create a mock AnimeSeries with episodeDict cache set."""
|
||||||
|
mock = MagicMock(spec=AnimeSeries)
|
||||||
|
mock.key = key
|
||||||
|
mock.name = name
|
||||||
|
mock.site = site
|
||||||
|
mock.folder = folder
|
||||||
|
mock.year = year
|
||||||
|
mock.episodeDict = episodeDict
|
||||||
|
mock._episode_dict_cache = episodeDict
|
||||||
|
return mock
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_series_app():
|
def mock_series_app():
|
||||||
"""Create a mock SeriesApp with scanner."""
|
"""Create a mock SeriesApp with scanner."""
|
||||||
@@ -73,8 +85,8 @@ class TestAddSeriesWithEpisodes:
|
|||||||
|
|
||||||
# Mock scan_single_series to update keyDict
|
# Mock scan_single_series to update keyDict
|
||||||
def mock_scan(key, folder):
|
def mock_scan(key, folder):
|
||||||
# Create Serie with episodes
|
# Create anime with episodes
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key=key,
|
key=key,
|
||||||
name="Test Anime",
|
name="Test Anime",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
@@ -83,7 +95,7 @@ class TestAddSeriesWithEpisodes:
|
|||||||
year=2024
|
year=2024
|
||||||
)
|
)
|
||||||
# Update scanner's keyDict
|
# Update scanner's keyDict
|
||||||
mock_series_app.serie_scanner.keyDict[key] = serie
|
mock_series_app.serie_scanner.keyDict[key] = anime
|
||||||
return {1: [1, 2, 3]}
|
return {1: [1, 2, 3]}
|
||||||
|
|
||||||
mock_series_app.serie_scanner.scan_single_series = mock_scan
|
mock_series_app.serie_scanner.scan_single_series = mock_scan
|
||||||
@@ -106,8 +118,8 @@ class TestAddSeriesWithEpisodes:
|
|||||||
# Arrange
|
# Arrange
|
||||||
key = "test-anime"
|
key = "test-anime"
|
||||||
|
|
||||||
# Create Serie in scanner's keyDict with episodes
|
# Create anime in scanner's keyDict with episodes
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key=key,
|
key=key,
|
||||||
name="Test Anime",
|
name="Test Anime",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
@@ -115,7 +127,7 @@ class TestAddSeriesWithEpisodes:
|
|||||||
episodeDict={1: [1, 2, 3], 2: [1, 2]},
|
episodeDict={1: [1, 2, 3], 2: [1, 2]},
|
||||||
year=2024
|
year=2024
|
||||||
)
|
)
|
||||||
mock_series_app.serie_scanner.keyDict[key] = serie
|
mock_series_app.serie_scanner.keyDict[key] = anime
|
||||||
|
|
||||||
# Mock the database save method
|
# Mock the database save method
|
||||||
with patch.object(
|
with patch.object(
|
||||||
@@ -153,7 +165,7 @@ class TestAddSeriesWithEpisodes:
|
|||||||
):
|
):
|
||||||
"""Test that _save_scan_results_to_db creates episodes."""
|
"""Test that _save_scan_results_to_db creates episodes."""
|
||||||
# Arrange
|
# Arrange
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="test-anime",
|
key="test-anime",
|
||||||
name="Test Anime",
|
name="Test Anime",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
@@ -193,7 +205,7 @@ class TestAddSeriesWithEpisodes:
|
|||||||
mock_episode_service.create = AsyncMock(side_effect=track_episode_create)
|
mock_episode_service.create = AsyncMock(side_effect=track_episode_create)
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
result = await mock_anime_service._save_scan_results_to_db([serie])
|
result = await mock_anime_service._save_scan_results_to_db([anime])
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert result == 1 # One series saved
|
assert result == 1 # One series saved
|
||||||
@@ -217,7 +229,7 @@ class TestAddSeriesWithEpisodes:
|
|||||||
):
|
):
|
||||||
"""Test that _update_series_in_db adds new missing episodes."""
|
"""Test that _update_series_in_db adds new missing episodes."""
|
||||||
# Arrange
|
# Arrange
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key="test-anime",
|
key="test-anime",
|
||||||
name="Test Anime",
|
name="Test Anime",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
@@ -269,7 +281,7 @@ class TestAddSeriesWithEpisodes:
|
|||||||
mock_episode_service.delete = AsyncMock()
|
mock_episode_service.delete = AsyncMock()
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
result = await mock_anime_service._save_scan_results_to_db([serie])
|
result = await mock_anime_service._save_scan_results_to_db([anime])
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert result == 1
|
assert result == 1
|
||||||
@@ -292,7 +304,7 @@ class TestAddSeriesWithEpisodes:
|
|||||||
|
|
||||||
# Setup mock scanner to populate keyDict
|
# Setup mock scanner to populate keyDict
|
||||||
def mock_scan(key, folder):
|
def mock_scan(key, folder):
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key=key,
|
key=key,
|
||||||
name="Test Anime",
|
name="Test Anime",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
@@ -300,7 +312,7 @@ class TestAddSeriesWithEpisodes:
|
|||||||
episodeDict={1: [1, 2, 3]},
|
episodeDict={1: [1, 2, 3]},
|
||||||
year=2024
|
year=2024
|
||||||
)
|
)
|
||||||
mock_series_app.serie_scanner.keyDict[key] = serie
|
mock_series_app.serie_scanner.keyDict[key] = anime
|
||||||
return {1: [1, 2, 3]}
|
return {1: [1, 2, 3]}
|
||||||
|
|
||||||
mock_series_app.serie_scanner.scan_single_series = mock_scan
|
mock_series_app.serie_scanner.scan_single_series = mock_scan
|
||||||
@@ -368,8 +380,8 @@ class TestAddSeriesWithEpisodes:
|
|||||||
# Arrange
|
# Arrange
|
||||||
key = "test-anime"
|
key = "test-anime"
|
||||||
|
|
||||||
# Create Serie in list.keyDict with episodes
|
# Create anime in list.keyDict with episodes
|
||||||
serie = Serie(
|
anime = make_anime(
|
||||||
key=key,
|
key=key,
|
||||||
name="Test Anime",
|
name="Test Anime",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
@@ -377,7 +389,7 @@ class TestAddSeriesWithEpisodes:
|
|||||||
episodeDict={1: [1, 2, 3]},
|
episodeDict={1: [1, 2, 3]},
|
||||||
year=2024
|
year=2024
|
||||||
)
|
)
|
||||||
mock_series_app.list.keyDict[key] = serie
|
mock_series_app.list.keyDict[key] = anime
|
||||||
|
|
||||||
# Mock database AnimeSeries with NFO data
|
# Mock database AnimeSeries with NFO data
|
||||||
mock_db_series = AnimeSeries(
|
mock_db_series = AnimeSeries(
|
||||||
|
|||||||
@@ -7,12 +7,26 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
from src.server.SeriesApp import SeriesApp
|
||||||
from src.core.SeriesApp import SeriesApp
|
|
||||||
from src.server.database.models import AnimeSeries, Episode
|
from src.server.database.models import AnimeSeries, Episode
|
||||||
from src.server.services.anime_service import AnimeService
|
from src.server.services.anime_service import AnimeService
|
||||||
|
|
||||||
|
|
||||||
|
def make_anime(key, name, site, folder, episodeDict=None, year=None):
|
||||||
|
"""Create a mock AnimeSeries with episodeDict cache set."""
|
||||||
|
if episodeDict is None:
|
||||||
|
episodeDict = {}
|
||||||
|
mock = MagicMock(spec=AnimeSeries)
|
||||||
|
mock.key = key
|
||||||
|
mock.name = name
|
||||||
|
mock.site = site
|
||||||
|
mock.folder = folder
|
||||||
|
mock.year = year
|
||||||
|
mock.episodeDict = episodeDict
|
||||||
|
mock._episode_dict_cache = episodeDict
|
||||||
|
return mock
|
||||||
|
|
||||||
|
|
||||||
class TestAnimeListLoading:
|
class TestAnimeListLoading:
|
||||||
"""Test suite for anime list loading functionality."""
|
"""Test suite for anime list loading functionality."""
|
||||||
|
|
||||||
@@ -98,8 +112,8 @@ class TestAnimeListLoading:
|
|||||||
called_series = mock_series_app.load_series_from_list.call_args[0][0]
|
called_series = mock_series_app.load_series_from_list.call_args[0][0]
|
||||||
assert len(called_series) == 2
|
assert len(called_series) == 2
|
||||||
|
|
||||||
# Verify Serie objects have correct attributes
|
# Verify AnimeSeries objects have correct attributes
|
||||||
assert all(isinstance(s, Serie) for s in called_series)
|
assert all(isinstance(s, AnimeSeries) for s in called_series)
|
||||||
assert called_series[0].key == "test-anime-1"
|
assert called_series[0].key == "test-anime-1"
|
||||||
assert called_series[0].name == "Test Anime 1"
|
assert called_series[0].name == "Test Anime 1"
|
||||||
assert called_series[0].folder == "Test Anime 1 (2023)"
|
assert called_series[0].folder == "Test Anime 1 (2023)"
|
||||||
@@ -140,14 +154,14 @@ class TestAnimeListLoading:
|
|||||||
|
|
||||||
# Create test series
|
# Create test series
|
||||||
test_series = [
|
test_series = [
|
||||||
Serie(
|
make_anime(
|
||||||
key="test-1",
|
key="test-1",
|
||||||
name="Test Series 1",
|
name="Test Series 1",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder="Test Series 1 (2023)",
|
folder="Test Series 1 (2023)",
|
||||||
episodeDict={1: [1, 2, 3]}
|
episodeDict={1: [1, 2, 3]}
|
||||||
),
|
),
|
||||||
Serie(
|
make_anime(
|
||||||
key="test-2",
|
key="test-2",
|
||||||
name="Test Series 2",
|
name="Test Series 2",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
@@ -295,7 +309,7 @@ class TestAnimeListLoading:
|
|||||||
"With skip_load=True, list should be empty initially"
|
"With skip_load=True, list should be empty initially"
|
||||||
|
|
||||||
# Test that manual loading works
|
# Test that manual loading works
|
||||||
test_serie = Serie(
|
test_serie = make_anime(
|
||||||
key="test",
|
key="test",
|
||||||
name="Test",
|
name="Test",
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ from unittest.mock import MagicMock, Mock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from src.core.providers.aniworld_provider import AniworldLoader
|
from src.server.providers.aniworld_provider import AniworldLoader
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def loader():
|
def loader():
|
||||||
"""Create AniworldLoader with mocked session to prevent real HTTP calls."""
|
"""Create AniworldLoader with mocked session to prevent real HTTP calls."""
|
||||||
with patch("src.core.providers.aniworld_provider.UserAgent") as mock_ua:
|
with patch("src.server.providers.aniworld_provider.UserAgent") as mock_ua:
|
||||||
mock_ua.return_value.random = "MockUserAgent/1.0"
|
mock_ua.return_value.random = "MockUserAgent/1.0"
|
||||||
instance = AniworldLoader()
|
instance = AniworldLoader()
|
||||||
instance.session = MagicMock()
|
instance.session = MagicMock()
|
||||||
@@ -390,7 +390,7 @@ class TestAniworldProviderParsing:
|
|||||||
class TestAniworldSeasonEpisodeCount:
|
class TestAniworldSeasonEpisodeCount:
|
||||||
"""Test season and episode count retrieval."""
|
"""Test season and episode count retrieval."""
|
||||||
|
|
||||||
@patch("src.core.providers.aniworld_provider.requests.get")
|
@patch("src.server.providers.aniworld_provider.requests.get")
|
||||||
def test_get_season_episode_count(self, mock_get, loader):
|
def test_get_season_episode_count(self, mock_get, loader):
|
||||||
"""get_season_episode_count should return correct counts."""
|
"""get_season_episode_count should return correct counts."""
|
||||||
# Main page with 2 seasons
|
# Main page with 2 seasons
|
||||||
@@ -421,7 +421,7 @@ class TestAniworldSeasonEpisodeCount:
|
|||||||
result = loader.get_season_episode_count("naruto")
|
result = loader.get_season_episode_count("naruto")
|
||||||
assert result == {1: 3, 2: 2}
|
assert result == {1: 3, 2: 2}
|
||||||
|
|
||||||
@patch("src.core.providers.aniworld_provider.requests.get")
|
@patch("src.server.providers.aniworld_provider.requests.get")
|
||||||
def test_get_season_episode_count_no_seasons(self, mock_get, loader):
|
def test_get_season_episode_count_no_seasons(self, mock_get, loader):
|
||||||
"""get_season_episode_count should return empty dict when no seasons."""
|
"""get_season_episode_count should return empty dict when no seasons."""
|
||||||
html = "<html><body></body></html>"
|
html = "<html><body></body></html>"
|
||||||
@@ -616,7 +616,7 @@ class TestAniworldDownloadFailover:
|
|||||||
return ydl
|
return ydl
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.aniworld_provider.YoutubeDL",
|
"src.server.providers.aniworld_provider.YoutubeDL",
|
||||||
side_effect=fake_ytdl,
|
side_effect=fake_ytdl,
|
||||||
):
|
):
|
||||||
result = patched_loader.download(
|
result = patched_loader.download(
|
||||||
@@ -649,7 +649,7 @@ class TestAniworldDownloadFailover:
|
|||||||
return ydl
|
return ydl
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.aniworld_provider.YoutubeDL",
|
"src.server.providers.aniworld_provider.YoutubeDL",
|
||||||
side_effect=fake_ytdl,
|
side_effect=fake_ytdl,
|
||||||
):
|
):
|
||||||
result = patched_loader.download(
|
result = patched_loader.download(
|
||||||
@@ -670,7 +670,7 @@ class TestAniworldDownloadFailover:
|
|||||||
patched_loader._try_direct_stream.side_effect = write_direct
|
patched_loader._try_direct_stream.side_effect = write_direct
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.aniworld_provider.YoutubeDL"
|
"src.server.providers.aniworld_provider.YoutubeDL"
|
||||||
) as mock_ydl:
|
) as mock_ydl:
|
||||||
result = patched_loader.download(
|
result = patched_loader.download(
|
||||||
str(tmp_path), "Anime", 1, 1, "k", "German Dub"
|
str(tmp_path), "Anime", 1, 1, "k", "German Dub"
|
||||||
@@ -682,7 +682,7 @@ class TestAniworldDownloadFailover:
|
|||||||
self, patched_loader, tmp_path, caplog
|
self, patched_loader, tmp_path, caplog
|
||||||
):
|
):
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.aniworld_provider.YoutubeDL",
|
"src.server.providers.aniworld_provider.YoutubeDL",
|
||||||
side_effect=Exception("HTTP 404"),
|
side_effect=Exception("HTTP 404"),
|
||||||
):
|
):
|
||||||
result = patched_loader.download(
|
result = patched_loader.download(
|
||||||
@@ -728,7 +728,7 @@ class TestDecodeHtmlContent:
|
|||||||
|
|
||||||
def test_decodes_utf8_content(self):
|
def test_decodes_utf8_content(self):
|
||||||
"""Should correctly decode UTF-8 content."""
|
"""Should correctly decode UTF-8 content."""
|
||||||
from src.core.providers.aniworld_provider import _decode_html_content
|
from src.server.providers.aniworld_provider import _decode_html_content
|
||||||
html = '<html><body><h1>Titel mit Ümläüten</h1></body></html>'
|
html = '<html><body><h1>Titel mit Ümläüten</h1></body></html>'
|
||||||
content = html.encode('utf-8')
|
content = html.encode('utf-8')
|
||||||
result = _decode_html_content(content)
|
result = _decode_html_content(content)
|
||||||
@@ -736,7 +736,7 @@ class TestDecodeHtmlContent:
|
|||||||
|
|
||||||
def test_decodes_latin1_content(self):
|
def test_decodes_latin1_content(self):
|
||||||
"""Should correctly decode Latin-1 content when chardet detects it."""
|
"""Should correctly decode Latin-1 content when chardet detects it."""
|
||||||
from src.core.providers.aniworld_provider import _decode_html_content
|
from src.server.providers.aniworld_provider import _decode_html_content
|
||||||
# Longer content for more reliable chardet detection
|
# Longer content for more reliable chardet detection
|
||||||
html = '<html><body><h1>CafÉ and more text here</h1></body></html>'
|
html = '<html><body><h1>CafÉ and more text here</h1></body></html>'
|
||||||
content = html.encode('latin-1')
|
content = html.encode('latin-1')
|
||||||
@@ -745,13 +745,13 @@ class TestDecodeHtmlContent:
|
|||||||
|
|
||||||
def test_replaces_invalid_bytes(self):
|
def test_replaces_invalid_bytes(self):
|
||||||
"""Should replace invalid bytes with replacement character."""
|
"""Should replace invalid bytes with replacement character."""
|
||||||
from src.core.providers.aniworld_provider import _decode_html_content
|
from src.server.providers.aniworld_provider import _decode_html_content
|
||||||
content = b'\xff\xfe Invalid \x80\x81'
|
content = b'\xff\xfe Invalid \x80\x81'
|
||||||
result = _decode_html_content(content)
|
result = _decode_html_content(content)
|
||||||
assert isinstance(result, str)
|
assert isinstance(result, str)
|
||||||
|
|
||||||
def test_handles_empty_content(self):
|
def test_handles_empty_content(self):
|
||||||
"""Should handle empty content gracefully."""
|
"""Should handle empty content gracefully."""
|
||||||
from src.core.providers.aniworld_provider import _decode_html_content
|
from src.server.providers.aniworld_provider import _decode_html_content
|
||||||
result = _decode_html_content(b'')
|
result = _decode_html_content(b'')
|
||||||
assert result == ''
|
assert result == ''
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.providers.base_provider import Loader
|
from src.server.providers.base_provider import Loader
|
||||||
|
|
||||||
|
|
||||||
class TestLoaderAbstractInterface:
|
class TestLoaderAbstractInterface:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ functionality.
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from src.core.interfaces.callbacks import (
|
from src.server.interfaces.callbacks import (
|
||||||
CallbackManager,
|
CallbackManager,
|
||||||
CompletionCallback,
|
CompletionCallback,
|
||||||
CompletionContext,
|
CompletionContext,
|
||||||
|
|||||||
@@ -535,7 +535,7 @@ class TestAnimeServiceScanLock:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_anime_service_ignores_concurrent_rescan_requests(self):
|
async def test_anime_service_ignores_concurrent_rescan_requests(self):
|
||||||
"""Test that AnimeService ignores concurrent rescan requests."""
|
"""Test that AnimeService ignores concurrent rescan requests."""
|
||||||
from src.core.SeriesApp import SeriesApp
|
from src.server.SeriesApp import SeriesApp
|
||||||
from src.server.services.anime_service import AnimeService
|
from src.server.services.anime_service import AnimeService
|
||||||
|
|
||||||
# Mock database
|
# Mock database
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.error_handler import (
|
from src.server.error_handler import (
|
||||||
DownloadError,
|
DownloadError,
|
||||||
FileCorruptionDetector,
|
FileCorruptionDetector,
|
||||||
NetworkError,
|
NetworkError,
|
||||||
@@ -259,7 +259,7 @@ class TestWithErrorRecoveryDecorator:
|
|||||||
raise RuntimeError("oops")
|
raise RuntimeError("oops")
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
with patch("src.core.error_handler.logger") as mock_logger:
|
with patch("src.server.error_handler.logger") as mock_logger:
|
||||||
fail_once()
|
fail_once()
|
||||||
# Should have logged a warning with context
|
# Should have logged a warning with context
|
||||||
mock_logger.warning.assert_called()
|
mock_logger.warning.assert_called()
|
||||||
|
|||||||
@@ -430,7 +430,7 @@ class TestExponentialBackoff:
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from src.core.utils.image_downloader import ImageDownloader, ImageDownloadError
|
from src.server.utils.image_downloader import ImageDownloader, ImageDownloadError
|
||||||
|
|
||||||
downloader = ImageDownloader(max_retries=3, retry_delay=0.1)
|
downloader = ImageDownloader(max_retries=3, retry_delay=0.1)
|
||||||
|
|
||||||
|
|||||||
@@ -699,61 +699,58 @@ class TestErrorHandling:
|
|||||||
class TestRemoveEpisodeFromMissingList:
|
class TestRemoveEpisodeFromMissingList:
|
||||||
"""Test that completed downloads remove episodes from missing list."""
|
"""Test that completed downloads remove episodes from missing list."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def make_anime(key, name, folder, episode_dict):
|
||||||
|
"""Create mock AnimeSeries for testing."""
|
||||||
|
anime = MagicMock()
|
||||||
|
anime.key = key
|
||||||
|
anime.name = name
|
||||||
|
anime.site = "https://example.com"
|
||||||
|
anime.folder = folder
|
||||||
|
anime.episodeDict = episode_dict
|
||||||
|
return anime
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_remove_episode_from_memory(self, download_service):
|
async def test_remove_episode_from_memory(self, download_service):
|
||||||
"""Test _remove_episode_from_memory updates in-memory state."""
|
"""Test _remove_episode_from_memory updates in-memory state."""
|
||||||
from src.core.entities.series import Serie
|
|
||||||
|
|
||||||
# Set up in-memory series with missing episodes
|
# Set up in-memory series with missing episodes
|
||||||
serie = Serie(
|
anime = self.make_anime("test-series", "Test Series", "Test Series (2024)", {1: [1, 2, 3], 2: [1, 2]})
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series (2024)",
|
|
||||||
episodeDict={1: [1, 2, 3], 2: [1, 2]},
|
|
||||||
)
|
|
||||||
mock_app = MagicMock()
|
mock_app = MagicMock()
|
||||||
mock_app.list.keyDict = {"test-series": serie}
|
mock_app.list.keyDict = {"test-series": anime}
|
||||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
mock_app.list.GetMissingEpisode.return_value = [anime]
|
||||||
mock_app.series_list = [serie]
|
mock_app.series_list = [anime]
|
||||||
download_service._anime_service._app = mock_app
|
download_service._anime_service._app = mock_app
|
||||||
|
|
||||||
# Remove episode S01E02
|
# Remove episode S01E02
|
||||||
download_service._remove_episode_from_memory("test-series", 1, 2)
|
download_service._remove_episode_from_memory("test-series", 1, 2)
|
||||||
|
|
||||||
# Episode should be removed from episodeDict
|
# Episode should be removed from episodeDict
|
||||||
assert 2 not in serie.episodeDict[1]
|
assert 2 not in anime.episodeDict[1]
|
||||||
assert serie.episodeDict[1] == [1, 3]
|
assert anime.episodeDict[1] == [1, 3]
|
||||||
# Season 2 should be untouched
|
# Season 2 should be untouched
|
||||||
assert serie.episodeDict[2] == [1, 2]
|
assert anime.episodeDict[2] == [1, 2]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_remove_last_episode_in_season_removes_season(
|
async def test_remove_last_episode_in_season_removes_season(
|
||||||
self, download_service
|
self, download_service
|
||||||
):
|
):
|
||||||
"""Test removing the last episode in a season removes the season key."""
|
"""Test removing the last episode in a season removes the season key."""
|
||||||
from src.core.entities.series import Serie
|
|
||||||
|
|
||||||
serie = Serie(
|
anime = self.make_anime("test-series", "Test Series", "Test Series (2024)", {1: [5], 2: [1, 2]})
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series (2024)",
|
|
||||||
episodeDict={1: [5], 2: [1, 2]},
|
|
||||||
)
|
|
||||||
mock_app = MagicMock()
|
mock_app = MagicMock()
|
||||||
mock_app.list.keyDict = {"test-series": serie}
|
mock_app.list.keyDict = {"test-series": anime}
|
||||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
mock_app.list.GetMissingEpisode.return_value = [anime]
|
||||||
mock_app.series_list = [serie]
|
mock_app.series_list = [anime]
|
||||||
download_service._anime_service._app = mock_app
|
download_service._anime_service._app = mock_app
|
||||||
|
|
||||||
# Remove the only episode in season 1
|
# Remove the only episode in season 1
|
||||||
download_service._remove_episode_from_memory("test-series", 1, 5)
|
download_service._remove_episode_from_memory("test-series", 1, 5)
|
||||||
|
|
||||||
# Season 1 should be completely removed
|
# Season 1 should be completely removed
|
||||||
assert 1 not in serie.episodeDict
|
assert 1 not in anime.episodeDict
|
||||||
# Season 2 untouched
|
# Season 2 untouched
|
||||||
assert serie.episodeDict[2] == [1, 2]
|
assert anime.episodeDict[2] == [1, 2]
|
||||||
# GetMissingEpisode should have been called to refresh
|
# GetMissingEpisode should have been called to refresh
|
||||||
mock_app.list.GetMissingEpisode.assert_called()
|
mock_app.list.GetMissingEpisode.assert_called()
|
||||||
|
|
||||||
@@ -778,20 +775,12 @@ class TestRemoveEpisodeFromMissingList:
|
|||||||
"""Test _remove_episode_from_missing_list updates both DB and memory."""
|
"""Test _remove_episode_from_missing_list updates both DB and memory."""
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
|
||||||
|
|
||||||
# Set up in-memory state
|
# Set up in-memory state
|
||||||
serie = Serie(
|
anime = self.make_anime("test-series", "Test Series", "Test Series (2024)", {1: [1, 2, 3]})
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series (2024)",
|
|
||||||
episodeDict={1: [1, 2, 3]},
|
|
||||||
)
|
|
||||||
mock_app = MagicMock()
|
mock_app = MagicMock()
|
||||||
mock_app.list.keyDict = {"test-series": serie}
|
mock_app.list.keyDict = {"test-series": anime}
|
||||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
mock_app.list.GetMissingEpisode.return_value = [anime]
|
||||||
mock_app.series_list = [serie]
|
mock_app.series_list = [anime]
|
||||||
download_service._anime_service._app = mock_app
|
download_service._anime_service._app = mock_app
|
||||||
download_service._anime_service._cached_list_missing = MagicMock()
|
download_service._anime_service._cached_list_missing = MagicMock()
|
||||||
|
|
||||||
@@ -845,8 +834,8 @@ class TestRemoveEpisodeFromMissingList:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
# In-memory update happened
|
# In-memory update happened
|
||||||
assert 2 not in serie.episodeDict[1]
|
assert 2 not in anime.episodeDict[1]
|
||||||
assert serie.episodeDict[1] == [1, 3]
|
assert anime.episodeDict[1] == [1, 3]
|
||||||
# Cache was cleared
|
# Cache was cleared
|
||||||
download_service._anime_service._cached_list_missing.cache_clear.assert_called()
|
download_service._anime_service._cached_list_missing.cache_clear.assert_called()
|
||||||
# Broadcast was sent so frontend gets real-time update
|
# Broadcast was sent so frontend gets real-time update
|
||||||
@@ -862,25 +851,17 @@ class TestRemoveEpisodeFromMissingList:
|
|||||||
"""Test full flow: download success removes episode from missing list."""
|
"""Test full flow: download success removes episode from missing list."""
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
|
||||||
|
|
||||||
# Setup mock anime service to return success
|
# Setup mock anime service to return success
|
||||||
download_service._anime_service.download = AsyncMock(
|
download_service._anime_service.download = AsyncMock(
|
||||||
return_value=True
|
return_value=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up in-memory series state
|
# Set up in-memory series state
|
||||||
serie = Serie(
|
anime = self.make_anime("series-1", "Test Series", "series", {1: [1, 2, 3]})
|
||||||
key="series-1",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="series",
|
|
||||||
episodeDict={1: [1, 2, 3]},
|
|
||||||
)
|
|
||||||
mock_app = MagicMock()
|
mock_app = MagicMock()
|
||||||
mock_app.list.keyDict = {"series-1": serie}
|
mock_app.list.keyDict = {"series-1": anime}
|
||||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
mock_app.list.GetMissingEpisode.return_value = [anime]
|
||||||
mock_app.series_list = [serie]
|
mock_app.series_list = [anime]
|
||||||
download_service._anime_service._app = mock_app
|
download_service._anime_service._app = mock_app
|
||||||
download_service._anime_service._cached_list_missing = MagicMock()
|
download_service._anime_service._cached_list_missing = MagicMock()
|
||||||
|
|
||||||
@@ -936,8 +917,8 @@ class TestRemoveEpisodeFromMissingList:
|
|||||||
assert download_service._completed_items[0].status == DownloadStatus.COMPLETED
|
assert download_service._completed_items[0].status == DownloadStatus.COMPLETED
|
||||||
|
|
||||||
# Episode 2 should be removed from in-memory missing list
|
# Episode 2 should be removed from in-memory missing list
|
||||||
assert 2 not in serie.episodeDict[1]
|
assert 2 not in anime.episodeDict[1]
|
||||||
assert serie.episodeDict[1] == [1, 3]
|
assert anime.episodeDict[1] == [1, 3]
|
||||||
|
|
||||||
|
|
||||||
class TestQueueDeduplication:
|
class TestQueueDeduplication:
|
||||||
|
|||||||
@@ -7,16 +7,16 @@ from unittest.mock import MagicMock, Mock, PropertyMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.error_handler import (
|
from src.server.error_handler import (
|
||||||
DownloadError,
|
DownloadError,
|
||||||
NetworkError,
|
NetworkError,
|
||||||
NonRetryableError,
|
NonRetryableError,
|
||||||
RetryableError,
|
RetryableError,
|
||||||
)
|
)
|
||||||
from src.core.providers.base_provider import Loader
|
from src.server.providers.base_provider import Loader
|
||||||
|
|
||||||
# Import the class but we need a concrete subclass to test it
|
# Import the class but we need a concrete subclass to test it
|
||||||
from src.core.providers.enhanced_provider import EnhancedAniWorldLoader
|
from src.server.providers.enhanced_provider import EnhancedAniWorldLoader
|
||||||
|
|
||||||
|
|
||||||
class ConcreteEnhancedLoader(EnhancedAniWorldLoader):
|
class ConcreteEnhancedLoader(EnhancedAniWorldLoader):
|
||||||
@@ -50,9 +50,9 @@ class ConcreteEnhancedLoader(EnhancedAniWorldLoader):
|
|||||||
def enhanced_loader():
|
def enhanced_loader():
|
||||||
"""Create ConcreteEnhancedLoader with mocked externals."""
|
"""Create ConcreteEnhancedLoader with mocked externals."""
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.enhanced_provider.UserAgent"
|
"src.server.providers.enhanced_provider.UserAgent"
|
||||||
) as mock_ua, patch(
|
) as mock_ua, patch(
|
||||||
"src.core.providers.enhanced_provider.get_integrity_manager"
|
"src.server.providers.enhanced_provider.get_integrity_manager"
|
||||||
):
|
):
|
||||||
mock_ua.return_value.random = "MockAgent/1.0"
|
mock_ua.return_value.random = "MockAgent/1.0"
|
||||||
loader = ConcreteEnhancedLoader()
|
loader = ConcreteEnhancedLoader()
|
||||||
@@ -360,7 +360,7 @@ class TestDownloadStatistics:
|
|||||||
class TestEnhancedDownloadValidation:
|
class TestEnhancedDownloadValidation:
|
||||||
"""Test download input validation."""
|
"""Test download input validation."""
|
||||||
|
|
||||||
@patch("src.core.providers.enhanced_provider.get_integrity_manager")
|
@patch("src.server.providers.enhanced_provider.get_integrity_manager")
|
||||||
def test_download_missing_base_directory_raises(
|
def test_download_missing_base_directory_raises(
|
||||||
self, mock_integrity, enhanced_loader
|
self, mock_integrity, enhanced_loader
|
||||||
):
|
):
|
||||||
@@ -368,7 +368,7 @@ class TestEnhancedDownloadValidation:
|
|||||||
with pytest.raises((ValueError, DownloadError)):
|
with pytest.raises((ValueError, DownloadError)):
|
||||||
enhanced_loader.Download("", "folder", 1, 1, "key")
|
enhanced_loader.Download("", "folder", 1, 1, "key")
|
||||||
|
|
||||||
@patch("src.core.providers.enhanced_provider.get_integrity_manager")
|
@patch("src.server.providers.enhanced_provider.get_integrity_manager")
|
||||||
def test_download_missing_serie_folder_raises(
|
def test_download_missing_serie_folder_raises(
|
||||||
self, mock_integrity, enhanced_loader
|
self, mock_integrity, enhanced_loader
|
||||||
):
|
):
|
||||||
@@ -376,7 +376,7 @@ class TestEnhancedDownloadValidation:
|
|||||||
with pytest.raises((ValueError, DownloadError)):
|
with pytest.raises((ValueError, DownloadError)):
|
||||||
enhanced_loader.Download("/base", "", 1, 1, "key")
|
enhanced_loader.Download("/base", "", 1, 1, "key")
|
||||||
|
|
||||||
@patch("src.core.providers.enhanced_provider.get_integrity_manager")
|
@patch("src.server.providers.enhanced_provider.get_integrity_manager")
|
||||||
def test_download_negative_season_raises(
|
def test_download_negative_season_raises(
|
||||||
self, mock_integrity, enhanced_loader
|
self, mock_integrity, enhanced_loader
|
||||||
):
|
):
|
||||||
@@ -384,7 +384,7 @@ class TestEnhancedDownloadValidation:
|
|||||||
with pytest.raises((ValueError, DownloadError)):
|
with pytest.raises((ValueError, DownloadError)):
|
||||||
enhanced_loader.Download("/base", "folder", -1, 1, "key")
|
enhanced_loader.Download("/base", "folder", -1, 1, "key")
|
||||||
|
|
||||||
@patch("src.core.providers.enhanced_provider.get_integrity_manager")
|
@patch("src.server.providers.enhanced_provider.get_integrity_manager")
|
||||||
def test_download_negative_episode_raises(
|
def test_download_negative_episode_raises(
|
||||||
self, mock_integrity, enhanced_loader
|
self, mock_integrity, enhanced_loader
|
||||||
):
|
):
|
||||||
@@ -392,7 +392,7 @@ class TestEnhancedDownloadValidation:
|
|||||||
with pytest.raises((ValueError, DownloadError)):
|
with pytest.raises((ValueError, DownloadError)):
|
||||||
enhanced_loader.Download("/base", "folder", 1, -1, "key")
|
enhanced_loader.Download("/base", "folder", 1, -1, "key")
|
||||||
|
|
||||||
@patch("src.core.providers.enhanced_provider.get_integrity_manager")
|
@patch("src.server.providers.enhanced_provider.get_integrity_manager")
|
||||||
def test_download_increments_total_count(
|
def test_download_increments_total_count(
|
||||||
self, mock_integrity, enhanced_loader
|
self, mock_integrity, enhanced_loader
|
||||||
):
|
):
|
||||||
@@ -459,7 +459,7 @@ class TestFetchAnimeListWithRecovery:
|
|||||||
mock_response.text = json.dumps([{"title": "Naruto"}])
|
mock_response.text = json.dumps([{"title": "Naruto"}])
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||||
) as mock_rs:
|
) as mock_rs:
|
||||||
mock_rs.handle_network_failure.return_value = mock_response
|
mock_rs.handle_network_failure.return_value = mock_response
|
||||||
result = enhanced_loader._fetch_anime_list_with_recovery(
|
result = enhanced_loader._fetch_anime_list_with_recovery(
|
||||||
@@ -476,7 +476,7 @@ class TestFetchAnimeListWithRecovery:
|
|||||||
mock_response.status_code = 404
|
mock_response.status_code = 404
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||||
) as mock_rs:
|
) as mock_rs:
|
||||||
mock_rs.handle_network_failure.return_value = mock_response
|
mock_rs.handle_network_failure.return_value = mock_response
|
||||||
with pytest.raises(NonRetryableError, match="not found"):
|
with pytest.raises(NonRetryableError, match="not found"):
|
||||||
@@ -491,7 +491,7 @@ class TestFetchAnimeListWithRecovery:
|
|||||||
mock_response.status_code = 403
|
mock_response.status_code = 403
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||||
) as mock_rs:
|
) as mock_rs:
|
||||||
mock_rs.handle_network_failure.return_value = mock_response
|
mock_rs.handle_network_failure.return_value = mock_response
|
||||||
with pytest.raises(NonRetryableError, match="forbidden"):
|
with pytest.raises(NonRetryableError, match="forbidden"):
|
||||||
@@ -506,7 +506,7 @@ class TestFetchAnimeListWithRecovery:
|
|||||||
mock_response.status_code = 500
|
mock_response.status_code = 500
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||||
) as mock_rs:
|
) as mock_rs:
|
||||||
mock_rs.handle_network_failure.return_value = mock_response
|
mock_rs.handle_network_failure.return_value = mock_response
|
||||||
with pytest.raises(RetryableError, match="Server error"):
|
with pytest.raises(RetryableError, match="Server error"):
|
||||||
@@ -519,7 +519,7 @@ class TestFetchAnimeListWithRecovery:
|
|||||||
import requests as req
|
import requests as req
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||||
) as mock_rs:
|
) as mock_rs:
|
||||||
mock_rs.handle_network_failure.side_effect = (
|
mock_rs.handle_network_failure.side_effect = (
|
||||||
req.RequestException("timeout")
|
req.RequestException("timeout")
|
||||||
@@ -548,7 +548,7 @@ class TestGetKeyHTML:
|
|||||||
mock_response.ok = True
|
mock_response.ok = True
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||||
) as mock_rs:
|
) as mock_rs:
|
||||||
mock_rs.handle_network_failure.return_value = mock_response
|
mock_rs.handle_network_failure.return_value = mock_response
|
||||||
result = enhanced_loader._GetKeyHTML("new-key")
|
result = enhanced_loader._GetKeyHTML("new-key")
|
||||||
@@ -563,7 +563,7 @@ class TestGetKeyHTML:
|
|||||||
mock_response.status_code = 404
|
mock_response.status_code = 404
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||||
) as mock_rs:
|
) as mock_rs:
|
||||||
mock_rs.handle_network_failure.return_value = mock_response
|
mock_rs.handle_network_failure.return_value = mock_response
|
||||||
with pytest.raises(NonRetryableError, match="not found"):
|
with pytest.raises(NonRetryableError, match="not found"):
|
||||||
@@ -628,7 +628,7 @@ class TestGetEmbeddedLink:
|
|||||||
"_get_redirect_link",
|
"_get_redirect_link",
|
||||||
return_value=("https://aniworld.to/redirect/100", "VOE"),
|
return_value=("https://aniworld.to/redirect/100", "VOE"),
|
||||||
), patch(
|
), patch(
|
||||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||||
) as mock_rs:
|
) as mock_rs:
|
||||||
mock_rs.handle_network_failure.return_value = mock_response
|
mock_rs.handle_network_failure.return_value = mock_response
|
||||||
result = enhanced_loader._get_embeded_link(
|
result = enhanced_loader._get_embeded_link(
|
||||||
@@ -718,11 +718,11 @@ class TestDownloadWithRecovery:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||||
) as mock_rs, patch(
|
) as mock_rs, patch(
|
||||||
"src.core.providers.enhanced_provider.file_corruption_detector"
|
"src.server.providers.enhanced_provider.file_corruption_detector"
|
||||||
) as mock_fcd, patch(
|
) as mock_fcd, patch(
|
||||||
"src.core.providers.enhanced_provider.get_integrity_manager"
|
"src.server.providers.enhanced_provider.get_integrity_manager"
|
||||||
) as mock_im:
|
) as mock_im:
|
||||||
mock_rs.handle_network_failure.return_value = (
|
mock_rs.handle_network_failure.return_value = (
|
||||||
"https://direct.example.com/v.mp4",
|
"https://direct.example.com/v.mp4",
|
||||||
@@ -746,7 +746,7 @@ class TestDownloadWithRecovery:
|
|||||||
output_path = str(tmp_path / "output.mp4")
|
output_path = str(tmp_path / "output.mp4")
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||||
) as mock_rs:
|
) as mock_rs:
|
||||||
mock_rs.handle_network_failure.side_effect = Exception("fail")
|
mock_rs.handle_network_failure.side_effect = Exception("fail")
|
||||||
|
|
||||||
@@ -769,9 +769,9 @@ class TestDownloadWithRecovery:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||||
) as mock_rs, patch(
|
) as mock_rs, patch(
|
||||||
"src.core.providers.enhanced_provider.file_corruption_detector"
|
"src.server.providers.enhanced_provider.file_corruption_detector"
|
||||||
) as mock_fcd:
|
) as mock_fcd:
|
||||||
mock_rs.handle_network_failure.return_value = (
|
mock_rs.handle_network_failure.return_value = (
|
||||||
"https://direct.example.com/v.mp4",
|
"https://direct.example.com/v.mp4",
|
||||||
@@ -816,7 +816,7 @@ class TestGetSeasonEpisodeCount:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||||
) as mock_rs:
|
) as mock_rs:
|
||||||
mock_rs.handle_network_failure.side_effect = responses
|
mock_rs.handle_network_failure.side_effect = responses
|
||||||
result = enhanced_loader.get_season_episode_count("test")
|
result = enhanced_loader.get_season_episode_count("test")
|
||||||
@@ -828,7 +828,7 @@ class TestGetSeasonEpisodeCount:
|
|||||||
base_html = b"<html><body>No seasons</body></html>"
|
base_html = b"<html><body>No seasons</body></html>"
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||||
) as mock_rs:
|
) as mock_rs:
|
||||||
mock_rs.handle_network_failure.return_value = MagicMock(
|
mock_rs.handle_network_failure.return_value = MagicMock(
|
||||||
content=base_html
|
content=base_html
|
||||||
@@ -844,7 +844,7 @@ class TestPerformYtdlDownload:
|
|||||||
def test_success(self, enhanced_loader):
|
def test_success(self, enhanced_loader):
|
||||||
"""Should return True on successful download."""
|
"""Should return True on successful download."""
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.enhanced_provider.YoutubeDL"
|
"src.server.providers.enhanced_provider.YoutubeDL"
|
||||||
) as MockYDL:
|
) as MockYDL:
|
||||||
mock_ydl = MagicMock()
|
mock_ydl = MagicMock()
|
||||||
MockYDL.return_value.__enter__ = MagicMock(return_value=mock_ydl)
|
MockYDL.return_value.__enter__ = MagicMock(return_value=mock_ydl)
|
||||||
@@ -858,7 +858,7 @@ class TestPerformYtdlDownload:
|
|||||||
def test_failure_raises_download_error(self, enhanced_loader):
|
def test_failure_raises_download_error(self, enhanced_loader):
|
||||||
"""yt-dlp failure should raise DownloadError."""
|
"""yt-dlp failure should raise DownloadError."""
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.enhanced_provider.YoutubeDL"
|
"src.server.providers.enhanced_provider.YoutubeDL"
|
||||||
) as MockYDL:
|
) as MockYDL:
|
||||||
mock_ydl = MagicMock()
|
mock_ydl = MagicMock()
|
||||||
mock_ydl.download.side_effect = Exception("yt-dlp crash")
|
mock_ydl.download.side_effect = Exception("yt-dlp crash")
|
||||||
@@ -873,7 +873,7 @@ class TestPerformYtdlDownload:
|
|||||||
class TestDownloadFlow:
|
class TestDownloadFlow:
|
||||||
"""Test full Download method flow."""
|
"""Test full Download method flow."""
|
||||||
|
|
||||||
@patch("src.core.providers.enhanced_provider.get_integrity_manager")
|
@patch("src.server.providers.enhanced_provider.get_integrity_manager")
|
||||||
def test_existing_valid_file_returns_true(
|
def test_existing_valid_file_returns_true(
|
||||||
self, mock_integrity, enhanced_loader, tmp_path
|
self, mock_integrity, enhanced_loader, tmp_path
|
||||||
):
|
):
|
||||||
@@ -889,7 +889,7 @@ class TestDownloadFlow:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.enhanced_provider.file_corruption_detector"
|
"src.server.providers.enhanced_provider.file_corruption_detector"
|
||||||
) as mock_fcd:
|
) as mock_fcd:
|
||||||
mock_fcd.is_valid_video_file.return_value = True
|
mock_fcd.is_valid_video_file.return_value = True
|
||||||
mock_integrity.return_value.has_checksum.return_value = False
|
mock_integrity.return_value.has_checksum.return_value = False
|
||||||
@@ -901,7 +901,7 @@ class TestDownloadFlow:
|
|||||||
assert result is True
|
assert result is True
|
||||||
assert enhanced_loader.download_stats["successful_downloads"] == 1
|
assert enhanced_loader.download_stats["successful_downloads"] == 1
|
||||||
|
|
||||||
@patch("src.core.providers.enhanced_provider.get_integrity_manager")
|
@patch("src.server.providers.enhanced_provider.get_integrity_manager")
|
||||||
def test_missing_key_raises_value_error(
|
def test_missing_key_raises_value_error(
|
||||||
self, mock_integrity, enhanced_loader, tmp_path
|
self, mock_integrity, enhanced_loader, tmp_path
|
||||||
):
|
):
|
||||||
@@ -915,7 +915,7 @@ class TestAniworldLoaderCompat:
|
|||||||
|
|
||||||
def test_inherits_from_enhanced(self):
|
def test_inherits_from_enhanced(self):
|
||||||
"""AniworldLoader should extend EnhancedAniWorldLoader."""
|
"""AniworldLoader should extend EnhancedAniWorldLoader."""
|
||||||
from src.core.providers.enhanced_provider import AniworldLoader
|
from src.server.providers.enhanced_provider import AniworldLoader
|
||||||
|
|
||||||
assert issubclass(AniworldLoader, EnhancedAniWorldLoader)
|
assert issubclass(AniworldLoader, EnhancedAniWorldLoader)
|
||||||
|
|
||||||
@@ -936,11 +936,11 @@ class TestFfmpegHlsOptions:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||||
) as mock_rs, patch(
|
) as mock_rs, patch(
|
||||||
"src.core.providers.enhanced_provider.file_corruption_detector"
|
"src.server.providers.enhanced_provider.file_corruption_detector"
|
||||||
) as mock_fcd, patch(
|
) as mock_fcd, patch(
|
||||||
"src.core.providers.enhanced_provider.get_integrity_manager"
|
"src.server.providers.enhanced_provider.get_integrity_manager"
|
||||||
) as mock_im:
|
) as mock_im:
|
||||||
mock_rs.handle_network_failure.return_value = (
|
mock_rs.handle_network_failure.return_value = (
|
||||||
"https://direct.example.com/v.mp4",
|
"https://direct.example.com/v.mp4",
|
||||||
@@ -969,7 +969,7 @@ class TestHlsUrlDetection:
|
|||||||
def test_voe_hls_pattern_extracts_hls_url(self):
|
def test_voe_hls_pattern_extracts_hls_url(self):
|
||||||
"""HLS_PATTERN should extract HLS URL from VOE embedded player HTML."""
|
"""HLS_PATTERN should extract HLS URL from VOE embedded player HTML."""
|
||||||
import re
|
import re
|
||||||
from src.core.providers.streaming.voe import HLS_PATTERN
|
from src.server.providers.streaming.voe import HLS_PATTERN
|
||||||
|
|
||||||
html_with_hls = """
|
html_with_hls = """
|
||||||
var playerConfig = {
|
var playerConfig = {
|
||||||
@@ -984,7 +984,7 @@ class TestHlsUrlDetection:
|
|||||||
def test_voe_hls_pattern_returns_none_when_no_hls(self):
|
def test_voe_hls_pattern_returns_none_when_no_hls(self):
|
||||||
"""HLS_PATTERN should return None when no HLS URL in HTML."""
|
"""HLS_PATTERN should return None when no HLS URL in HTML."""
|
||||||
import re
|
import re
|
||||||
from src.core.providers.streaming.voe import HLS_PATTERN
|
from src.server.providers.streaming.voe import HLS_PATTERN
|
||||||
|
|
||||||
html_no_hls = """
|
html_no_hls = """
|
||||||
var playerConfig = {
|
var playerConfig = {
|
||||||
@@ -997,7 +997,7 @@ class TestHlsUrlDetection:
|
|||||||
def test_hls_url_detection_in_provider_flow(self, enhanced_loader, tmp_path):
|
def test_hls_url_detection_in_provider_flow(self, enhanced_loader, tmp_path):
|
||||||
"""Provider should detect and handle HLS URLs from VOE extractor."""
|
"""Provider should detect and handle HLS URLs from VOE extractor."""
|
||||||
import re
|
import re
|
||||||
from src.core.providers.streaming.voe import HLS_PATTERN
|
from src.server.providers.streaming.voe import HLS_PATTERN
|
||||||
|
|
||||||
# Simulate VOE returning an HLS URL (base64 encoded .m3u8)
|
# Simulate VOE returning an HLS URL (base64 encoded .m3u8)
|
||||||
encoded_hls = "aHR0cHM6Ly9leGFtcGxlLmNvbS92aWRlby5tM3U4"
|
encoded_hls = "aHR0cHM6Ly9leGFtcGxlLmNvbS92aWRlby5tM3U4"
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ class TestSerieScannerIgnorePatterns:
|
|||||||
|
|
||||||
def test_scanner_skips_ignored_folders(self, tmp_path):
|
def test_scanner_skips_ignored_folders(self, tmp_path):
|
||||||
"""Test scanner skips folders matching ignore patterns."""
|
"""Test scanner skips folders matching ignore patterns."""
|
||||||
from src.core.SerieScanner import SerieScanner
|
from src.server.SerieScanner import SerieScanner
|
||||||
from src.core.providers.aniworld_provider import AniworldLoader
|
from src.server.providers.aniworld_provider import AniworldLoader
|
||||||
|
|
||||||
# Create test folders
|
# Create test folders
|
||||||
ignored_folder = tmp_path / "The Last of Us"
|
ignored_folder = tmp_path / "The Last of Us"
|
||||||
@@ -131,8 +131,8 @@ class TestSerieScannerIgnorePatterns:
|
|||||||
|
|
||||||
def test_scanner_normal_folders_not_ignored(self, tmp_path):
|
def test_scanner_normal_folders_not_ignored(self, tmp_path):
|
||||||
"""Test normal folders are not skipped."""
|
"""Test normal folders are not skipped."""
|
||||||
from src.core.SerieScanner import SerieScanner
|
from src.server.SerieScanner import SerieScanner
|
||||||
from src.core.providers.aniworld_provider import AniworldLoader
|
from src.server.providers.aniworld_provider import AniworldLoader
|
||||||
|
|
||||||
folder1 = tmp_path / "Attack on Titan"
|
folder1 = tmp_path / "Attack on Titan"
|
||||||
folder1.mkdir()
|
folder1.mkdir()
|
||||||
@@ -153,8 +153,8 @@ class TestSerieScannerIgnorePatterns:
|
|||||||
|
|
||||||
def test_scanner_respects_default_ignore_patterns(self, tmp_path):
|
def test_scanner_respects_default_ignore_patterns(self, tmp_path):
|
||||||
"""Test scanner respects default ignore patterns."""
|
"""Test scanner respects default ignore patterns."""
|
||||||
from src.core.SerieScanner import SerieScanner
|
from src.server.SerieScanner import SerieScanner
|
||||||
from src.core.providers.aniworld_provider import AniworldLoader
|
from src.server.providers.aniworld_provider import AniworldLoader
|
||||||
|
|
||||||
# Create folder matching default ignore pattern (Chernobyl)
|
# Create folder matching default ignore pattern (Chernobyl)
|
||||||
ignored_folder = tmp_path / "Chernobyl Complete Series"
|
ignored_folder = tmp_path / "Chernobyl Complete Series"
|
||||||
@@ -175,48 +175,20 @@ class TestSerieScannerIgnorePatterns:
|
|||||||
|
|
||||||
|
|
||||||
class TestSerieListIgnorePatterns:
|
class TestSerieListIgnorePatterns:
|
||||||
"""Test SerieList respects ignore patterns."""
|
"""Test SerieList ignore pattern filtering - DB mode tests removed.
|
||||||
|
|
||||||
|
Note: File-based load_series() has been removed from SerieList.
|
||||||
|
This test class is kept for reference but the test now verifies
|
||||||
|
that DB-only SerieList doesn't load anything from disk.
|
||||||
|
"""
|
||||||
|
|
||||||
def test_load_series_skips_ignored_folders(self, tmp_path):
|
def test_serie_list_db_mode_creates_empty_list(self, tmp_path):
|
||||||
"""Test load_series skips folders matching ignore patterns."""
|
"""Test that DB-only SerieList creates empty keyDict on init."""
|
||||||
from src.core.entities.SerieList import SerieList
|
from src.server.database.SerieList import SerieList
|
||||||
from src.core.entities.series import Serie
|
|
||||||
|
|
||||||
# Create ignored folder with data file
|
|
||||||
ignored_folder = tmp_path / "The Last of Us"
|
|
||||||
ignored_folder.mkdir()
|
|
||||||
ignored_data = ignored_folder / "data"
|
|
||||||
|
|
||||||
ignored_serie = Serie(
|
# DB-only SerieList doesn't auto-load from disk
|
||||||
key="the-last-of-us",
|
|
||||||
name="The Last of Us",
|
|
||||||
site="https://aniworld.to/anime/stream/the-last-of-us",
|
|
||||||
folder="The Last of Us",
|
|
||||||
episodeDict={1: [1, 2, 3]}
|
|
||||||
)
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
ignored_serie.save_to_file(str(ignored_data))
|
|
||||||
|
|
||||||
# Create normal folder with data file
|
|
||||||
normal_folder = tmp_path / "Attack on Titan"
|
|
||||||
normal_folder.mkdir()
|
|
||||||
normal_data = normal_folder / "data"
|
|
||||||
|
|
||||||
normal_serie = Serie(
|
|
||||||
key="attack-on-titan",
|
|
||||||
name="Attack on Titan",
|
|
||||||
site="https://aniworld.to/anime/stream/attack-on-titan",
|
|
||||||
folder="Attack on Titan",
|
|
||||||
episodeDict={1: [1, 2]}
|
|
||||||
)
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
normal_serie.save_to_file(str(normal_data))
|
|
||||||
|
|
||||||
# Load series
|
|
||||||
serie_list = SerieList(str(tmp_path))
|
serie_list = SerieList(str(tmp_path))
|
||||||
|
|
||||||
# Verify ignored folder was skipped
|
# keyDict should be empty (no auto-loading)
|
||||||
assert serie_list.contains("attack-on-titan") is True
|
assert len(serie_list.keyDict) == 0
|
||||||
assert serie_list.contains("the-last-of-us") is False
|
assert not serie_list.contains("attack-on-titan")
|
||||||
@@ -8,7 +8,7 @@ import aiohttp
|
|||||||
import pytest
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from src.core.utils.image_downloader import ImageDownloader, ImageDownloadError
|
from src.server.utils.image_downloader import ImageDownloader, ImageDownloadError
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Unit tests for key generation utilities.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from src.core.utils.key_utils import (
|
from src.server.utils.key_utils import (
|
||||||
generate_key_from_folder,
|
generate_key_from_folder,
|
||||||
normalize_key,
|
normalize_key,
|
||||||
is_valid_key,
|
is_valid_key,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.providers.base_provider import Loader
|
from src.server.providers.base_provider import Loader
|
||||||
from src.core.providers.monitored_provider import (
|
from src.server.providers.monitored_provider import (
|
||||||
MonitoredProviderWrapper,
|
MonitoredProviderWrapper,
|
||||||
wrap_provider,
|
wrap_provider,
|
||||||
)
|
)
|
||||||
@@ -84,7 +84,7 @@ def mock_health_monitor():
|
|||||||
def monitored_wrapper(mock_provider, mock_health_monitor):
|
def monitored_wrapper(mock_provider, mock_health_monitor):
|
||||||
"""Create a monitored wrapper with mock health monitor."""
|
"""Create a monitored wrapper with mock health monitor."""
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.monitored_provider.get_health_monitor",
|
"src.server.providers.monitored_provider.get_health_monitor",
|
||||||
return_value=mock_health_monitor,
|
return_value=mock_health_monitor,
|
||||||
):
|
):
|
||||||
wrapper = ConcreteMonitoredWrapper(
|
wrapper = ConcreteMonitoredWrapper(
|
||||||
@@ -100,7 +100,7 @@ class TestMonitoredProviderWrapperInit:
|
|||||||
def test_wrapper_stores_provider(self, mock_provider):
|
def test_wrapper_stores_provider(self, mock_provider):
|
||||||
"""Wrapper should store the wrapped provider."""
|
"""Wrapper should store the wrapped provider."""
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.monitored_provider.get_health_monitor"
|
"src.server.providers.monitored_provider.get_health_monitor"
|
||||||
):
|
):
|
||||||
wrapper = ConcreteMonitoredWrapper(mock_provider)
|
wrapper = ConcreteMonitoredWrapper(mock_provider)
|
||||||
assert wrapper._provider is mock_provider
|
assert wrapper._provider is mock_provider
|
||||||
@@ -108,7 +108,7 @@ class TestMonitoredProviderWrapperInit:
|
|||||||
def test_wrapper_monitoring_enabled_by_default(self, mock_provider):
|
def test_wrapper_monitoring_enabled_by_default(self, mock_provider):
|
||||||
"""Monitoring should be enabled by default."""
|
"""Monitoring should be enabled by default."""
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.monitored_provider.get_health_monitor"
|
"src.server.providers.monitored_provider.get_health_monitor"
|
||||||
):
|
):
|
||||||
wrapper = ConcreteMonitoredWrapper(mock_provider)
|
wrapper = ConcreteMonitoredWrapper(mock_provider)
|
||||||
assert wrapper._enable_monitoring is True
|
assert wrapper._enable_monitoring is True
|
||||||
@@ -320,7 +320,7 @@ class TestWrapProviderFunction:
|
|||||||
def test_wrap_creates_monitored_wrapper(self, mock_provider):
|
def test_wrap_creates_monitored_wrapper(self, mock_provider):
|
||||||
"""wrap_provider should return MonitoredProviderWrapper."""
|
"""wrap_provider should return MonitoredProviderWrapper."""
|
||||||
with patch(
|
with patch(
|
||||||
"src.core.providers.monitored_provider.get_health_monitor"
|
"src.server.providers.monitored_provider.get_health_monitor"
|
||||||
):
|
):
|
||||||
# wrap_provider returns MonitoredProviderWrapper which can't be
|
# wrap_provider returns MonitoredProviderWrapper which can't be
|
||||||
# instantiated directly due to missing abstract methods.
|
# instantiated directly due to missing abstract methods.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.providers.config_manager import (
|
from src.server.providers.config_manager import (
|
||||||
ProviderConfigManager,
|
ProviderConfigManager,
|
||||||
ProviderSettings,
|
ProviderSettings,
|
||||||
get_config_manager,
|
get_config_manager,
|
||||||
@@ -407,7 +407,7 @@ class TestGetConfigManagerSingleton:
|
|||||||
def test_returns_instance(self):
|
def test_returns_instance(self):
|
||||||
"""get_config_manager should return a ProviderConfigManager."""
|
"""get_config_manager should return a ProviderConfigManager."""
|
||||||
# Reset global state for test
|
# Reset global state for test
|
||||||
import src.core.providers.config_manager as cm
|
import src.server.providers.config_manager as cm
|
||||||
cm._config_manager = None
|
cm._config_manager = None
|
||||||
|
|
||||||
manager = get_config_manager()
|
manager = get_config_manager()
|
||||||
@@ -418,7 +418,7 @@ class TestGetConfigManagerSingleton:
|
|||||||
|
|
||||||
def test_returns_same_instance(self):
|
def test_returns_same_instance(self):
|
||||||
"""get_config_manager should return same instance on repeated calls."""
|
"""get_config_manager should return same instance on repeated calls."""
|
||||||
import src.core.providers.config_manager as cm
|
import src.server.providers.config_manager as cm
|
||||||
cm._config_manager = None
|
cm._config_manager = None
|
||||||
|
|
||||||
first = get_config_manager()
|
first = get_config_manager()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.providers.aniworld_provider import AniworldLoader
|
from src.server.providers.aniworld_provider import AniworldLoader
|
||||||
|
|
||||||
|
|
||||||
def _mock_response(content: str) -> MagicMock:
|
def _mock_response(content: str) -> MagicMock:
|
||||||
@@ -202,7 +202,7 @@ class TestEmptyResponses:
|
|||||||
"""No season meta tag returns empty dict or zero."""
|
"""No season meta tag returns empty dict or zero."""
|
||||||
loader = _loader()
|
loader = _loader()
|
||||||
html_str = "<html><head></head><body></body></html>"
|
html_str = "<html><head></head><body></body></html>"
|
||||||
with patch("src.core.providers.aniworld_provider.requests.get", return_value=_mock_response(html_str)):
|
with patch("src.server.providers.aniworld_provider.requests.get", return_value=_mock_response(html_str)):
|
||||||
result = loader.get_season_episode_count("some-anime")
|
result = loader.get_season_episode_count("some-anime")
|
||||||
# Either empty dict or {1: 0} depending on implementation
|
# Either empty dict or {1: 0} depending on implementation
|
||||||
assert isinstance(result, (dict, int))
|
assert isinstance(result, (dict, int))
|
||||||
|
|||||||
@@ -4,21 +4,21 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.providers.base_provider import Loader
|
from src.server.providers.base_provider import Loader
|
||||||
from src.core.providers.provider_factory import Loaders
|
from src.server.providers.provider_factory import Loaders
|
||||||
|
|
||||||
|
|
||||||
class TestLoadersInit:
|
class TestLoadersInit:
|
||||||
"""Test Loaders factory initialization."""
|
"""Test Loaders factory initialization."""
|
||||||
|
|
||||||
@patch("src.core.providers.provider_factory.AniworldLoader")
|
@patch("src.server.providers.provider_factory.AniworldLoader")
|
||||||
def test_factory_initializes_with_default_providers(self, mock_aniworld):
|
def test_factory_initializes_with_default_providers(self, mock_aniworld):
|
||||||
"""Factory should register aniworld.to provider by default."""
|
"""Factory should register aniworld.to provider by default."""
|
||||||
mock_aniworld.return_value = MagicMock(spec=Loader)
|
mock_aniworld.return_value = MagicMock(spec=Loader)
|
||||||
factory = Loaders()
|
factory = Loaders()
|
||||||
assert "aniworld.to" in factory.dict
|
assert "aniworld.to" in factory.dict
|
||||||
|
|
||||||
@patch("src.core.providers.provider_factory.AniworldLoader")
|
@patch("src.server.providers.provider_factory.AniworldLoader")
|
||||||
def test_factory_dict_contains_loader_instances(self, mock_aniworld):
|
def test_factory_dict_contains_loader_instances(self, mock_aniworld):
|
||||||
"""Factory dict values should be Loader instances."""
|
"""Factory dict values should be Loader instances."""
|
||||||
mock_instance = MagicMock(spec=Loader)
|
mock_instance = MagicMock(spec=Loader)
|
||||||
@@ -31,7 +31,7 @@ class TestLoadersInit:
|
|||||||
class TestLoadersGetLoader:
|
class TestLoadersGetLoader:
|
||||||
"""Test GetLoader method."""
|
"""Test GetLoader method."""
|
||||||
|
|
||||||
@patch("src.core.providers.provider_factory.AniworldLoader")
|
@patch("src.server.providers.provider_factory.AniworldLoader")
|
||||||
def test_get_loader_returns_registered_provider(self, mock_aniworld):
|
def test_get_loader_returns_registered_provider(self, mock_aniworld):
|
||||||
"""GetLoader should return provider for known key."""
|
"""GetLoader should return provider for known key."""
|
||||||
mock_instance = MagicMock(spec=Loader)
|
mock_instance = MagicMock(spec=Loader)
|
||||||
@@ -40,7 +40,7 @@ class TestLoadersGetLoader:
|
|||||||
loader = factory.GetLoader("aniworld.to")
|
loader = factory.GetLoader("aniworld.to")
|
||||||
assert loader is mock_instance
|
assert loader is mock_instance
|
||||||
|
|
||||||
@patch("src.core.providers.provider_factory.AniworldLoader")
|
@patch("src.server.providers.provider_factory.AniworldLoader")
|
||||||
def test_get_loader_raises_key_error_for_unknown(self, mock_aniworld):
|
def test_get_loader_raises_key_error_for_unknown(self, mock_aniworld):
|
||||||
"""GetLoader should raise KeyError for unknown provider key."""
|
"""GetLoader should raise KeyError for unknown provider key."""
|
||||||
mock_aniworld.return_value = MagicMock(spec=Loader)
|
mock_aniworld.return_value = MagicMock(spec=Loader)
|
||||||
@@ -48,7 +48,7 @@ class TestLoadersGetLoader:
|
|||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
factory.GetLoader("nonexistent.provider")
|
factory.GetLoader("nonexistent.provider")
|
||||||
|
|
||||||
@patch("src.core.providers.provider_factory.AniworldLoader")
|
@patch("src.server.providers.provider_factory.AniworldLoader")
|
||||||
def test_get_loader_returns_same_instance(self, mock_aniworld):
|
def test_get_loader_returns_same_instance(self, mock_aniworld):
|
||||||
"""GetLoader should return same instance on repeated calls."""
|
"""GetLoader should return same instance on repeated calls."""
|
||||||
mock_instance = MagicMock(spec=Loader)
|
mock_instance = MagicMock(spec=Loader)
|
||||||
@@ -58,7 +58,7 @@ class TestLoadersGetLoader:
|
|||||||
second = factory.GetLoader("aniworld.to")
|
second = factory.GetLoader("aniworld.to")
|
||||||
assert first is second
|
assert first is second
|
||||||
|
|
||||||
@patch("src.core.providers.provider_factory.AniworldLoader")
|
@patch("src.server.providers.provider_factory.AniworldLoader")
|
||||||
def test_get_loader_empty_key(self, mock_aniworld):
|
def test_get_loader_empty_key(self, mock_aniworld):
|
||||||
"""GetLoader should raise KeyError for empty string key."""
|
"""GetLoader should raise KeyError for empty string key."""
|
||||||
mock_aniworld.return_value = MagicMock(spec=Loader)
|
mock_aniworld.return_value = MagicMock(spec=Loader)
|
||||||
@@ -70,14 +70,14 @@ class TestLoadersGetLoader:
|
|||||||
class TestLoadersProviderRegistry:
|
class TestLoadersProviderRegistry:
|
||||||
"""Test the provider registry within the factory."""
|
"""Test the provider registry within the factory."""
|
||||||
|
|
||||||
@patch("src.core.providers.provider_factory.AniworldLoader")
|
@patch("src.server.providers.provider_factory.AniworldLoader")
|
||||||
def test_registry_size(self, mock_aniworld):
|
def test_registry_size(self, mock_aniworld):
|
||||||
"""Factory should have exactly one default provider."""
|
"""Factory should have exactly one default provider."""
|
||||||
mock_aniworld.return_value = MagicMock(spec=Loader)
|
mock_aniworld.return_value = MagicMock(spec=Loader)
|
||||||
factory = Loaders()
|
factory = Loaders()
|
||||||
assert len(factory.dict) == 1
|
assert len(factory.dict) == 1
|
||||||
|
|
||||||
@patch("src.core.providers.provider_factory.AniworldLoader")
|
@patch("src.server.providers.provider_factory.AniworldLoader")
|
||||||
def test_can_add_custom_provider(self, mock_aniworld):
|
def test_can_add_custom_provider(self, mock_aniworld):
|
||||||
"""Custom providers can be added to the factory registry."""
|
"""Custom providers can be added to the factory registry."""
|
||||||
mock_aniworld.return_value = MagicMock(spec=Loader)
|
mock_aniworld.return_value = MagicMock(spec=Loader)
|
||||||
@@ -86,7 +86,7 @@ class TestLoadersProviderRegistry:
|
|||||||
factory.dict["custom.provider"] = custom_provider
|
factory.dict["custom.provider"] = custom_provider
|
||||||
assert factory.GetLoader("custom.provider") is custom_provider
|
assert factory.GetLoader("custom.provider") is custom_provider
|
||||||
|
|
||||||
@patch("src.core.providers.provider_factory.AniworldLoader")
|
@patch("src.server.providers.provider_factory.AniworldLoader")
|
||||||
def test_can_override_existing_provider(self, mock_aniworld):
|
def test_can_override_existing_provider(self, mock_aniworld):
|
||||||
"""Existing providers can be overridden in the registry."""
|
"""Existing providers can be overridden in the registry."""
|
||||||
mock_aniworld.return_value = MagicMock(spec=Loader)
|
mock_aniworld.return_value = MagicMock(spec=Loader)
|
||||||
@@ -95,7 +95,7 @@ class TestLoadersProviderRegistry:
|
|||||||
factory.dict["aniworld.to"] = new_provider
|
factory.dict["aniworld.to"] = new_provider
|
||||||
assert factory.GetLoader("aniworld.to") is new_provider
|
assert factory.GetLoader("aniworld.to") is new_provider
|
||||||
|
|
||||||
@patch("src.core.providers.provider_factory.AniworldLoader")
|
@patch("src.server.providers.provider_factory.AniworldLoader")
|
||||||
def test_multiple_factories_are_independent(self, mock_aniworld):
|
def test_multiple_factories_are_independent(self, mock_aniworld):
|
||||||
"""Multiple factory instances should have independent registries."""
|
"""Multiple factory instances should have independent registries."""
|
||||||
mock_aniworld.return_value = MagicMock(spec=Loader)
|
mock_aniworld.return_value = MagicMock(spec=Loader)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Unit tests for provider failover system."""
|
"""Unit tests for provider failover system."""
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.providers.failover import (
|
from src.server.providers.failover import (
|
||||||
ProviderFailover,
|
ProviderFailover,
|
||||||
configure_failover,
|
configure_failover,
|
||||||
get_failover,
|
get_failover,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.providers.health_monitor import (
|
from src.server.providers.health_monitor import (
|
||||||
ProviderHealthMetrics,
|
ProviderHealthMetrics,
|
||||||
ProviderHealthMonitor,
|
ProviderHealthMonitor,
|
||||||
RequestMetric,
|
RequestMetric,
|
||||||
|
|||||||
@@ -1,749 +0,0 @@
|
|||||||
"""
|
|
||||||
Unit tests for Serie class to verify key validation and identifier usage.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
|
||||||
|
|
||||||
|
|
||||||
class TestSerieValidation:
|
|
||||||
"""Test Serie class validation logic."""
|
|
||||||
|
|
||||||
def test_serie_creation_with_valid_key(self):
|
|
||||||
"""Test creating Serie with valid key."""
|
|
||||||
serie = Serie(
|
|
||||||
key="attack-on-titan",
|
|
||||||
name="Attack on Titan",
|
|
||||||
site="https://aniworld.to/anime/stream/attack-on-titan",
|
|
||||||
folder="Attack on Titan (2013)",
|
|
||||||
episodeDict={1: [1, 2, 3], 2: [1, 2]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.key == "attack-on-titan"
|
|
||||||
assert serie.name == "Attack on Titan"
|
|
||||||
assert serie.site == "https://aniworld.to/anime/stream/attack-on-titan"
|
|
||||||
assert serie.folder == "Attack on Titan (2013)"
|
|
||||||
assert serie.episodeDict == {1: [1, 2, 3], 2: [1, 2]}
|
|
||||||
|
|
||||||
def test_serie_creation_with_empty_key_raises_error(self):
|
|
||||||
"""Test that creating Serie with empty key raises ValueError."""
|
|
||||||
with pytest.raises(ValueError, match="key cannot be None or empty"):
|
|
||||||
Serie(
|
|
||||||
key="",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Folder",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_serie_creation_with_whitespace_key_raises_error(self):
|
|
||||||
"""Test that creating Serie with whitespace-only key raises error."""
|
|
||||||
with pytest.raises(ValueError, match="key cannot be None or empty"):
|
|
||||||
Serie(
|
|
||||||
key=" ",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Folder",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_serie_key_is_stripped(self):
|
|
||||||
"""Test that Serie key is stripped of whitespace."""
|
|
||||||
serie = Serie(
|
|
||||||
key=" attack-on-titan ",
|
|
||||||
name="Attack on Titan",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Attack on Titan (2013)",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.key == "attack-on-titan"
|
|
||||||
|
|
||||||
def test_serie_key_setter_with_valid_value(self):
|
|
||||||
"""Test setting key property with valid value."""
|
|
||||||
serie = Serie(
|
|
||||||
key="initial-key",
|
|
||||||
name="Test",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Folder",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
serie.key = "new-key"
|
|
||||||
assert serie.key == "new-key"
|
|
||||||
|
|
||||||
def test_serie_key_setter_with_empty_value_raises_error(self):
|
|
||||||
"""Test that setting key to empty string raises ValueError."""
|
|
||||||
serie = Serie(
|
|
||||||
key="initial-key",
|
|
||||||
name="Test",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Folder",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="key cannot be None or empty"):
|
|
||||||
serie.key = ""
|
|
||||||
|
|
||||||
def test_serie_key_setter_with_whitespace_raises_error(self):
|
|
||||||
"""Test that setting key to whitespace raises ValueError."""
|
|
||||||
serie = Serie(
|
|
||||||
key="initial-key",
|
|
||||||
name="Test",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Folder",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="key cannot be None or empty"):
|
|
||||||
serie.key = " "
|
|
||||||
|
|
||||||
def test_serie_key_setter_strips_whitespace(self):
|
|
||||||
"""Test that key setter strips whitespace."""
|
|
||||||
serie = Serie(
|
|
||||||
key="initial-key",
|
|
||||||
name="Test",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Folder",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
serie.key = " new-key "
|
|
||||||
assert serie.key == "new-key"
|
|
||||||
|
|
||||||
|
|
||||||
class TestSerieProperties:
|
|
||||||
"""Test Serie class properties and methods."""
|
|
||||||
|
|
||||||
def test_serie_str_representation(self):
|
|
||||||
"""Test string representation of Serie."""
|
|
||||||
serie = Serie(
|
|
||||||
key="test-key",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Folder",
|
|
||||||
episodeDict={1: [1, 2]}
|
|
||||||
)
|
|
||||||
|
|
||||||
str_repr = str(serie)
|
|
||||||
assert "key='test-key'" in str_repr
|
|
||||||
assert "name='Test Series'" in str_repr
|
|
||||||
assert "folder='Test Folder'" in str_repr
|
|
||||||
|
|
||||||
def test_serie_to_dict(self):
|
|
||||||
"""Test conversion of Serie to dictionary."""
|
|
||||||
serie = Serie(
|
|
||||||
key="test-key",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Folder",
|
|
||||||
episodeDict={1: [1, 2], 2: [1, 2, 3]}
|
|
||||||
)
|
|
||||||
|
|
||||||
data = serie.to_dict()
|
|
||||||
|
|
||||||
assert data["key"] == "test-key"
|
|
||||||
assert data["name"] == "Test Series"
|
|
||||||
assert data["site"] == "https://example.com"
|
|
||||||
assert data["folder"] == "Test Folder"
|
|
||||||
assert "1" in data["episodeDict"]
|
|
||||||
assert data["episodeDict"]["1"] == [1, 2]
|
|
||||||
|
|
||||||
def test_serie_from_dict(self):
|
|
||||||
"""Test creating Serie from dictionary."""
|
|
||||||
data = {
|
|
||||||
"key": "test-key",
|
|
||||||
"name": "Test Series",
|
|
||||||
"site": "https://example.com",
|
|
||||||
"folder": "Test Folder",
|
|
||||||
"episodeDict": {"1": [1, 2], "2": [1, 2, 3]}
|
|
||||||
}
|
|
||||||
|
|
||||||
serie = Serie.from_dict(data)
|
|
||||||
|
|
||||||
assert serie.key == "test-key"
|
|
||||||
assert serie.name == "Test Series"
|
|
||||||
assert serie.folder == "Test Folder"
|
|
||||||
assert serie.episodeDict == {1: [1, 2], 2: [1, 2, 3]}
|
|
||||||
|
|
||||||
def test_serie_save_and_load_from_file(self):
|
|
||||||
"""Test saving and loading Serie from file."""
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-key",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Folder",
|
|
||||||
episodeDict={1: [1, 2, 3]}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create temporary file
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
mode='w',
|
|
||||||
delete=False,
|
|
||||||
suffix='.json'
|
|
||||||
) as f:
|
|
||||||
temp_filename = f.name
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Suppress deprecation warnings for this test
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
|
|
||||||
# Save to file
|
|
||||||
serie.save_to_file(temp_filename)
|
|
||||||
|
|
||||||
# Load from file
|
|
||||||
loaded_serie = Serie.load_from_file(temp_filename)
|
|
||||||
|
|
||||||
# Verify all properties match
|
|
||||||
assert loaded_serie.key == serie.key
|
|
||||||
assert loaded_serie.name == serie.name
|
|
||||||
assert loaded_serie.site == serie.site
|
|
||||||
assert loaded_serie.folder == serie.folder
|
|
||||||
assert loaded_serie.episodeDict == serie.episodeDict
|
|
||||||
finally:
|
|
||||||
# Cleanup
|
|
||||||
if os.path.exists(temp_filename):
|
|
||||||
os.remove(temp_filename)
|
|
||||||
|
|
||||||
def test_serie_folder_is_mutable(self):
|
|
||||||
"""Test that folder property can be changed (it's metadata only)."""
|
|
||||||
serie = Serie(
|
|
||||||
key="test-key",
|
|
||||||
name="Test",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Old Folder",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
serie.folder = "New Folder"
|
|
||||||
assert serie.folder == "New Folder"
|
|
||||||
# Key should remain unchanged
|
|
||||||
assert serie.key == "test-key"
|
|
||||||
|
|
||||||
|
|
||||||
class TestSerieDocumentation:
|
|
||||||
"""Test that Serie class has proper documentation."""
|
|
||||||
|
|
||||||
def test_serie_class_has_docstring(self):
|
|
||||||
"""Test that Serie class has a docstring."""
|
|
||||||
assert Serie.__doc__ is not None
|
|
||||||
assert "unique identifier" in Serie.__doc__.lower()
|
|
||||||
|
|
||||||
def test_key_property_has_docstring(self):
|
|
||||||
"""Test that key property has descriptive docstring."""
|
|
||||||
assert Serie.key.fget.__doc__ is not None
|
|
||||||
assert "unique" in Serie.key.fget.__doc__.lower()
|
|
||||||
assert "identifier" in Serie.key.fget.__doc__.lower()
|
|
||||||
|
|
||||||
def test_folder_property_has_docstring(self):
|
|
||||||
"""Test that folder property documents it's metadata only."""
|
|
||||||
assert Serie.folder.fget.__doc__ is not None
|
|
||||||
assert "metadata" in Serie.folder.fget.__doc__.lower()
|
|
||||||
assert "not used for lookups" in Serie.folder.fget.__doc__.lower()
|
|
||||||
|
|
||||||
|
|
||||||
class TestSerieDeprecationWarnings:
|
|
||||||
"""Test deprecation warnings for file-based methods."""
|
|
||||||
|
|
||||||
def test_save_to_file_raises_deprecation_warning(self):
|
|
||||||
"""Test save_to_file() raises deprecation warning."""
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-key",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Folder",
|
|
||||||
episodeDict={1: [1, 2, 3]}
|
|
||||||
)
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
mode='w', suffix='.json', delete=False
|
|
||||||
) as temp_file:
|
|
||||||
temp_filename = temp_file.name
|
|
||||||
|
|
||||||
try:
|
|
||||||
with warnings.catch_warnings(record=True) as w:
|
|
||||||
warnings.simplefilter("always")
|
|
||||||
serie.save_to_file(temp_filename)
|
|
||||||
|
|
||||||
# Check deprecation warning was raised
|
|
||||||
assert len(w) == 1
|
|
||||||
assert issubclass(w[0].category, DeprecationWarning)
|
|
||||||
assert "deprecated" in str(w[0].message).lower()
|
|
||||||
assert "save_to_file" in str(w[0].message)
|
|
||||||
finally:
|
|
||||||
if os.path.exists(temp_filename):
|
|
||||||
os.remove(temp_filename)
|
|
||||||
|
|
||||||
def test_load_from_file_raises_deprecation_warning(self):
|
|
||||||
"""Test load_from_file() raises deprecation warning."""
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-key",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Folder",
|
|
||||||
episodeDict={1: [1, 2, 3]}
|
|
||||||
)
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
mode='w', suffix='.json', delete=False
|
|
||||||
) as temp_file:
|
|
||||||
temp_filename = temp_file.name
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Save first (suppress warning for this)
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore")
|
|
||||||
serie.save_to_file(temp_filename)
|
|
||||||
|
|
||||||
# Now test loading
|
|
||||||
with warnings.catch_warnings(record=True) as w:
|
|
||||||
warnings.simplefilter("always")
|
|
||||||
Serie.load_from_file(temp_filename)
|
|
||||||
|
|
||||||
# Check deprecation warning was raised
|
|
||||||
assert len(w) == 1
|
|
||||||
assert issubclass(w[0].category, DeprecationWarning)
|
|
||||||
assert "deprecated" in str(w[0].message).lower()
|
|
||||||
assert "load_from_file" in str(w[0].message)
|
|
||||||
finally:
|
|
||||||
if os.path.exists(temp_filename):
|
|
||||||
os.remove(temp_filename)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSerieSanitizedFolder:
|
|
||||||
"""Test Serie.sanitized_folder property."""
|
|
||||||
|
|
||||||
def test_sanitized_folder_from_name(self):
|
|
||||||
"""Test that sanitized_folder uses the name property."""
|
|
||||||
serie = Serie(
|
|
||||||
key="attack-on-titan",
|
|
||||||
name="Attack on Titan: Final Season",
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="old-folder",
|
|
||||||
episodeDict={}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = serie.sanitized_folder
|
|
||||||
assert ":" not in result
|
|
||||||
assert "Attack on Titan" in result
|
|
||||||
|
|
||||||
def test_sanitized_folder_removes_special_chars(self):
|
|
||||||
"""Test that special characters are removed."""
|
|
||||||
serie = Serie(
|
|
||||||
key="re-zero",
|
|
||||||
name="Re:Zero - Starting Life in Another World?",
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="old-folder",
|
|
||||||
episodeDict={}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = serie.sanitized_folder
|
|
||||||
assert ":" not in result
|
|
||||||
assert "?" not in result
|
|
||||||
|
|
||||||
def test_sanitized_folder_fallback_to_folder(self):
|
|
||||||
"""Test fallback to folder when name is empty."""
|
|
||||||
serie = Serie(
|
|
||||||
key="test-key",
|
|
||||||
name="",
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="Valid Folder Name",
|
|
||||||
episodeDict={}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = serie.sanitized_folder
|
|
||||||
assert result == "Valid Folder Name"
|
|
||||||
|
|
||||||
def test_sanitized_folder_fallback_to_key(self):
|
|
||||||
"""Test fallback to key when name and folder can't be sanitized."""
|
|
||||||
serie = Serie(
|
|
||||||
key="valid-key",
|
|
||||||
name="",
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="",
|
|
||||||
episodeDict={}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = serie.sanitized_folder
|
|
||||||
assert result == "valid-key"
|
|
||||||
|
|
||||||
def test_sanitized_folder_preserves_unicode(self):
|
|
||||||
"""Test that Unicode characters are preserved."""
|
|
||||||
serie = Serie(
|
|
||||||
key="japanese-anime",
|
|
||||||
name="進撃の巨人",
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="old-folder",
|
|
||||||
episodeDict={}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = serie.sanitized_folder
|
|
||||||
assert "進撃の巨人" in result
|
|
||||||
|
|
||||||
def test_sanitized_folder_with_various_anime_titles(self):
|
|
||||||
"""Test sanitized_folder with real anime titles."""
|
|
||||||
test_cases = [
|
|
||||||
("fate-stay-night", "Fate/Stay Night: UBW"),
|
|
||||||
("86-eighty-six", "86: Eighty-Six"),
|
|
||||||
("steins-gate", "Steins;Gate"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for key, name in test_cases:
|
|
||||||
serie = Serie(
|
|
||||||
key=key,
|
|
||||||
name=name,
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="old-folder",
|
|
||||||
episodeDict={}
|
|
||||||
)
|
|
||||||
result = serie.sanitized_folder
|
|
||||||
# Verify invalid filesystem characters are removed
|
|
||||||
# Note: semicolon is valid on Linux but we test common invalid chars
|
|
||||||
assert ":" not in result
|
|
||||||
assert "/" not in result
|
|
||||||
|
|
||||||
|
|
||||||
class TestSerieNFOFeatures:
|
|
||||||
"""Test Serie class NFO-related features."""
|
|
||||||
|
|
||||||
def test_serie_creation_with_nfo_path(self):
|
|
||||||
"""Test creating Serie with NFO path."""
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Folder",
|
|
||||||
episodeDict={1: [1]},
|
|
||||||
nfo_path="/path/to/tvshow.nfo"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.nfo_path == "/path/to/tvshow.nfo"
|
|
||||||
|
|
||||||
def test_serie_creation_without_nfo_path(self):
|
|
||||||
"""Test creating Serie without NFO path defaults to None."""
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Folder",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.nfo_path is None
|
|
||||||
|
|
||||||
def test_serie_nfo_path_setter(self):
|
|
||||||
"""Test setting NFO path property."""
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Folder",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
serie.nfo_path = "/new/path/tvshow.nfo"
|
|
||||||
assert serie.nfo_path == "/new/path/tvshow.nfo"
|
|
||||||
|
|
||||||
def test_has_nfo_with_existing_file(self, tmp_path):
|
|
||||||
"""Test has_nfo returns True when NFO file exists."""
|
|
||||||
# Create a test directory structure
|
|
||||||
base_dir = tmp_path / "anime"
|
|
||||||
series_dir = base_dir / "Test Series"
|
|
||||||
series_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
nfo_file = series_dir / "tvshow.nfo"
|
|
||||||
nfo_file.write_text("test nfo content")
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.has_nfo(str(base_dir)) is True
|
|
||||||
|
|
||||||
def test_has_nfo_with_missing_file(self, tmp_path):
|
|
||||||
"""Test has_nfo returns False when NFO file doesn't exist."""
|
|
||||||
base_dir = tmp_path / "anime"
|
|
||||||
base_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.has_nfo(str(base_dir)) is False
|
|
||||||
|
|
||||||
def test_has_nfo_with_nfo_path_set(self, tmp_path):
|
|
||||||
"""Test has_nfo using nfo_path when base_directory not provided."""
|
|
||||||
nfo_file = tmp_path / "tvshow.nfo"
|
|
||||||
nfo_file.write_text("test nfo content")
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series",
|
|
||||||
episodeDict={1: [1]},
|
|
||||||
nfo_path=str(nfo_file)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.has_nfo() is True
|
|
||||||
|
|
||||||
def test_has_nfo_without_base_directory_or_path(self):
|
|
||||||
"""Test has_nfo returns False when no base_directory or nfo_path."""
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.has_nfo() is False
|
|
||||||
|
|
||||||
def test_has_poster_with_existing_file(self, tmp_path):
|
|
||||||
"""Test has_poster returns True when poster.jpg exists."""
|
|
||||||
base_dir = tmp_path / "anime"
|
|
||||||
series_dir = base_dir / "Test Series"
|
|
||||||
series_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
poster_file = series_dir / "poster.jpg"
|
|
||||||
poster_file.write_text("test image data")
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.has_poster(str(base_dir)) is True
|
|
||||||
|
|
||||||
def test_has_poster_with_missing_file(self, tmp_path):
|
|
||||||
"""Test has_poster returns False when poster.jpg doesn't exist."""
|
|
||||||
base_dir = tmp_path / "anime"
|
|
||||||
base_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.has_poster(str(base_dir)) is False
|
|
||||||
|
|
||||||
def test_has_poster_without_base_directory(self):
|
|
||||||
"""Test has_poster returns False when no base_directory provided."""
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.has_poster() is False
|
|
||||||
|
|
||||||
def test_has_logo_with_existing_file(self, tmp_path):
|
|
||||||
"""Test has_logo returns True when logo.png exists."""
|
|
||||||
base_dir = tmp_path / "anime"
|
|
||||||
series_dir = base_dir / "Test Series"
|
|
||||||
series_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
logo_file = series_dir / "logo.png"
|
|
||||||
logo_file.write_text("test logo data")
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.has_logo(str(base_dir)) is True
|
|
||||||
|
|
||||||
def test_has_logo_with_missing_file(self, tmp_path):
|
|
||||||
"""Test has_logo returns False when logo.png doesn't exist."""
|
|
||||||
base_dir = tmp_path / "anime"
|
|
||||||
base_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.has_logo(str(base_dir)) is False
|
|
||||||
|
|
||||||
def test_has_logo_without_base_directory(self):
|
|
||||||
"""Test has_logo returns False when no base_directory provided."""
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.has_logo() is False
|
|
||||||
|
|
||||||
def test_has_fanart_with_existing_file(self, tmp_path):
|
|
||||||
"""Test has_fanart returns True when fanart.jpg exists."""
|
|
||||||
base_dir = tmp_path / "anime"
|
|
||||||
series_dir = base_dir / "Test Series"
|
|
||||||
series_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
fanart_file = series_dir / "fanart.jpg"
|
|
||||||
fanart_file.write_text("test fanart data")
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.has_fanart(str(base_dir)) is True
|
|
||||||
|
|
||||||
def test_has_fanart_with_missing_file(self, tmp_path):
|
|
||||||
"""Test has_fanart returns False when fanart.jpg doesn't exist."""
|
|
||||||
base_dir = tmp_path / "anime"
|
|
||||||
base_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.has_fanart(str(base_dir)) is False
|
|
||||||
|
|
||||||
def test_has_fanart_without_base_directory(self):
|
|
||||||
"""Test has_fanart returns False when no base_directory provided."""
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Series",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert serie.has_fanart() is False
|
|
||||||
|
|
||||||
def test_to_dict_includes_nfo_path(self):
|
|
||||||
"""Test that to_dict includes nfo_path field."""
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Folder",
|
|
||||||
episodeDict={1: [1, 2], 2: [1]},
|
|
||||||
year=2024,
|
|
||||||
nfo_path="/path/to/tvshow.nfo"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = serie.to_dict()
|
|
||||||
|
|
||||||
assert result["nfo_path"] == "/path/to/tvshow.nfo"
|
|
||||||
assert result["key"] == "test-series"
|
|
||||||
assert result["name"] == "Test Series"
|
|
||||||
assert result["year"] == 2024
|
|
||||||
|
|
||||||
def test_to_dict_with_none_nfo_path(self):
|
|
||||||
"""Test that to_dict handles None nfo_path."""
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Folder",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = serie.to_dict()
|
|
||||||
|
|
||||||
assert result["nfo_path"] is None
|
|
||||||
|
|
||||||
def test_from_dict_with_nfo_path(self):
|
|
||||||
"""Test that from_dict correctly loads nfo_path."""
|
|
||||||
data = {
|
|
||||||
"key": "test-series",
|
|
||||||
"name": "Test Series",
|
|
||||||
"site": "https://example.com",
|
|
||||||
"folder": "Test Folder",
|
|
||||||
"episodeDict": {"1": [1, 2]},
|
|
||||||
"year": 2024,
|
|
||||||
"nfo_path": "/path/to/tvshow.nfo"
|
|
||||||
}
|
|
||||||
|
|
||||||
serie = Serie.from_dict(data)
|
|
||||||
|
|
||||||
assert serie.nfo_path == "/path/to/tvshow.nfo"
|
|
||||||
assert serie.key == "test-series"
|
|
||||||
assert serie.year == 2024
|
|
||||||
|
|
||||||
def test_from_dict_without_nfo_path(self):
|
|
||||||
"""Test that from_dict handles missing nfo_path (backward compatibility)."""
|
|
||||||
data = {
|
|
||||||
"key": "test-series",
|
|
||||||
"name": "Test Series",
|
|
||||||
"site": "https://example.com",
|
|
||||||
"folder": "Test Folder",
|
|
||||||
"episodeDict": {"1": [1, 2]}
|
|
||||||
}
|
|
||||||
|
|
||||||
serie = Serie.from_dict(data)
|
|
||||||
|
|
||||||
assert serie.nfo_path is None
|
|
||||||
assert serie.key == "test-series"
|
|
||||||
|
|
||||||
def test_save_and_load_file_with_nfo_path(self, tmp_path):
|
|
||||||
"""Test that save_to_file and load_from_file preserve nfo_path."""
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder="Test Folder",
|
|
||||||
episodeDict={1: [1, 2], 2: [1]},
|
|
||||||
year=2024,
|
|
||||||
nfo_path="/path/to/tvshow.nfo"
|
|
||||||
)
|
|
||||||
|
|
||||||
file_path = tmp_path / "data"
|
|
||||||
|
|
||||||
with pytest.warns(DeprecationWarning):
|
|
||||||
serie.save_to_file(str(file_path))
|
|
||||||
|
|
||||||
with pytest.warns(DeprecationWarning):
|
|
||||||
loaded_serie = Serie.load_from_file(str(file_path))
|
|
||||||
|
|
||||||
assert loaded_serie.nfo_path == "/path/to/tvshow.nfo"
|
|
||||||
assert loaded_serie.key == "test-series"
|
|
||||||
assert loaded_serie.year == 2024
|
|
||||||
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
"""Tests for Serie.ensure_folder_with_year() method."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from src.core.entities.series import Serie
|
|
||||||
|
|
||||||
|
|
||||||
class TestSerieEnsureFolderWithYear:
|
|
||||||
"""Test suite for ensure_folder_with_year method."""
|
|
||||||
|
|
||||||
def test_ensure_folder_with_year_adds_year(self):
|
|
||||||
"""Test that ensure_folder_with_year adds year to folder name."""
|
|
||||||
serie = Serie(
|
|
||||||
key="perfect-blue",
|
|
||||||
name="Perfect Blue",
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="Perfect Blue",
|
|
||||||
episodeDict={1: [1, 2, 3]},
|
|
||||||
year=1997
|
|
||||||
)
|
|
||||||
|
|
||||||
result = serie.ensure_folder_with_year()
|
|
||||||
|
|
||||||
assert result == "Perfect Blue (1997)"
|
|
||||||
assert serie.folder == "Perfect Blue (1997)"
|
|
||||||
|
|
||||||
def test_ensure_folder_with_year_already_has_year(self):
|
|
||||||
"""Test that ensure_folder_with_year doesn't duplicate year."""
|
|
||||||
serie = Serie(
|
|
||||||
key="blue-exorcist",
|
|
||||||
name="Blue Exorcist",
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="Blue Exorcist (2011)",
|
|
||||||
episodeDict={1: [1, 2, 3]},
|
|
||||||
year=2011
|
|
||||||
)
|
|
||||||
|
|
||||||
result = serie.ensure_folder_with_year()
|
|
||||||
|
|
||||||
assert result == "Blue Exorcist (2011)"
|
|
||||||
assert serie.folder == "Blue Exorcist (2011)"
|
|
||||||
|
|
||||||
def test_ensure_folder_with_year_no_year_available(self):
|
|
||||||
"""Test that ensure_folder_with_year returns folder unchanged if no year."""
|
|
||||||
serie = Serie(
|
|
||||||
key="unknown-anime",
|
|
||||||
name="Unknown Anime",
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="Unknown Anime",
|
|
||||||
episodeDict={1: [1, 2, 3]},
|
|
||||||
year=None
|
|
||||||
)
|
|
||||||
|
|
||||||
result = serie.ensure_folder_with_year()
|
|
||||||
|
|
||||||
assert result == "Unknown Anime"
|
|
||||||
assert serie.folder == "Unknown Anime"
|
|
||||||
|
|
||||||
def test_ensure_folder_with_year_sanitizes_name(self):
|
|
||||||
"""Test that ensure_folder_with_year uses sanitized_folder property."""
|
|
||||||
serie = Serie(
|
|
||||||
key="attack-on-titan",
|
|
||||||
name="Attack on Titan: Final Season",
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="Attack on Titan Final", # Old folder without year
|
|
||||||
episodeDict={1: [1, 2, 3]},
|
|
||||||
year=2020
|
|
||||||
)
|
|
||||||
|
|
||||||
result = serie.ensure_folder_with_year()
|
|
||||||
|
|
||||||
# Should use sanitized version of name_with_year
|
|
||||||
assert "(2020)" in result
|
|
||||||
assert serie.folder == result
|
|
||||||
# Colon should be removed by sanitization
|
|
||||||
assert ":" not in result
|
|
||||||
|
|
||||||
def test_ensure_folder_with_year_updates_folder_property(self):
|
|
||||||
"""Test that folder property is updated when year is added."""
|
|
||||||
serie = Serie(
|
|
||||||
key="dororo",
|
|
||||||
name="Dororo",
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="Dororo",
|
|
||||||
episodeDict={1: [1, 2, 3]},
|
|
||||||
year=2019
|
|
||||||
)
|
|
||||||
|
|
||||||
original_folder = serie.folder
|
|
||||||
result = serie.ensure_folder_with_year()
|
|
||||||
|
|
||||||
assert original_folder == "Dororo"
|
|
||||||
assert result == "Dororo (2019)"
|
|
||||||
assert serie.folder == "Dororo (2019)"
|
|
||||||
assert serie.folder != original_folder
|
|
||||||
@@ -1,212 +1,136 @@
|
|||||||
"""Tests for SerieList class - identifier standardization."""
|
"""Tests for SerieList class - DB-only operations."""
|
||||||
# pylint: disable=redefined-outer-name
|
|
||||||
|
|
||||||
import os
|
from unittest.mock import MagicMock
|
||||||
import tempfile
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.entities.SerieList import SerieList
|
from src.server.database.SerieList import SerieList
|
||||||
from src.core.entities.series import Serie
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def temp_directory():
|
|
||||||
"""Create a temporary directory for testing."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
yield tmpdir
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_serie():
|
def sample_serie():
|
||||||
"""Create a sample Serie for testing."""
|
"""Create a sample AnimeSeries mock for testing."""
|
||||||
return Serie(
|
anime = MagicMock()
|
||||||
key="attack-on-titan",
|
anime.key = "attack-on-titan"
|
||||||
name="Attack on Titan",
|
anime.name = "Attack on Titan"
|
||||||
site="https://aniworld.to/anime/stream/attack-on-titan",
|
anime.site = "https://aniworld.to/anime/stream/attack-on-titan"
|
||||||
folder="Attack on Titan (2013)",
|
anime.folder = "Attack on Titan (2013)"
|
||||||
episodeDict={1: [1, 2, 3]}
|
anime.year = 2013
|
||||||
)
|
anime.nfo_path = None
|
||||||
|
anime.episodeDict = {1: [1, 2, 3]}
|
||||||
|
return anime
|
||||||
|
|
||||||
|
|
||||||
class TestSerieListKeyBasedStorage:
|
class TestSerieListKeyBasedStorage:
|
||||||
"""Test SerieList uses key for internal storage."""
|
"""Test SerieList uses key for internal storage."""
|
||||||
|
|
||||||
def test_init_creates_empty_keydict(self, temp_directory):
|
def test_init_creates_empty_keydict(self, tmp_path):
|
||||||
"""Test initialization creates keyDict."""
|
"""Test initialization creates keyDict."""
|
||||||
serie_list = SerieList(temp_directory)
|
serie_list = SerieList(str(tmp_path))
|
||||||
assert hasattr(serie_list, 'keyDict')
|
assert hasattr(serie_list, 'keyDict')
|
||||||
assert isinstance(serie_list.keyDict, dict)
|
assert isinstance(serie_list.keyDict, dict)
|
||||||
|
assert len(serie_list.keyDict) == 0
|
||||||
|
|
||||||
def test_add_stores_by_key(self, temp_directory, sample_serie):
|
def test_contains_checks_by_key(self, tmp_path, sample_serie):
|
||||||
"""Test add() stores series by key."""
|
|
||||||
serie_list = SerieList(temp_directory)
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
serie_list.add(sample_serie)
|
|
||||||
|
|
||||||
# Verify stored by key, not folder
|
|
||||||
assert sample_serie.key in serie_list.keyDict
|
|
||||||
assert serie_list.keyDict[sample_serie.key] == sample_serie
|
|
||||||
|
|
||||||
def test_contains_checks_by_key(self, temp_directory, sample_serie):
|
|
||||||
"""Test contains() checks by key."""
|
"""Test contains() checks by key."""
|
||||||
serie_list = SerieList(temp_directory)
|
serie_list = SerieList(str(tmp_path))
|
||||||
with warnings.catch_warnings():
|
serie_list.keyDict[sample_serie.key] = sample_serie
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
serie_list.add(sample_serie)
|
|
||||||
|
|
||||||
assert serie_list.contains(sample_serie.key)
|
assert serie_list.contains(sample_serie.key)
|
||||||
assert not serie_list.contains("nonexistent-key")
|
assert not serie_list.contains("nonexistent-key")
|
||||||
|
|
||||||
def test_add_prevents_duplicates_by_key(
|
def test_get_by_key_returns_correct_serie(self, tmp_path, sample_serie):
|
||||||
self, temp_directory, sample_serie
|
|
||||||
):
|
|
||||||
"""Test add() prevents duplicates based on key."""
|
|
||||||
serie_list = SerieList(temp_directory)
|
|
||||||
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
# Add same serie twice
|
|
||||||
serie_list.add(sample_serie)
|
|
||||||
initial_count = len(serie_list.keyDict)
|
|
||||||
|
|
||||||
serie_list.add(sample_serie)
|
|
||||||
|
|
||||||
# Should still have only one entry
|
|
||||||
assert len(serie_list.keyDict) == initial_count
|
|
||||||
assert len(serie_list.keyDict) == 1
|
|
||||||
|
|
||||||
def test_get_by_key_returns_correct_serie(
|
|
||||||
self, temp_directory, sample_serie
|
|
||||||
):
|
|
||||||
"""Test get_by_key() retrieves series correctly."""
|
"""Test get_by_key() retrieves series correctly."""
|
||||||
serie_list = SerieList(temp_directory)
|
serie_list = SerieList(str(tmp_path))
|
||||||
with warnings.catch_warnings():
|
serie_list.keyDict[sample_serie.key] = sample_serie
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
serie_list.add(sample_serie)
|
|
||||||
|
|
||||||
result = serie_list.get_by_key(sample_serie.key)
|
result = serie_list.get_by_key(sample_serie.key)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.key == sample_serie.key
|
assert result.key == sample_serie.key
|
||||||
assert result.name == sample_serie.name
|
assert result.name == sample_serie.name
|
||||||
|
|
||||||
def test_get_by_key_returns_none_for_missing(self, temp_directory):
|
def test_get_by_key_returns_none_for_missing(self, tmp_path):
|
||||||
"""Test get_by_key() returns None for nonexistent key."""
|
"""Test get_by_key() returns None for nonexistent key."""
|
||||||
serie_list = SerieList(temp_directory)
|
serie_list = SerieList(str(tmp_path))
|
||||||
|
|
||||||
result = serie_list.get_by_key("nonexistent-key")
|
result = serie_list.get_by_key("nonexistent-key")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
def test_get_by_folder_backward_compatibility(
|
def test_get_by_folder_backward_compatibility(self, tmp_path, sample_serie):
|
||||||
self, temp_directory, sample_serie
|
|
||||||
):
|
|
||||||
"""Test get_by_folder() provides backward compatibility."""
|
"""Test get_by_folder() provides backward compatibility."""
|
||||||
serie_list = SerieList(temp_directory)
|
import warnings
|
||||||
|
serie_list = SerieList(str(tmp_path))
|
||||||
|
serie_list.keyDict[sample_serie.key] = sample_serie
|
||||||
|
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
warnings.simplefilter("ignore", DeprecationWarning)
|
||||||
serie_list.add(sample_serie)
|
|
||||||
result = serie_list.get_by_folder(sample_serie.folder)
|
result = serie_list.get_by_folder(sample_serie.folder)
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.key == sample_serie.key
|
assert result.key == sample_serie.key
|
||||||
assert result.folder == sample_serie.folder
|
assert result.folder == sample_serie.folder
|
||||||
|
|
||||||
def test_get_by_folder_returns_none_for_missing(self, temp_directory):
|
def test_get_by_folder_returns_none_for_missing(self, tmp_path):
|
||||||
"""Test get_by_folder() returns None for nonexistent folder."""
|
"""Test get_by_folder() returns None for nonexistent folder."""
|
||||||
serie_list = SerieList(temp_directory)
|
import warnings
|
||||||
|
serie_list = SerieList(str(tmp_path))
|
||||||
|
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
warnings.simplefilter("ignore", DeprecationWarning)
|
||||||
result = serie_list.get_by_folder("Nonexistent Folder")
|
result = serie_list.get_by_folder("Nonexistent Folder")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
def test_get_all_returns_all_series(self, temp_directory, sample_serie):
|
def test_get_all_returns_all_series(self, tmp_path, sample_serie):
|
||||||
"""Test get_all() returns all series from keyDict."""
|
"""Test get_all() returns all series from keyDict."""
|
||||||
serie_list = SerieList(temp_directory)
|
serie_list = SerieList(str(tmp_path))
|
||||||
|
|
||||||
serie2 = Serie(
|
serie2 = MagicMock()
|
||||||
key="naruto",
|
serie2.key = "naruto"
|
||||||
name="Naruto",
|
serie2.name = "Naruto"
|
||||||
site="https://aniworld.to/anime/stream/naruto",
|
serie2.site = "https://aniworld.to/anime/stream/naruto"
|
||||||
folder="Naruto (2002)",
|
serie2.folder = "Naruto (2002)"
|
||||||
episodeDict={1: [1, 2]}
|
serie2.episodeDict = {1: [1, 2]}
|
||||||
)
|
|
||||||
|
|
||||||
with warnings.catch_warnings():
|
serie_list.keyDict[sample_serie.key] = sample_serie
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
serie_list.keyDict[serie2.key] = serie2
|
||||||
serie_list.add(sample_serie)
|
|
||||||
serie_list.add(serie2)
|
|
||||||
|
|
||||||
all_series = serie_list.get_all()
|
all_series = serie_list.get_all()
|
||||||
assert len(all_series) == 2
|
assert len(all_series) == 2
|
||||||
assert sample_serie in all_series
|
assert sample_serie in all_series
|
||||||
assert serie2 in all_series
|
assert serie2 in all_series
|
||||||
|
|
||||||
def test_get_missing_episodes_filters_by_episode_dict(
|
def test_get_missing_episodes_filters_by_episode_dict(self, tmp_path):
|
||||||
self, temp_directory
|
|
||||||
):
|
|
||||||
"""Test get_missing_episodes() returns only series with episodes."""
|
"""Test get_missing_episodes() returns only series with episodes."""
|
||||||
serie_list = SerieList(temp_directory)
|
serie_list = SerieList(str(tmp_path))
|
||||||
|
|
||||||
# Serie with missing episodes
|
serie_with_episodes = MagicMock()
|
||||||
serie_with_episodes = Serie(
|
serie_with_episodes.key = "serie-with-episodes"
|
||||||
key="serie-with-episodes",
|
serie_with_episodes.name = "Serie With Episodes"
|
||||||
name="Serie With Episodes",
|
serie_with_episodes.episodeDict = {1: [1, 2, 3]}
|
||||||
site="https://aniworld.to/anime/stream/serie-with-episodes",
|
|
||||||
folder="Serie With Episodes (2020)",
|
|
||||||
episodeDict={1: [1, 2, 3]}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Serie without missing episodes
|
serie_without_episodes = MagicMock()
|
||||||
serie_without_episodes = Serie(
|
serie_without_episodes.key = "serie-without-episodes"
|
||||||
key="serie-without-episodes",
|
serie_without_episodes.name = "Serie Without Episodes"
|
||||||
name="Serie Without Episodes",
|
serie_without_episodes.episodeDict = {}
|
||||||
site="https://aniworld.to/anime/stream/serie-without-episodes",
|
|
||||||
folder="Serie Without Episodes (2021)",
|
|
||||||
episodeDict={}
|
|
||||||
)
|
|
||||||
|
|
||||||
with warnings.catch_warnings():
|
serie_list.keyDict[serie_with_episodes.key] = serie_with_episodes
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
serie_list.keyDict[serie_without_episodes.key] = serie_without_episodes
|
||||||
serie_list.add(serie_with_episodes)
|
|
||||||
serie_list.add(serie_without_episodes)
|
|
||||||
|
|
||||||
missing = serie_list.get_missing_episodes()
|
missing = serie_list.get_missing_episodes()
|
||||||
assert len(missing) == 1
|
assert len(missing) == 1
|
||||||
assert serie_with_episodes in missing
|
assert serie_with_episodes in missing
|
||||||
assert serie_without_episodes not in missing
|
assert serie_without_episodes not in missing
|
||||||
|
|
||||||
def test_load_series_stores_by_key(self, temp_directory, sample_serie):
|
|
||||||
"""Test load_series() stores series by key when loading from disk."""
|
|
||||||
# Create directory structure and save serie
|
|
||||||
folder_path = os.path.join(temp_directory, sample_serie.folder)
|
|
||||||
os.makedirs(folder_path, exist_ok=True)
|
|
||||||
data_path = os.path.join(folder_path, "data")
|
|
||||||
sample_serie.save_to_file(data_path)
|
|
||||||
|
|
||||||
# Create new SerieList (triggers load_series in __init__)
|
|
||||||
serie_list = SerieList(temp_directory)
|
|
||||||
|
|
||||||
# Verify loaded by key
|
|
||||||
assert sample_serie.key in serie_list.keyDict
|
|
||||||
loaded_serie = serie_list.keyDict[sample_serie.key]
|
|
||||||
assert loaded_serie.key == sample_serie.key
|
|
||||||
assert loaded_serie.name == sample_serie.name
|
|
||||||
|
|
||||||
|
|
||||||
class TestSerieListPublicAPI:
|
class TestSerieListPublicAPI:
|
||||||
"""Test that public API still works correctly."""
|
"""Test that public API still works correctly."""
|
||||||
|
|
||||||
def test_public_methods_work(self, temp_directory, sample_serie):
|
def test_public_methods_work(self, tmp_path, sample_serie):
|
||||||
"""Test that all public methods work correctly after refactoring."""
|
"""Test that all public methods work correctly after refactoring."""
|
||||||
serie_list = SerieList(temp_directory)
|
serie_list = SerieList(str(tmp_path))
|
||||||
|
|
||||||
# Test add (suppress deprecation warning for test)
|
# Add directly to keyDict (simulating DB load)
|
||||||
with warnings.catch_warnings():
|
serie_list.keyDict[sample_serie.key] = sample_serie
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
serie_list.add(sample_serie)
|
|
||||||
|
|
||||||
# Test contains
|
# Test contains
|
||||||
assert serie_list.contains(sample_serie.key)
|
assert serie_list.contains(sample_serie.key)
|
||||||
@@ -219,30 +143,17 @@ class TestSerieListPublicAPI:
|
|||||||
assert len(serie_list.GetMissingEpisode()) == 1
|
assert len(serie_list.GetMissingEpisode()) == 1
|
||||||
assert len(serie_list.get_missing_episodes()) == 1
|
assert len(serie_list.get_missing_episodes()) == 1
|
||||||
|
|
||||||
# Test new helper methods
|
# Test get_by_key
|
||||||
assert serie_list.get_by_key(sample_serie.key) is not None
|
assert serie_list.get_by_key(sample_serie.key) is not None
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
assert serie_list.get_by_folder(sample_serie.folder) is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestSerieListSkipLoad:
|
|
||||||
"""Test SerieList initialization options."""
|
|
||||||
|
|
||||||
def test_init_with_skip_load(self, temp_directory):
|
|
||||||
"""Test initialization with skip_load=True skips loading."""
|
|
||||||
serie_list = SerieList(temp_directory, skip_load=True)
|
|
||||||
assert len(serie_list.keyDict) == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestSerieListDeprecationWarnings:
|
class TestSerieListDeprecationWarnings:
|
||||||
"""Test deprecation warnings are raised for file-based methods."""
|
"""Test deprecation warnings are raised for deprecated methods."""
|
||||||
|
|
||||||
def test_get_by_folder_raises_deprecation_warning(
|
def test_get_by_folder_raises_deprecation_warning(self, tmp_path, sample_serie):
|
||||||
self, temp_directory, sample_serie
|
|
||||||
):
|
|
||||||
"""Test get_by_folder() raises deprecation warning."""
|
"""Test get_by_folder() raises deprecation warning."""
|
||||||
serie_list = SerieList(temp_directory, skip_load=True)
|
import warnings
|
||||||
|
serie_list = SerieList(str(tmp_path))
|
||||||
serie_list.keyDict[sample_serie.key] = sample_serie
|
serie_list.keyDict[sample_serie.key] = sample_serie
|
||||||
|
|
||||||
with warnings.catch_warnings(record=True) as w:
|
with warnings.catch_warnings(record=True) as w:
|
||||||
@@ -255,267 +166,15 @@ class TestSerieListDeprecationWarnings:
|
|||||||
assert "get_by_key()" in str(w[0].message)
|
assert "get_by_key()" in str(w[0].message)
|
||||||
|
|
||||||
|
|
||||||
class TestSerieListBackwardCompatibility:
|
class TestInvalidateCache:
|
||||||
"""Test backward compatibility of file-based operations."""
|
"""Test invalidate_cache method."""
|
||||||
|
|
||||||
def test_file_based_mode_still_works(
|
def test_invalidate_cache_clears_keydict(self, tmp_path, sample_serie):
|
||||||
self, temp_directory, sample_serie
|
"""Verify invalidate_cache clears the in-memory cache."""
|
||||||
):
|
serie_list = SerieList(str(tmp_path))
|
||||||
"""Test file-based mode still works without db_session."""
|
serie_list.keyDict[sample_serie.key] = sample_serie
|
||||||
serie_list = SerieList(temp_directory)
|
assert len(serie_list.keyDict) == 1
|
||||||
|
|
||||||
# Add should still work (with deprecation warning)
|
serie_list.invalidate_cache()
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
serie_list.add(sample_serie)
|
|
||||||
|
|
||||||
# File should be created
|
|
||||||
data_path = os.path.join(
|
|
||||||
temp_directory, sample_serie.folder, "data"
|
|
||||||
)
|
|
||||||
assert os.path.isfile(data_path)
|
|
||||||
|
|
||||||
# Series should be in memory
|
|
||||||
assert serie_list.contains(sample_serie.key)
|
|
||||||
|
|
||||||
def test_load_from_file_still_works(
|
|
||||||
self, temp_directory, sample_serie
|
|
||||||
):
|
|
||||||
"""Test loading from files still works."""
|
|
||||||
# Create directory and save file
|
|
||||||
folder_path = os.path.join(temp_directory, sample_serie.folder)
|
|
||||||
os.makedirs(folder_path, exist_ok=True)
|
|
||||||
data_path = os.path.join(folder_path, "data")
|
|
||||||
sample_serie.save_to_file(data_path)
|
|
||||||
|
|
||||||
# New SerieList should load it
|
|
||||||
serie_list = SerieList(temp_directory)
|
|
||||||
|
|
||||||
assert serie_list.contains(sample_serie.key)
|
|
||||||
loaded = serie_list.get_by_key(sample_serie.key)
|
|
||||||
assert loaded.name == sample_serie.name
|
|
||||||
|
|
||||||
|
|
||||||
class TestSerieListNFOFeatures:
|
|
||||||
"""Test SerieList NFO detection and logging."""
|
|
||||||
|
|
||||||
def test_load_series_detects_nfo_file(self, temp_directory, caplog):
|
|
||||||
"""Test load_series detects and sets nfo_path for series with NFO."""
|
|
||||||
import logging
|
|
||||||
caplog.set_level(logging.INFO)
|
|
||||||
|
|
||||||
# Create series folder with data file and NFO
|
|
||||||
folder_name = "Test Series"
|
|
||||||
folder_path = os.path.join(temp_directory, folder_name)
|
|
||||||
os.makedirs(folder_path)
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder=folder_name,
|
|
||||||
episodeDict={1: [1, 2]}
|
|
||||||
)
|
|
||||||
|
|
||||||
data_path = os.path.join(folder_path, "data")
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
serie.save_to_file(data_path)
|
|
||||||
|
|
||||||
# Create NFO file
|
|
||||||
nfo_path = os.path.join(folder_path, "tvshow.nfo")
|
|
||||||
with open(nfo_path, "w") as f:
|
|
||||||
f.write("<tvshow></tvshow>")
|
|
||||||
|
|
||||||
# Load series
|
|
||||||
serie_list = SerieList(temp_directory)
|
|
||||||
|
|
||||||
# Verify NFO was detected
|
|
||||||
loaded = serie_list.get_by_key("test-series")
|
|
||||||
assert loaded is not None
|
|
||||||
assert loaded.nfo_path == nfo_path
|
|
||||||
|
|
||||||
# Verify logging
|
|
||||||
assert "1 with NFO" in caplog.text
|
|
||||||
|
|
||||||
def test_load_series_detects_missing_nfo(self, temp_directory, caplog):
|
|
||||||
"""Test load_series logs when NFO is missing."""
|
|
||||||
import logging
|
|
||||||
caplog.set_level(logging.DEBUG)
|
|
||||||
|
|
||||||
# Create series folder with data file but NO NFO
|
|
||||||
folder_name = "Test Series"
|
|
||||||
folder_path = os.path.join(temp_directory, folder_name)
|
|
||||||
os.makedirs(folder_path)
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder=folder_name,
|
|
||||||
episodeDict={1: [1, 2]}
|
|
||||||
)
|
|
||||||
|
|
||||||
data_path = os.path.join(folder_path, "data")
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
serie.save_to_file(data_path)
|
|
||||||
|
|
||||||
# Load series
|
|
||||||
serie_list = SerieList(temp_directory)
|
|
||||||
|
|
||||||
# Verify NFO not set
|
|
||||||
loaded = serie_list.get_by_key("test-series")
|
|
||||||
assert loaded is not None
|
|
||||||
assert loaded.nfo_path is None
|
|
||||||
|
|
||||||
# Verify logging
|
|
||||||
assert "missing tvshow.nfo" in caplog.text
|
|
||||||
|
|
||||||
def test_load_series_detects_media_files(self, temp_directory, caplog):
|
|
||||||
"""Test load_series detects poster, logo, and fanart files."""
|
|
||||||
import logging
|
|
||||||
caplog.set_level(logging.INFO)
|
|
||||||
|
|
||||||
# Create series folder with all media files
|
|
||||||
folder_name = "Test Series"
|
|
||||||
folder_path = os.path.join(temp_directory, folder_name)
|
|
||||||
os.makedirs(folder_path)
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder=folder_name,
|
|
||||||
episodeDict={1: [1, 2]}
|
|
||||||
)
|
|
||||||
|
|
||||||
data_path = os.path.join(folder_path, "data")
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
serie.save_to_file(data_path)
|
|
||||||
|
|
||||||
# Create media files
|
|
||||||
with open(os.path.join(folder_path, "poster.jpg"), "w") as f:
|
|
||||||
f.write("poster data")
|
|
||||||
with open(os.path.join(folder_path, "logo.png"), "w") as f:
|
|
||||||
f.write("logo data")
|
|
||||||
with open(os.path.join(folder_path, "fanart.jpg"), "w") as f:
|
|
||||||
f.write("fanart data")
|
|
||||||
|
|
||||||
# Load series
|
|
||||||
serie_list = SerieList(temp_directory)
|
|
||||||
|
|
||||||
# Verify logging shows all media found
|
|
||||||
assert "Poster (1/1)" in caplog.text
|
|
||||||
assert "Logo (1/1)" in caplog.text
|
|
||||||
assert "Fanart (1/1)" in caplog.text
|
|
||||||
|
|
||||||
def test_load_series_detects_missing_media_files(
|
|
||||||
self, temp_directory, caplog
|
|
||||||
):
|
|
||||||
"""Test load_series logs when media files are missing."""
|
|
||||||
import logging
|
|
||||||
caplog.set_level(logging.DEBUG)
|
|
||||||
|
|
||||||
# Create series folder with NO media files
|
|
||||||
folder_name = "Test Series"
|
|
||||||
folder_path = os.path.join(temp_directory, folder_name)
|
|
||||||
os.makedirs(folder_path)
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key="test-series",
|
|
||||||
name="Test Series",
|
|
||||||
site="https://example.com",
|
|
||||||
folder=folder_name,
|
|
||||||
episodeDict={1: [1, 2]}
|
|
||||||
)
|
|
||||||
|
|
||||||
data_path = os.path.join(folder_path, "data")
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
serie.save_to_file(data_path)
|
|
||||||
|
|
||||||
# Load series
|
|
||||||
serie_list = SerieList(temp_directory)
|
|
||||||
|
|
||||||
# Verify logging shows missing media
|
|
||||||
assert "missing poster.jpg" in caplog.text
|
|
||||||
assert "missing logo.png" in caplog.text
|
|
||||||
assert "missing fanart.jpg" in caplog.text
|
|
||||||
|
|
||||||
def test_load_series_summary_statistics(self, temp_directory, caplog):
|
|
||||||
"""Test load_series logs summary statistics for NFO and media."""
|
|
||||||
import logging
|
|
||||||
caplog.set_level(logging.INFO)
|
|
||||||
|
|
||||||
# Create multiple series with varying NFO/media status
|
|
||||||
for i in range(3):
|
|
||||||
folder_name = f"Series {i}"
|
|
||||||
folder_path = os.path.join(temp_directory, folder_name)
|
|
||||||
os.makedirs(folder_path)
|
|
||||||
|
|
||||||
serie = Serie(
|
|
||||||
key=f"series-{i}",
|
|
||||||
name=f"Series {i}",
|
|
||||||
site="https://example.com",
|
|
||||||
folder=folder_name,
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
)
|
|
||||||
|
|
||||||
data_path = os.path.join(folder_path, "data")
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
serie.save_to_file(data_path)
|
|
||||||
|
|
||||||
# First series has everything
|
|
||||||
if i == 0:
|
|
||||||
with open(os.path.join(folder_path, "tvshow.nfo"), "w") as f:
|
|
||||||
f.write("<tvshow></tvshow>")
|
|
||||||
with open(os.path.join(folder_path, "poster.jpg"), "w") as f:
|
|
||||||
f.write("poster")
|
|
||||||
with open(os.path.join(folder_path, "logo.png"), "w") as f:
|
|
||||||
f.write("logo")
|
|
||||||
with open(os.path.join(folder_path, "fanart.jpg"), "w") as f:
|
|
||||||
f.write("fanart")
|
|
||||||
# Second series has NFO and poster only
|
|
||||||
elif i == 1:
|
|
||||||
with open(os.path.join(folder_path, "tvshow.nfo"), "w") as f:
|
|
||||||
f.write("<tvshow></tvshow>")
|
|
||||||
with open(os.path.join(folder_path, "poster.jpg"), "w") as f:
|
|
||||||
f.write("poster")
|
|
||||||
# Third series has nothing
|
|
||||||
|
|
||||||
# Load series
|
|
||||||
serie_list = SerieList(temp_directory)
|
|
||||||
|
|
||||||
# Verify summary statistics
|
|
||||||
assert "3 series total" in caplog.text
|
|
||||||
assert "2 with NFO, 1 without NFO" in caplog.text
|
|
||||||
assert "Poster (2/3)" in caplog.text
|
|
||||||
assert "Logo (1/3)" in caplog.text
|
|
||||||
assert "Fanart (1/3)" in caplog.text
|
|
||||||
|
|
||||||
def test_load_series_handles_load_failure(self, temp_directory, caplog):
|
|
||||||
"""Test load_series handles series that fail to load gracefully."""
|
|
||||||
import logging
|
|
||||||
caplog.set_level(logging.ERROR)
|
|
||||||
|
|
||||||
# Create folder with invalid data file
|
|
||||||
folder_name = "Invalid Series"
|
|
||||||
folder_path = os.path.join(temp_directory, folder_name)
|
|
||||||
os.makedirs(folder_path)
|
|
||||||
|
|
||||||
data_path = os.path.join(folder_path, "data")
|
|
||||||
with open(data_path, "w") as f:
|
|
||||||
f.write("invalid json {{{")
|
|
||||||
|
|
||||||
# Load series - should not crash
|
|
||||||
serie_list = SerieList(temp_directory)
|
|
||||||
|
|
||||||
# Verify error logged
|
|
||||||
assert "Failed to load metadata" in caplog.text
|
|
||||||
|
|
||||||
# Should not be in keyDict
|
|
||||||
assert len(serie_list.keyDict) == 0
|
assert len(serie_list.keyDict) == 0
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -24,27 +24,49 @@ def sample_anime_series():
|
|||||||
mock.site = "aniworld.to"
|
mock.site = "aniworld.to"
|
||||||
mock.folder = "Attack on Titan (2013)"
|
mock.folder = "Attack on Titan (2013)"
|
||||||
mock.year = 2013
|
mock.year = 2013
|
||||||
mock.episodes = [
|
|
||||||
MagicMock(season=1, episode_number=1),
|
# Create properly configured episode mocks that work with iteration
|
||||||
MagicMock(season=1, episode_number=2),
|
episode1 = MagicMock(season=1, episode_number=1)
|
||||||
MagicMock(season=1, episode_number=3),
|
episode2 = MagicMock(season=1, episode_number=2)
|
||||||
MagicMock(season=2, episode_number=1),
|
episode3 = MagicMock(season=1, episode_number=3)
|
||||||
MagicMock(season=2, episode_number=2),
|
episode4 = MagicMock(season=2, episode_number=1)
|
||||||
]
|
episode5 = MagicMock(season=2, episode_number=2)
|
||||||
|
mock.episodes = [episode1, episode2, episode3, episode4, episode5]
|
||||||
|
|
||||||
|
# Set _episode_dict_cache to None to force building from episodes
|
||||||
|
mock._episode_dict_cache = None
|
||||||
|
|
||||||
|
# Configure episodeDict as a property that computes from episodes
|
||||||
|
# This mirrors what the real AnimeSeries.episodeDict property does
|
||||||
|
def build_episode_dict():
|
||||||
|
episode_dict = {}
|
||||||
|
for ep in mock.episodes:
|
||||||
|
season = ep.season or 1
|
||||||
|
if season not in episode_dict:
|
||||||
|
episode_dict[season] = []
|
||||||
|
episode_dict[season].append(ep.episode_number or 0)
|
||||||
|
return episode_dict
|
||||||
|
|
||||||
|
# Create a mock property that returns computed dict
|
||||||
|
mock.episodeDict = property(lambda self: build_episode_dict())
|
||||||
|
# But we need it to work when accessed, not as a property object
|
||||||
|
# So configure the mock to return the dict directly when episodeDict is accessed
|
||||||
|
type(mock).episodeDict = property(lambda self: build_episode_dict())
|
||||||
|
|
||||||
return mock
|
return mock
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_serie():
|
def sample_serie():
|
||||||
"""Create a sample Serie for testing."""
|
"""Create a sample AnimeSeries mock for testing."""
|
||||||
return Serie(
|
anime = MagicMock(spec=AnimeSeries)
|
||||||
key="attack-on-titan",
|
anime.key = "attack-on-titan"
|
||||||
name="Attack on Titan",
|
anime.name = "Attack on Titan"
|
||||||
site="aniworld.to",
|
anime.site = "aniworld.to"
|
||||||
folder="Attack on Titan (2013)",
|
anime.folder = "Attack on Titan (2013)"
|
||||||
episodeDict={1: [1, 2, 3], 2: [1, 2]},
|
anime.year = 2013
|
||||||
year=2013
|
anime.episodeDict = {1: [1, 2, 3], 2: [1, 2]}
|
||||||
)
|
return anime
|
||||||
|
|
||||||
|
|
||||||
class TestLoadAllFromDb:
|
class TestLoadAllFromDb:
|
||||||
@@ -63,9 +85,9 @@ class TestLoadAllFromDb:
|
|||||||
"src.server.database.service.AnimeSeriesService.get_all",
|
"src.server.database.service.AnimeSeriesService.get_all",
|
||||||
return_value=[sample_anime_series]
|
return_value=[sample_anime_series]
|
||||||
):
|
):
|
||||||
from src.core.entities.SerieList import SerieList
|
from src.server.database.SerieList import SerieList
|
||||||
|
|
||||||
serie_list = SerieList("/tmp", skip_load=True)
|
serie_list = SerieList("/tmp")
|
||||||
count = await serie_list.load_all_from_db()
|
count = await serie_list.load_all_from_db()
|
||||||
|
|
||||||
assert count == 1
|
assert count == 1
|
||||||
@@ -98,9 +120,9 @@ class TestLoadAllFromDb:
|
|||||||
"src.server.database.service.AnimeSeriesService.get_all",
|
"src.server.database.service.AnimeSeriesService.get_all",
|
||||||
return_value=[sample_anime_series, mock_series2]
|
return_value=[sample_anime_series, mock_series2]
|
||||||
):
|
):
|
||||||
from src.core.entities.SerieList import SerieList
|
from src.server.database.SerieList import SerieList
|
||||||
|
|
||||||
serie_list = SerieList("/tmp", skip_load=True)
|
serie_list = SerieList("/tmp")
|
||||||
count = await serie_list.load_all_from_db()
|
count = await serie_list.load_all_from_db()
|
||||||
|
|
||||||
assert count == 2
|
assert count == 2
|
||||||
@@ -122,9 +144,9 @@ class TestLoadAllFromDb:
|
|||||||
"src.server.database.service.AnimeSeriesService.get_all",
|
"src.server.database.service.AnimeSeriesService.get_all",
|
||||||
return_value=[sample_anime_series]
|
return_value=[sample_anime_series]
|
||||||
):
|
):
|
||||||
from src.core.entities.SerieList import SerieList
|
from src.server.database.SerieList import SerieList
|
||||||
|
|
||||||
serie_list = SerieList("/tmp", skip_load=True)
|
serie_list = SerieList("/tmp")
|
||||||
await serie_list.load_all_from_db()
|
await serie_list.load_all_from_db()
|
||||||
|
|
||||||
serie = serie_list.keyDict["attack-on-titan"]
|
serie = serie_list.keyDict["attack-on-titan"]
|
||||||
@@ -146,9 +168,9 @@ class TestLoadAllFromDb:
|
|||||||
"src.server.database.service.AnimeSeriesService.get_all",
|
"src.server.database.service.AnimeSeriesService.get_all",
|
||||||
return_value=[]
|
return_value=[]
|
||||||
):
|
):
|
||||||
from src.core.entities.SerieList import SerieList
|
from src.server.database.SerieList import SerieList
|
||||||
|
|
||||||
serie_list = SerieList("/tmp", skip_load=True)
|
serie_list = SerieList("/tmp")
|
||||||
count = await serie_list.load_all_from_db()
|
count = await serie_list.load_all_from_db()
|
||||||
|
|
||||||
assert count == 0
|
assert count == 0
|
||||||
@@ -167,9 +189,9 @@ class TestLoadAllFromDb:
|
|||||||
"src.server.database.service.AnimeSeriesService.get_all",
|
"src.server.database.service.AnimeSeriesService.get_all",
|
||||||
side_effect=RuntimeError("Database not initialized")
|
side_effect=RuntimeError("Database not initialized")
|
||||||
):
|
):
|
||||||
from src.core.entities.SerieList import SerieList
|
from src.server.database.SerieList import SerieList
|
||||||
|
|
||||||
serie_list = SerieList("/tmp", skip_load=True)
|
serie_list = SerieList("/tmp")
|
||||||
count = await serie_list.load_all_from_db()
|
count = await serie_list.load_all_from_db()
|
||||||
|
|
||||||
assert count == 0
|
assert count == 0
|
||||||
@@ -194,9 +216,9 @@ class TestLoadSingleSeriesFromDb:
|
|||||||
"src.server.database.service.AnimeSeriesService.get_by_folder",
|
"src.server.database.service.AnimeSeriesService.get_by_folder",
|
||||||
return_value=sample_anime_series
|
return_value=sample_anime_series
|
||||||
):
|
):
|
||||||
from src.core.entities.SerieList import SerieList
|
from src.server.database.SerieList import SerieList
|
||||||
|
|
||||||
serie_list = SerieList("/tmp", skip_load=True)
|
serie_list = SerieList("/tmp")
|
||||||
serie = await serie_list._load_single_series_from_db("Attack on Titan (2013)")
|
serie = await serie_list._load_single_series_from_db("Attack on Titan (2013)")
|
||||||
|
|
||||||
assert serie is not None
|
assert serie is not None
|
||||||
@@ -218,9 +240,9 @@ class TestLoadSingleSeriesFromDb:
|
|||||||
"src.server.database.service.AnimeSeriesService.get_by_folder",
|
"src.server.database.service.AnimeSeriesService.get_by_folder",
|
||||||
return_value=None
|
return_value=None
|
||||||
):
|
):
|
||||||
from src.core.entities.SerieList import SerieList
|
from src.server.database.SerieList import SerieList
|
||||||
|
|
||||||
serie_list = SerieList("/tmp", skip_load=True)
|
serie_list = SerieList("/tmp")
|
||||||
serie = await serie_list._load_single_series_from_db("Unknown Series")
|
serie = await serie_list._load_single_series_from_db("Unknown Series")
|
||||||
|
|
||||||
assert serie is None
|
assert serie is None
|
||||||
@@ -241,9 +263,9 @@ class TestLoadSingleSeriesFromDb:
|
|||||||
"src.server.database.service.AnimeSeriesService.get_by_folder",
|
"src.server.database.service.AnimeSeriesService.get_by_folder",
|
||||||
side_effect=RuntimeError("Database not initialized")
|
side_effect=RuntimeError("Database not initialized")
|
||||||
):
|
):
|
||||||
from src.core.entities.SerieList import SerieList
|
from src.server.database.SerieList import SerieList
|
||||||
|
|
||||||
serie_list = SerieList("/tmp", skip_load=True)
|
serie_list = SerieList("/tmp")
|
||||||
serie = await serie_list._load_single_series_from_db("Some Folder")
|
serie = await serie_list._load_single_series_from_db("Some Folder")
|
||||||
|
|
||||||
assert serie is None
|
assert serie is None
|
||||||
@@ -254,9 +276,9 @@ class TestInvalidateCache:
|
|||||||
|
|
||||||
def test_invalidate_cache_clears_keydict(self, sample_serie):
|
def test_invalidate_cache_clears_keydict(self, sample_serie):
|
||||||
"""Verify invalidate_cache clears the in-memory cache."""
|
"""Verify invalidate_cache clears the in-memory cache."""
|
||||||
from src.core.entities.SerieList import SerieList
|
from src.server.database.SerieList import SerieList
|
||||||
|
|
||||||
serie_list = SerieList("/tmp", skip_load=True)
|
serie_list = SerieList("/tmp")
|
||||||
serie_list.keyDict["attack-on-titan"] = sample_serie
|
serie_list.keyDict["attack-on-titan"] = sample_serie
|
||||||
assert len(serie_list.keyDict) == 1
|
assert len(serie_list.keyDict) == 1
|
||||||
|
|
||||||
@@ -276,9 +298,9 @@ class TestInvalidateCache:
|
|||||||
"src.server.database.service.AnimeSeriesService.get_all",
|
"src.server.database.service.AnimeSeriesService.get_all",
|
||||||
return_value=[sample_anime_series]
|
return_value=[sample_anime_series]
|
||||||
):
|
):
|
||||||
from src.core.entities.SerieList import SerieList
|
from src.server.database.SerieList import SerieList
|
||||||
|
|
||||||
serie_list = SerieList("/tmp", skip_load=True)
|
serie_list = SerieList("/tmp")
|
||||||
serie_list.keyDict["some-key"] = MagicMock()
|
serie_list.keyDict["some-key"] = MagicMock()
|
||||||
|
|
||||||
serie_list.invalidate_cache()
|
serie_list.invalidate_cache()
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
from src.core.SerieScanner import SerieScanner
|
from src.server.SerieScanner import SerieScanner
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -40,14 +40,16 @@ def mock_loader():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_serie():
|
def sample_serie():
|
||||||
"""Create a sample Serie for testing."""
|
"""Create a sample AnimeSeries mock for testing."""
|
||||||
return Serie(
|
anime = MagicMock(spec=AnimeSeries)
|
||||||
key="attack-on-titan",
|
anime.key = "attack-on-titan"
|
||||||
name="Attack on Titan",
|
anime.name = "Attack on Titan"
|
||||||
site="aniworld.to",
|
anime.site = "aniworld.to"
|
||||||
folder="Attack on Titan (2013)",
|
anime.folder = "Attack on Titan (2013)"
|
||||||
episodeDict={1: [2, 3, 4]}
|
anime.year = None
|
||||||
)
|
anime.nfo_path = None
|
||||||
|
anime.episodeDict = {1: [2, 3, 4]}
|
||||||
|
return anime
|
||||||
|
|
||||||
|
|
||||||
class TestSerieScannerInitialization:
|
class TestSerieScannerInitialization:
|
||||||
@@ -134,7 +136,9 @@ class TestSerieScannerScan:
|
|||||||
'_SerieScanner__get_missing_episodes_and_season',
|
'_SerieScanner__get_missing_episodes_and_season',
|
||||||
return_value=({1: [2, 3]}, "aniworld.to")
|
return_value=({1: [2, 3]}, "aniworld.to")
|
||||||
):
|
):
|
||||||
with patch.object(sample_serie, 'save_to_file'):
|
with patch.object(
|
||||||
|
scanner, '_persist_serie_to_db'
|
||||||
|
):
|
||||||
scanner.scan()
|
scanner.scan()
|
||||||
|
|
||||||
assert sample_serie.key in scanner.keyDict
|
assert sample_serie.key in scanner.keyDict
|
||||||
@@ -519,61 +523,17 @@ class TestFindMp4Files:
|
|||||||
class TestReadDataFromFile:
|
class TestReadDataFromFile:
|
||||||
"""Test __read_data_from_file method."""
|
"""Test __read_data_from_file method."""
|
||||||
|
|
||||||
def test_reads_data_file(self, mock_loader):
|
def test_empty_folder_name_returns_none(self, temp_directory, mock_loader):
|
||||||
"""Should read Serie from 'data' file when no DB entry exists."""
|
"""Empty folder name -> returns None (no DB lookup attempted)."""
|
||||||
import tempfile
|
scanner = SerieScanner(temp_directory, mock_loader)
|
||||||
|
result = scanner._SerieScanner__read_data_from_file("")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
def test_nonexistent_folder_no_exception(self, temp_directory, mock_loader):
|
||||||
anime_folder = os.path.join(tmpdir, "SomeAnime")
|
"""Folder doesn't exist -> returns None without raising."""
|
||||||
os.makedirs(anime_folder)
|
scanner = SerieScanner(temp_directory, mock_loader)
|
||||||
|
result = scanner._SerieScanner__read_data_from_file("Nonexistent Folder")
|
||||||
# Create a data file
|
assert result is None
|
||||||
serie = Serie("test-key", "Test", "aniworld.to", "SomeAnime", {})
|
|
||||||
data_path = os.path.join(anime_folder, "data")
|
|
||||||
serie.save_to_file(data_path)
|
|
||||||
|
|
||||||
scanner = SerieScanner(tmpdir, mock_loader)
|
|
||||||
result = scanner._SerieScanner__read_data_from_file("SomeAnime")
|
|
||||||
assert result is not None
|
|
||||||
assert result.key == "test-key"
|
|
||||||
|
|
||||||
def test_no_files_returns_serie_with_generated_key(self, mock_loader):
|
|
||||||
"""Should return Serie with generated key when no key or data file exists."""
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
anime_folder = os.path.join(tmpdir, "Empty")
|
|
||||||
os.makedirs(anime_folder)
|
|
||||||
|
|
||||||
scanner = SerieScanner(tmpdir, mock_loader)
|
|
||||||
result = scanner._SerieScanner__read_data_from_file("Empty")
|
|
||||||
# Step 5 (was Step 4) generates key from folder name when no files exist
|
|
||||||
assert result is not None
|
|
||||||
assert isinstance(result, Serie)
|
|
||||||
assert result.key == "empty"
|
|
||||||
|
|
||||||
def test_scan_key_override_used_instead_of_generated(self, mock_loader):
|
|
||||||
"""Should use override key when folder name matches override dict."""
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
anime_folder = os.path.join(tmpdir, "Anyway, I'm Falling in Love with You (2025)")
|
|
||||||
os.makedirs(anime_folder)
|
|
||||||
|
|
||||||
overrides = {
|
|
||||||
"Anyway, I'm Falling in Love with You (2025)": "anyway-im-falling-in-love-with-you-2025"
|
|
||||||
}
|
|
||||||
scanner = SerieScanner(tmpdir, mock_loader, scan_key_overrides=overrides)
|
|
||||||
result = scanner._SerieScanner__read_data_from_file(
|
|
||||||
"Anyway, I'm Falling in Love with You (2025)"
|
|
||||||
)
|
|
||||||
# Override key should be used instead of generated key
|
|
||||||
assert result is not None
|
|
||||||
assert isinstance(result, Serie)
|
|
||||||
assert result.key == "anyway-im-falling-in-love-with-you-2025"
|
|
||||||
|
|
||||||
|
|
||||||
class TestReinit:
|
|
||||||
"""Test reinit method."""
|
"""Test reinit method."""
|
||||||
|
|
||||||
def test_clears_keydict(self, temp_directory, mock_loader):
|
def test_clears_keydict(self, temp_directory, mock_loader):
|
||||||
@@ -640,12 +600,10 @@ class TestScanProgressEvents:
|
|||||||
call_data = completion_handler.call_args[0][0]
|
call_data = completion_handler.call_args[0][0]
|
||||||
assert call_data["success"] is True
|
assert call_data["success"] is True
|
||||||
|
|
||||||
def test_scan_emits_error_on_no_key(
|
def test_scan_emits_error(
|
||||||
self, temp_directory, mock_loader
|
self, temp_directory, mock_loader
|
||||||
):
|
):
|
||||||
"""Should emit on_error when NoKeyFoundException occurs."""
|
"""Should emit on_error when an exception occurs."""
|
||||||
from src.core.exceptions.Exceptions import NoKeyFoundException
|
|
||||||
|
|
||||||
scanner = SerieScanner(temp_directory, mock_loader)
|
scanner = SerieScanner(temp_directory, mock_loader)
|
||||||
error_handler = MagicMock()
|
error_handler = MagicMock()
|
||||||
scanner.subscribe_on_error(error_handler)
|
scanner.subscribe_on_error(error_handler)
|
||||||
@@ -657,7 +615,7 @@ class TestScanProgressEvents:
|
|||||||
), \
|
), \
|
||||||
patch.object(
|
patch.object(
|
||||||
scanner, '_SerieScanner__read_data_from_file',
|
scanner, '_SerieScanner__read_data_from_file',
|
||||||
side_effect=NoKeyFoundException("no key"),
|
side_effect=RuntimeError("DB error"),
|
||||||
):
|
):
|
||||||
scanner.scan()
|
scanner.scan()
|
||||||
|
|
||||||
@@ -666,186 +624,4 @@ class TestScanProgressEvents:
|
|||||||
assert call_data["recoverable"] is True
|
assert call_data["recoverable"] is True
|
||||||
|
|
||||||
|
|
||||||
class TestDbLookupFallback:
|
|
||||||
"""Tests for the db_lookup callback in SerieScanner."""
|
|
||||||
|
|
||||||
def _make_scanner(self, tmp_dir, mock_loader, db_lookup=None):
|
|
||||||
"""Create a scanner with an optional db_lookup."""
|
|
||||||
# Create a folder with an mp4 but NO key/data file
|
|
||||||
folder = os.path.join(tmp_dir, "Rooster Fighter (2026)")
|
|
||||||
os.makedirs(folder, exist_ok=True)
|
|
||||||
mp4 = os.path.join(folder, "Rooster Fighter - S01E001 - (German Dub).mp4")
|
|
||||||
with open(mp4, "w") as f:
|
|
||||||
f.write("dummy")
|
|
||||||
return SerieScanner(tmp_dir, mock_loader, db_lookup=db_lookup)
|
|
||||||
|
|
||||||
def test_db_lookup_stored_on_init(self, temp_directory, mock_loader):
|
|
||||||
"""db_lookup callable should be stored as _db_lookup."""
|
|
||||||
lookup = MagicMock(return_value=None)
|
|
||||||
scanner = SerieScanner(temp_directory, mock_loader, db_lookup=lookup)
|
|
||||||
assert scanner._db_lookup is lookup
|
|
||||||
|
|
||||||
def test_no_db_lookup_defaults_to_none(self, temp_directory, mock_loader):
|
|
||||||
"""Without db_lookup, _db_lookup should be None."""
|
|
||||||
scanner = SerieScanner(temp_directory, mock_loader)
|
|
||||||
assert scanner._db_lookup is None
|
|
||||||
|
|
||||||
def test_db_lookup_called_when_no_files(self, mock_loader):
|
|
||||||
"""db_lookup is called when neither key nor data file exists."""
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
lookup = MagicMock(return_value=None)
|
|
||||||
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
|
|
||||||
|
|
||||||
with patch.object(scanner, 'get_total_to_scan', return_value=1), \
|
|
||||||
patch.object(
|
|
||||||
scanner,
|
|
||||||
'_SerieScanner__get_missing_episodes_and_season',
|
|
||||||
return_value=({}, "aniworld.to"),
|
|
||||||
):
|
|
||||||
scanner.scan()
|
|
||||||
|
|
||||||
lookup.assert_called_once_with("Rooster Fighter (2026)")
|
|
||||||
|
|
||||||
def test_db_lookup_not_called_when_key_file_exists(self, mock_loader):
|
|
||||||
"""db_lookup is NOT called when a key file is present."""
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
folder = os.path.join(tmp_dir, "Rooster Fighter (2026)")
|
|
||||||
os.makedirs(folder, exist_ok=True)
|
|
||||||
mp4 = os.path.join(folder, "S01E001.mp4")
|
|
||||||
with open(mp4, "w") as f:
|
|
||||||
f.write("dummy")
|
|
||||||
with open(os.path.join(folder, "key"), "w") as f:
|
|
||||||
f.write("rooster-fighter")
|
|
||||||
|
|
||||||
lookup = MagicMock(return_value=None)
|
|
||||||
scanner = SerieScanner(tmp_dir, mock_loader, db_lookup=lookup)
|
|
||||||
|
|
||||||
with patch.object(scanner, 'get_total_to_scan', return_value=1), \
|
|
||||||
patch.object(
|
|
||||||
scanner,
|
|
||||||
'_SerieScanner__get_missing_episodes_and_season',
|
|
||||||
return_value=({1: []}, "aniworld.to"),
|
|
||||||
), \
|
|
||||||
patch.object(
|
|
||||||
SerieScanner,
|
|
||||||
'_SerieScanner__read_data_from_file',
|
|
||||||
return_value=Serie(
|
|
||||||
key="rooster-fighter", name="", site="aniworld.to",
|
|
||||||
folder="Rooster Fighter (2026)", episodeDict={},
|
|
||||||
),
|
|
||||||
):
|
|
||||||
scanner.scan()
|
|
||||||
|
|
||||||
lookup.assert_not_called()
|
|
||||||
|
|
||||||
def test_db_lookup_resolves_serie_and_scans(self, mock_loader):
|
|
||||||
"""When db_lookup returns a Serie, scanning continues normally."""
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
resolved = Serie(
|
|
||||||
key="rooster-fighter",
|
|
||||||
name="Rooster Fighter",
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="Rooster Fighter (2026)",
|
|
||||||
episodeDict={},
|
|
||||||
year=2026,
|
|
||||||
)
|
|
||||||
lookup = MagicMock(return_value=resolved)
|
|
||||||
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
|
|
||||||
|
|
||||||
with patch.object(scanner, 'get_total_to_scan', return_value=1), \
|
|
||||||
patch.object(
|
|
||||||
scanner,
|
|
||||||
'_SerieScanner__get_missing_episodes_and_season',
|
|
||||||
return_value=({1: [1, 2, 3]}, "aniworld.to"),
|
|
||||||
), \
|
|
||||||
patch.object(resolved, 'save_to_file'):
|
|
||||||
scanner.scan()
|
|
||||||
|
|
||||||
assert "rooster-fighter" in scanner.keyDict
|
|
||||||
assert scanner.keyDict["rooster-fighter"].episodeDict == {1: [1, 2, 3]}
|
|
||||||
|
|
||||||
def test_db_lookup_returns_none_folder_skipped(self, mock_loader):
|
|
||||||
"""When db_lookup returns None, Step 4 fallback generates key from folder name."""
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
lookup = MagicMock(return_value=None)
|
|
||||||
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
|
|
||||||
|
|
||||||
with patch.object(scanner, 'get_total_to_scan', return_value=1):
|
|
||||||
scanner.scan()
|
|
||||||
|
|
||||||
# Step 4 generates key from folder name, so keyDict is not empty
|
|
||||||
assert len(scanner.keyDict) == 1
|
|
||||||
|
|
||||||
def test_db_lookup_exception_skips_folder(self, mock_loader):
|
|
||||||
"""When db_lookup raises, Step 4 fallback generates key from folder name."""
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
lookup = MagicMock(side_effect=RuntimeError("DB offline"))
|
|
||||||
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
|
|
||||||
|
|
||||||
with patch.object(scanner, 'get_total_to_scan', return_value=1):
|
|
||||||
scanner.scan() # should not raise
|
|
||||||
|
|
||||||
# Step 4 generates key from folder name, so keyDict is not empty
|
|
||||||
assert len(scanner.keyDict) == 1
|
|
||||||
|
|
||||||
def test_db_lookup_warning_logged_when_no_files(
|
|
||||||
self, mock_loader, caplog
|
|
||||||
):
|
|
||||||
"""A warning is logged for folders without key/data file."""
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=None)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="src.core.SerieScanner"):
|
|
||||||
with patch.object(scanner, 'get_total_to_scan', return_value=1):
|
|
||||||
scanner.scan()
|
|
||||||
|
|
||||||
assert any(
|
|
||||||
"Rooster Fighter (2026)" in record.message
|
|
||||||
for record in caplog.records
|
|
||||||
if record.levelname == "WARNING"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_db_lookup_info_logged_on_resolution(
|
|
||||||
self, mock_loader, caplog
|
|
||||||
):
|
|
||||||
"""An INFO log is emitted when db_lookup resolves a folder."""
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
resolved = Serie(
|
|
||||||
key="rooster-fighter",
|
|
||||||
name="",
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="Rooster Fighter (2026)",
|
|
||||||
episodeDict={},
|
|
||||||
)
|
|
||||||
lookup = MagicMock(return_value=resolved)
|
|
||||||
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.INFO, logger="src.core.SerieScanner"), \
|
|
||||||
patch.object(scanner, 'get_total_to_scan', return_value=1), \
|
|
||||||
patch.object(
|
|
||||||
scanner,
|
|
||||||
'_SerieScanner__get_missing_episodes_and_season',
|
|
||||||
return_value=({}, "aniworld.to"),
|
|
||||||
), \
|
|
||||||
patch.object(resolved, 'save_to_file'):
|
|
||||||
scanner.scan()
|
|
||||||
|
|
||||||
assert any(
|
|
||||||
"rooster-fighter" in record.message
|
|
||||||
for record in caplog.records
|
|
||||||
if record.levelname == "INFO"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
from src.core.SerieScanner import SerieScanner
|
from src.server.SerieScanner import SerieScanner
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -51,7 +51,7 @@ class TestGetSerieFromFolderDbLookup:
|
|||||||
mock_anime_series.episodes = []
|
mock_anime_series.episodes = []
|
||||||
mock_session.execute.return_value.scalar_one_or_none.return_value = mock_anime_series
|
mock_session.execute.return_value.scalar_one_or_none.return_value = mock_anime_series
|
||||||
|
|
||||||
with patch("src.core.SerieScanner.get_sync_session", return_value=mock_session):
|
with patch("src.server.SerieScanner.get_sync_session", return_value=mock_session):
|
||||||
scanner = SerieScanner(temp_directory, mock_loader)
|
scanner = SerieScanner(temp_directory, mock_loader)
|
||||||
result = scanner._SerieScanner__read_data_from_file("Rooster Fighter (2026)")
|
result = scanner._SerieScanner__read_data_from_file("Rooster Fighter (2026)")
|
||||||
|
|
||||||
@@ -60,48 +60,30 @@ class TestGetSerieFromFolderDbLookup:
|
|||||||
assert result.name == "Rooster Fighter"
|
assert result.name == "Rooster Fighter"
|
||||||
assert result.year == 2026
|
assert result.year == 2026
|
||||||
|
|
||||||
def test_db_miss_falls_back_to_provider_callback(self, temp_directory, mock_loader):
|
def test_db_miss_returns_none(self, temp_directory, mock_loader):
|
||||||
"""DB miss -> _db_lookup callback called."""
|
"""DB miss -> returns None (no fallback)."""
|
||||||
lookup = MagicMock(return_value=Serie(
|
mock_session = MagicMock()
|
||||||
key="rooster-fighter",
|
mock_session.execute.return_value.scalar_one_or_none.return_value = None
|
||||||
name="Rooster Fighter",
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="Rooster Fighter (2026)",
|
|
||||||
episodeDict={},
|
|
||||||
))
|
|
||||||
scanner = SerieScanner(temp_directory, mock_loader, db_lookup=lookup)
|
|
||||||
|
|
||||||
result = scanner._SerieScanner__read_data_from_file("Rooster Fighter (2026)")
|
with patch("src.server.SerieScanner.get_sync_session", return_value=mock_session):
|
||||||
|
scanner = SerieScanner(temp_directory, mock_loader)
|
||||||
|
result = scanner._SerieScanner__read_data_from_file("Unknown Series (2026)")
|
||||||
|
|
||||||
assert result is not None
|
assert result is None
|
||||||
assert result.key == "rooster-fighter"
|
|
||||||
lookup.assert_called_once_with("Rooster Fighter (2026)")
|
|
||||||
|
|
||||||
def test_no_db_no_callback_generates_key_from_folder_name(self, temp_directory, mock_loader):
|
|
||||||
"""No DB entry, no callback -> key generated from folder name."""
|
|
||||||
folder = os.path.join(temp_directory, "Legacy Series")
|
|
||||||
os.makedirs(folder, exist_ok=True)
|
|
||||||
# No key file, no data file - should fall through to Step 4 (key generation)
|
|
||||||
|
|
||||||
scanner = SerieScanner(temp_directory, mock_loader)
|
|
||||||
|
|
||||||
result = scanner._SerieScanner__read_data_from_file("Legacy Series")
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert result.key == "legacy-series"
|
|
||||||
assert result.folder == "Legacy Series"
|
|
||||||
|
|
||||||
def test_db_lookup_exception_caught_and_logged(self, temp_directory, mock_loader):
|
def test_db_lookup_exception_caught_and_logged(self, temp_directory, mock_loader):
|
||||||
"""DB exception -> fallback to provider callback."""
|
"""DB exception -> returns None without raising."""
|
||||||
def bad_lookup(folder):
|
with patch(
|
||||||
raise RuntimeError("DB connection failed")
|
"src.server.SerieScanner.get_sync_session",
|
||||||
|
side_effect=RuntimeError("DB connection failed")
|
||||||
|
):
|
||||||
|
scanner = SerieScanner(temp_directory, mock_loader)
|
||||||
|
|
||||||
scanner = SerieScanner(temp_directory, mock_loader, db_lookup=bad_lookup)
|
with patch.object(logging.getLogger("src.server.SerieScanner"), "warning") as mock_warning:
|
||||||
|
result = scanner._SerieScanner__read_data_from_file("Rooster Fighter (2026)")
|
||||||
with patch.object(logging.getLogger("src.core.SerieScanner"), "warning") as mock_warning:
|
mock_warning.assert_called()
|
||||||
result = scanner._SerieScanner__read_data_from_file("Rooster Fighter (2026)")
|
assert any("DB lookup failed" in str(c) for c in mock_warning.call_args_list)
|
||||||
mock_warning.assert_called()
|
assert result is None
|
||||||
assert any("DB lookup failed" in str(c) for c in mock_warning.call_args_list)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetSerieFromFolderEdgeCases:
|
class TestGetSerieFromFolderEdgeCases:
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
from src.core.SerieScanner import SerieScanner
|
from src.server.SerieScanner import SerieScanner
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -18,15 +18,15 @@ def mock_session_factory():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_serie():
|
def sample_serie():
|
||||||
"""Create a sample Serie for testing."""
|
"""Create a sample AnimeSeries mock for testing."""
|
||||||
return Serie(
|
anime = MagicMock(spec=AnimeSeries)
|
||||||
key="attack-on-titan",
|
anime.key = "attack-on-titan"
|
||||||
name="Attack on Titan",
|
anime.name = "Attack on Titan"
|
||||||
site="aniworld.to",
|
anime.site = "aniworld.to"
|
||||||
folder="Attack on Titan (2013)",
|
anime.folder = "Attack on Titan (2013)"
|
||||||
episodeDict={1: [1, 2, 3], 2: [1, 2]},
|
anime.year = 2013
|
||||||
year=2013
|
anime.episodeDict = {1: [1, 2, 3], 2: [1, 2]}
|
||||||
)
|
return anime
|
||||||
|
|
||||||
|
|
||||||
class TestPersistSerieToDb:
|
class TestPersistSerieToDb:
|
||||||
|
|||||||
@@ -10,19 +10,19 @@ Tests the functionality of SeriesApp including:
|
|||||||
- Error handling
|
- Error handling
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.SeriesApp import SeriesApp
|
from src.server.SeriesApp import SeriesApp
|
||||||
|
|
||||||
|
|
||||||
class TestSeriesAppInitialization:
|
class TestSeriesAppInitialization:
|
||||||
"""Test SeriesApp initialization."""
|
"""Test SeriesApp initialization."""
|
||||||
|
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
def test_init_success(
|
def test_init_success(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders
|
self, mock_serie_list, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
@@ -37,7 +37,7 @@ class TestSeriesAppInitialization:
|
|||||||
mock_loaders.assert_called_once()
|
mock_loaders.assert_called_once()
|
||||||
mock_scanner.assert_called_once()
|
mock_scanner.assert_called_once()
|
||||||
|
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
def test_init_failure_raises_error(self, mock_loaders):
|
def test_init_failure_raises_error(self, mock_loaders):
|
||||||
"""Test that initialization failure raises error."""
|
"""Test that initialization failure raises error."""
|
||||||
test_dir = "/test/anime"
|
test_dir = "/test/anime"
|
||||||
@@ -49,10 +49,10 @@ class TestSeriesAppInitialization:
|
|||||||
with pytest.raises(RuntimeError):
|
with pytest.raises(RuntimeError):
|
||||||
SeriesApp(test_dir)
|
SeriesApp(test_dir)
|
||||||
|
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
@patch('src.core.SeriesApp.settings')
|
@patch('src.server.SeriesApp.settings')
|
||||||
def test_init_uses_config_fallback_for_nfo_service(
|
def test_init_uses_config_fallback_for_nfo_service(
|
||||||
self,
|
self,
|
||||||
mock_settings,
|
mock_settings,
|
||||||
@@ -71,9 +71,9 @@ class TestSeriesAppSearch:
|
|||||||
"""Test search functionality."""
|
"""Test search functionality."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
async def test_search_success(
|
async def test_search_success(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders
|
self, mock_serie_list, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
@@ -96,9 +96,9 @@ class TestSeriesAppSearch:
|
|||||||
app.loader.search.assert_called_once_with("test anime")
|
app.loader.search.assert_called_once_with("test anime")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
async def test_search_failure_raises_error(
|
async def test_search_failure_raises_error(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders
|
self, mock_serie_list, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
@@ -120,9 +120,9 @@ class TestSeriesAppDownload:
|
|||||||
"""Test download functionality."""
|
"""Test download functionality."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
async def test_download_success(
|
async def test_download_success(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders, tmp_path
|
self, mock_serie_list, mock_scanner, mock_loaders, tmp_path
|
||||||
):
|
):
|
||||||
@@ -157,9 +157,9 @@ class TestSeriesAppDownload:
|
|||||||
assert os.path.exists(folder_path)
|
assert os.path.exists(folder_path)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
async def test_download_with_progress_callback(
|
async def test_download_with_progress_callback(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders, tmp_path
|
self, mock_serie_list, mock_scanner, mock_loaders, tmp_path
|
||||||
):
|
):
|
||||||
@@ -197,9 +197,9 @@ class TestSeriesAppDownload:
|
|||||||
app.loader.download.assert_called_once()
|
app.loader.download.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
async def test_download_cancellation(
|
async def test_download_cancellation(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders, tmp_path
|
self, mock_serie_list, mock_scanner, mock_loaders, tmp_path
|
||||||
):
|
):
|
||||||
@@ -234,9 +234,9 @@ class TestSeriesAppDownload:
|
|||||||
assert app._events.download_status.called
|
assert app._events.download_status.called
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
async def test_download_failure(
|
async def test_download_failure(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders
|
self, mock_serie_list, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
@@ -268,9 +268,9 @@ class TestSeriesAppReScan:
|
|||||||
"""Test directory scanning functionality."""
|
"""Test directory scanning functionality."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
async def test_rescan_success(
|
async def test_rescan_success(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders
|
self, mock_serie_list, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
@@ -295,9 +295,9 @@ class TestSeriesAppReScan:
|
|||||||
app.serie_scanner.scan.assert_called_once()
|
app.serie_scanner.scan.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
async def test_rescan_with_events(
|
async def test_rescan_with_events(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders
|
self, mock_serie_list, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
@@ -327,9 +327,9 @@ class TestSeriesAppReScan:
|
|||||||
app.serie_scanner.unsubscribe_on_progress.assert_called_once()
|
app.serie_scanner.unsubscribe_on_progress.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
async def test_rescan_cancellation(
|
async def test_rescan_cancellation(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders
|
self, mock_serie_list, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
@@ -359,9 +359,9 @@ class TestSeriesAppReScan:
|
|||||||
class TestSeriesAppCancellation:
|
class TestSeriesAppCancellation:
|
||||||
"""Test operation cancellation."""
|
"""Test operation cancellation."""
|
||||||
|
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
def test_cancel_operation_when_running(
|
def test_cancel_operation_when_running(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders
|
self, mock_serie_list, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
@@ -373,9 +373,9 @@ class TestSeriesAppCancellation:
|
|||||||
# as the cancel mechanism may have changed
|
# as the cancel mechanism may have changed
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
def test_cancel_operation_when_idle(
|
def test_cancel_operation_when_idle(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders
|
self, mock_serie_list, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
@@ -387,9 +387,9 @@ class TestSeriesAppCancellation:
|
|||||||
class TestSeriesAppGetters:
|
class TestSeriesAppGetters:
|
||||||
"""Test getter methods."""
|
"""Test getter methods."""
|
||||||
|
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
def test_get_series_list(
|
def test_get_series_list(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders
|
self, mock_serie_list, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
@@ -400,9 +400,9 @@ class TestSeriesAppGetters:
|
|||||||
# Verify app was created
|
# Verify app was created
|
||||||
assert app is not None
|
assert app is not None
|
||||||
|
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
def test_get_operation_status(
|
def test_get_operation_status(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders
|
self, mock_serie_list, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
@@ -410,9 +410,9 @@ class TestSeriesAppGetters:
|
|||||||
# Skip - operation status API may have changed
|
# Skip - operation status API may have changed
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
def test_get_current_operation(
|
def test_get_current_operation(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders
|
self, mock_serie_list, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
@@ -424,9 +424,9 @@ class TestSeriesAppGetters:
|
|||||||
class TestSeriesAppDatabaseInit:
|
class TestSeriesAppDatabaseInit:
|
||||||
"""Test SeriesApp initialization (no database support in core)."""
|
"""Test SeriesApp initialization (no database support in core)."""
|
||||||
|
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
def test_init_creates_components(
|
def test_init_creates_components(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders
|
self, mock_serie_list, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
@@ -446,45 +446,39 @@ class TestSeriesAppDatabaseInit:
|
|||||||
class TestSeriesAppLoadSeriesFromList:
|
class TestSeriesAppLoadSeriesFromList:
|
||||||
"""Test SeriesApp load_series_from_list method."""
|
"""Test SeriesApp load_series_from_list method."""
|
||||||
|
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
def test_load_series_from_list_populates_keydict(
|
def test_load_series_from_list_populates_keydict(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders
|
self, mock_serie_list, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
"""Test load_series_from_list populates the list correctly."""
|
"""Test load_series_from_list populates the list correctly."""
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
|
|
||||||
test_dir = "/test/anime"
|
test_dir = "/test/anime"
|
||||||
mock_list = Mock()
|
mock_list = Mock()
|
||||||
mock_list.GetMissingEpisode.return_value = []
|
mock_list.GetMissingEpisode.return_value = []
|
||||||
mock_list.keyDict = {}
|
mock_list.keyDict = {}
|
||||||
mock_serie_list.return_value = mock_list
|
mock_serie_list.return_value = mock_list
|
||||||
|
|
||||||
# Create app
|
# Create app
|
||||||
app = SeriesApp(test_dir)
|
app = SeriesApp(test_dir)
|
||||||
|
|
||||||
# Create test series
|
# Create test series (AnimeSeries mocks)
|
||||||
test_series = [
|
def make_anime(key, name, folder):
|
||||||
Serie(
|
anime = MagicMock(spec=AnimeSeries)
|
||||||
key="anime1",
|
anime.key = key
|
||||||
name="Anime 1",
|
anime.name = name
|
||||||
site="aniworld.to",
|
anime.site = "aniworld.to"
|
||||||
folder="Anime 1",
|
anime.folder = folder
|
||||||
episodeDict={1: [1, 2]}
|
anime.episodeDict = {1: [1, 2]} if key == "anime1" else {1: [1]}
|
||||||
),
|
return anime
|
||||||
Serie(
|
|
||||||
key="anime2",
|
test_series = [make_anime("anime1", "Anime 1", "Anime 1"), make_anime("anime2", "Anime 2", "Anime 2")]
|
||||||
name="Anime 2",
|
|
||||||
site="aniworld.to",
|
|
||||||
folder="Anime 2",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Load series
|
# Load series
|
||||||
app.load_series_from_list(test_series)
|
app.load_series_from_list(test_series)
|
||||||
|
|
||||||
# Verify series were loaded
|
# Verify series were loaded
|
||||||
assert "anime1" in mock_list.keyDict
|
assert "anime1" in mock_list.keyDict
|
||||||
assert "anime2" in mock_list.keyDict
|
assert "anime2" in mock_list.keyDict
|
||||||
@@ -493,33 +487,30 @@ class TestSeriesAppLoadSeriesFromList:
|
|||||||
class TestSeriesAppGetAllSeriesFromDataFiles:
|
class TestSeriesAppGetAllSeriesFromDataFiles:
|
||||||
"""Test get_all_series_from_data_files() functionality."""
|
"""Test get_all_series_from_data_files() functionality."""
|
||||||
|
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
def test_returns_list_of_series(
|
def test_returns_list_of_series(
|
||||||
self, mock_serie_list_class, mock_scanner, mock_loaders
|
self, mock_serie_list_class, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
"""Test that get_all_series_from_data_files returns a list of Serie."""
|
"""Test that get_all_series_from_data_files returns a list of AnimeSeries."""
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
|
|
||||||
test_dir = "/test/anime"
|
test_dir = "/test/anime"
|
||||||
|
|
||||||
|
def make_anime(key, name, folder):
|
||||||
|
anime = MagicMock(spec=AnimeSeries)
|
||||||
|
anime.key = key
|
||||||
|
anime.name = name
|
||||||
|
anime.site = "https://aniworld.to"
|
||||||
|
anime.folder = folder
|
||||||
|
anime.episodeDict = {1: [1, 2, 3]} if key == "anime1" else {1: [1, 2]}
|
||||||
|
return anime
|
||||||
|
|
||||||
# Mock series to return
|
# Mock series to return
|
||||||
mock_series = [
|
mock_series = [
|
||||||
Serie(
|
make_anime("anime1", "Anime 1", "Anime 1 (2020)"),
|
||||||
key="anime1",
|
make_anime("anime2", "Anime 2", "Anime 2 (2021)"),
|
||||||
name="Anime 1",
|
|
||||||
site="https://aniworld.to",
|
|
||||||
folder="Anime 1 (2020)",
|
|
||||||
episodeDict={1: [1, 2, 3]}
|
|
||||||
),
|
|
||||||
Serie(
|
|
||||||
key="anime2",
|
|
||||||
name="Anime 2",
|
|
||||||
site="https://aniworld.to",
|
|
||||||
folder="Anime 2 (2021)",
|
|
||||||
episodeDict={1: [1]}
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Setup mock for the main SerieList instance (constructor call)
|
# Setup mock for the main SerieList instance (constructor call)
|
||||||
@@ -539,16 +530,16 @@ class TestSeriesAppGetAllSeriesFromDataFiles:
|
|||||||
# Call the method
|
# Call the method
|
||||||
result = app.get_all_series_from_data_files()
|
result = app.get_all_series_from_data_files()
|
||||||
|
|
||||||
# Verify result is a list of Serie
|
# Verify result is a list of AnimeSeries
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
assert all(isinstance(s, Serie) for s in result)
|
assert all(isinstance(s, MagicMock) for s in result)
|
||||||
assert result[0].key == "anime1"
|
assert result[0].key == "anime1"
|
||||||
assert result[1].key == "anime2"
|
assert result[1].key == "anime2"
|
||||||
|
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
def test_returns_empty_list_when_no_data_files(
|
def test_returns_empty_list_when_no_data_files(
|
||||||
self, mock_serie_list_class, mock_scanner, mock_loaders
|
self, mock_serie_list_class, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
@@ -575,9 +566,9 @@ class TestSeriesAppGetAllSeriesFromDataFiles:
|
|||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
assert len(result) == 0
|
assert len(result) == 0
|
||||||
|
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
def test_handles_exception_gracefully(
|
def test_handles_exception_gracefully(
|
||||||
self, mock_serie_list_class, mock_scanner, mock_loaders
|
self, mock_serie_list_class, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
@@ -604,13 +595,13 @@ class TestSeriesAppGetAllSeriesFromDataFiles:
|
|||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
assert len(result) == 0
|
assert len(result) == 0
|
||||||
|
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
def test_uses_file_based_loading(
|
def test_uses_file_based_loading(
|
||||||
self, mock_serie_list_class, mock_scanner, mock_loaders
|
self, mock_serie_list_class, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
"""Test that method uses file-based loading (no db_session)."""
|
"""Test that method uses SerieList for file-based loading."""
|
||||||
test_dir = "/test/anime"
|
test_dir = "/test/anime"
|
||||||
|
|
||||||
# Setup mock for the main SerieList instance
|
# Setup mock for the main SerieList instance
|
||||||
@@ -629,24 +620,23 @@ class TestSeriesAppGetAllSeriesFromDataFiles:
|
|||||||
# Call the method
|
# Call the method
|
||||||
app.get_all_series_from_data_files()
|
app.get_all_series_from_data_files()
|
||||||
|
|
||||||
# Verify the second SerieList was created with correct params
|
# Verify SerieList was called twice (main + temp)
|
||||||
# (file-based loading: db_session=None, skip_load=False)
|
|
||||||
calls = mock_serie_list_class.call_args_list
|
calls = mock_serie_list_class.call_args_list
|
||||||
assert len(calls) == 2
|
assert len(calls) == 2
|
||||||
|
|
||||||
# Check the second call (for get_all_series_from_data_files)
|
# Check the second call is for temp SerieList with directory
|
||||||
second_call = calls[1]
|
second_call = calls[1]
|
||||||
assert second_call.kwargs.get('db_session') is None
|
# base_path is passed as positional argument
|
||||||
assert second_call.kwargs.get('skip_load') is False
|
assert second_call.args[0] == test_dir
|
||||||
|
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.server.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.server.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.server.SeriesApp.SerieList')
|
||||||
def test_does_not_modify_main_list(
|
def test_does_not_modify_main_list(
|
||||||
self, mock_serie_list_class, mock_scanner, mock_loaders
|
self, mock_serie_list_class, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
"""Test that method does not modify the main SerieList instance."""
|
"""Test that method does not modify the main SerieList instance."""
|
||||||
from src.core.entities.series import Serie
|
from src.server.database.models import AnimeSeries
|
||||||
|
|
||||||
test_dir = "/test/anime"
|
test_dir = "/test/anime"
|
||||||
|
|
||||||
@@ -657,15 +647,13 @@ class TestSeriesAppGetAllSeriesFromDataFiles:
|
|||||||
|
|
||||||
# Setup mock for the temporary SerieList
|
# Setup mock for the temporary SerieList
|
||||||
mock_temp_list = Mock()
|
mock_temp_list = Mock()
|
||||||
mock_temp_list.get_all.return_value = [
|
anime = MagicMock(spec=AnimeSeries)
|
||||||
Serie(
|
anime.key = "anime1"
|
||||||
key="anime1",
|
anime.name = "Anime 1"
|
||||||
name="Anime 1",
|
anime.site = "https://aniworld.to"
|
||||||
site="https://aniworld.to",
|
anime.folder = "Anime 1"
|
||||||
folder="Anime 1",
|
anime.episodeDict = {}
|
||||||
episodeDict={}
|
mock_temp_list.get_all.return_value = [anime]
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
mock_serie_list_class.side_effect = [mock_main_list, mock_temp_list]
|
mock_serie_list_class.side_effect = [mock_main_list, mock_temp_list]
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import aiohttp
|
|||||||
import pytest
|
import pytest
|
||||||
from aiohttp import ClientResponseError, ClientSession
|
from aiohttp import ClientResponseError, ClientSession
|
||||||
|
|
||||||
from src.core.services.tmdb_client import TMDBAPIError, TMDBClient
|
from src.server.services_nfo_temp.tmdb_client import TMDBAPIError, TMDBClient
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.services.tmdb_client import TMDBAPIError, TMDBClient
|
from src.server.services_nfo_temp.tmdb_client import TMDBAPIError, TMDBClient
|
||||||
|
|
||||||
|
|
||||||
def _make_ctx(response):
|
def _make_ctx(response):
|
||||||
|
|||||||
Reference in New Issue
Block a user