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)
|
||||
|
||||
# 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)
|
||||
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 src.config.settings import settings
|
||||
from src.core.entities.series import Serie
|
||||
from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
|
||||
from src.core.providers.base_provider import Loader
|
||||
from src.core.utils.key_utils import generate_key_from_folder
|
||||
from src.server.database.models import AnimeSeries
|
||||
from src.server.exceptions.exceptions.Exceptions import MatchNotFoundError
|
||||
from src.server.providers.base_provider import Loader
|
||||
from src.server.database.connection import get_sync_session
|
||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||
|
||||
@@ -53,23 +52,12 @@ class SerieScanner:
|
||||
# scan() detects running event loop and uses create_task()
|
||||
# internally, so no special handling needed by caller.
|
||||
# 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__(
|
||||
self,
|
||||
basePath: str,
|
||||
loader: Loader,
|
||||
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
|
||||
scan_key_overrides: Optional[dict[str, str]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the SerieScanner.
|
||||
@@ -77,15 +65,6 @@ class SerieScanner:
|
||||
Args:
|
||||
basePath: Base directory containing anime series
|
||||
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:
|
||||
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}")
|
||||
|
||||
self.directory: str = abs_path
|
||||
self.keyDict: dict[str, Serie] = {}
|
||||
self.keyDict: dict[str, AnimeSeries] = {}
|
||||
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.events = Events()
|
||||
|
||||
@@ -242,64 +219,63 @@ class SerieScanner:
|
||||
self.events.on_completion.remove(handler)
|
||||
|
||||
def reinit(self) -> None:
|
||||
"""Reinitialize the series dictionary (keyed by serie.key)."""
|
||||
self.keyDict: dict[str, Serie] = {}
|
||||
"""Reinitialize the series dictionary (keyed by anime.key)."""
|
||||
self.keyDict: dict[str, AnimeSeries] = {}
|
||||
|
||||
async def _persist_serie_to_db(self, serie: Serie) -> None:
|
||||
"""Persist serie to database (create or update).
|
||||
async def _persist_serie_to_db(self, anime: AnimeSeries) -> None:
|
||||
"""Persist anime to database (create or update).
|
||||
|
||||
Args:
|
||||
serie: Serie domain object to persist
|
||||
anime: AnimeSeries model to persist
|
||||
"""
|
||||
try:
|
||||
from src.server.database.connection import get_async_session_factory
|
||||
|
||||
db = get_async_session_factory()
|
||||
try:
|
||||
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
||||
existing = await AnimeSeriesService.get_by_key(db, anime.key)
|
||||
if existing:
|
||||
await AnimeSeriesService.update(
|
||||
db, existing.id,
|
||||
name=serie.name,
|
||||
folder=serie.folder,
|
||||
year=serie.year
|
||||
name=anime.name,
|
||||
folder=anime.folder,
|
||||
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:
|
||||
anime_series = await AnimeSeriesService.create(
|
||||
db_anime = await AnimeSeriesService.create(
|
||||
db=db,
|
||||
key=serie.key,
|
||||
name=serie.name,
|
||||
site=serie.site,
|
||||
folder=serie.folder,
|
||||
year=serie.year
|
||||
key=anime.key,
|
||||
name=anime.name,
|
||||
site=anime.site,
|
||||
folder=anime.folder,
|
||||
year=anime.year
|
||||
)
|
||||
for season, eps in serie.episodeDict.items():
|
||||
for ep in eps:
|
||||
for ep in anime.episodes:
|
||||
await EpisodeService.create(
|
||||
db=db,
|
||||
series_id=anime_series.id,
|
||||
season=season,
|
||||
episode_number=ep
|
||||
series_id=db_anime.id,
|
||||
season=ep.season,
|
||||
episode_number=ep.episode_number
|
||||
)
|
||||
await db.commit()
|
||||
logger.debug(
|
||||
"Persisted serie '%s' (key=%s) to database",
|
||||
serie.name, serie.key
|
||||
"Persisted anime '%s' (key=%s) to database",
|
||||
anime.name, anime.key
|
||||
)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
"Failed to persist serie '%s' to DB: %s",
|
||||
serie.key, e, exc_info=True
|
||||
"Failed to persist anime '%s' to DB: %s",
|
||||
anime.key, e, exc_info=True
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
await db.close()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Could not persist serie '%s' to DB (DB unavailable?): %s",
|
||||
serie.key, e
|
||||
"Could not persist anime '%s' to DB (DB unavailable?): %s",
|
||||
anime.key, e
|
||||
)
|
||||
|
||||
async def _sync_episodes_to_db(
|
||||
@@ -419,59 +395,15 @@ class SerieScanner:
|
||||
serie = self.__read_data_from_file(folder)
|
||||
if serie is None or not serie.key or not serie.key.strip():
|
||||
logger.warning(
|
||||
"No key or data file found for folder '%s', skipping",
|
||||
"No series found in DB for folder '%s', skipping",
|
||||
folder,
|
||||
)
|
||||
continue
|
||||
if (
|
||||
serie is not None
|
||||
and serie.key
|
||||
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
|
||||
# remote metadata, yielding missing episodes per
|
||||
# season. Results are saved back to disk so that both
|
||||
@@ -536,21 +468,6 @@ class SerieScanner:
|
||||
"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:
|
||||
# Log error and notify via callback
|
||||
error_msg = (
|
||||
@@ -639,49 +556,25 @@ class SerieScanner:
|
||||
has_files = True
|
||||
yield anime_name, mp4_files if has_files else []
|
||||
|
||||
def __read_data_from_file(self, folder_name: str) -> Optional[Serie]:
|
||||
"""Load or discover a Serie for the given folder.
|
||||
def __read_data_from_file(self, folder_name: str) -> Optional[AnimeSeries]:
|
||||
"""Load or discover an AnimeSeries for the given folder.
|
||||
|
||||
Strategy:
|
||||
1. Query DB by folder name
|
||||
2. If found, return cached Serie object
|
||||
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
|
||||
2. If not found in DB, return None (no file fallback)
|
||||
|
||||
Args:
|
||||
folder_name: Filesystem folder name
|
||||
|
||||
Returns:
|
||||
Serie object with valid key if found, None otherwise
|
||||
|
||||
Note:
|
||||
DB is the source of truth. File-based lookups (data files)
|
||||
are temporary backward compatibility for CLI-only deployments.
|
||||
AnimeSeries object if found in DB, None otherwise
|
||||
"""
|
||||
# Step 1: Try DB lookup by folder name
|
||||
try:
|
||||
session = get_sync_session()
|
||||
try:
|
||||
anime_series = AnimeSeriesService.get_by_folder_sync(session, folder_name)
|
||||
if 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
|
||||
)
|
||||
return anime_series
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as exc:
|
||||
@@ -691,79 +584,6 @@ class SerieScanner:
|
||||
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
|
||||
|
||||
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:
|
||||
# Update existing serie
|
||||
self.keyDict[key].episodeDict = missing_episodes
|
||||
# Update existing anime - rebuild episodeDict from 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(
|
||||
"Updated existing series %s with %d missing episodes",
|
||||
key,
|
||||
sum(len(eps) for eps in missing_episodes.values())
|
||||
)
|
||||
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)
|
||||
if year:
|
||||
logger.info(
|
||||
"Using year from folder name: %s (year=%d)",
|
||||
folder,
|
||||
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(
|
||||
# Create new AnimeSeries entry (minimal, fields populated later)
|
||||
from src.server.database.models import AnimeSeries
|
||||
anime_series = AnimeSeries(
|
||||
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,
|
||||
folder=folder,
|
||||
episodeDict=missing_episodes,
|
||||
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(
|
||||
"Created new series entry for %s with %d missing episodes (year=%s)",
|
||||
key,
|
||||
@@ -19,10 +19,10 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
from events import Events
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.core.entities.SerieList import SerieList
|
||||
from src.core.entities.series import Serie
|
||||
from src.core.providers.provider_factory import Loaders
|
||||
from src.core.SerieScanner import SerieScanner
|
||||
from src.server.database.SerieList import SerieList
|
||||
from src.server.database.models import AnimeSeries
|
||||
from src.server.providers.provider_factory import Loaders
|
||||
from src.server.SerieScanner import SerieScanner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -141,16 +141,12 @@ class SeriesApp:
|
||||
def __init__(
|
||||
self,
|
||||
directory_to_search: str,
|
||||
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize SeriesApp.
|
||||
|
||||
Args:
|
||||
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
|
||||
@@ -166,12 +162,9 @@ class SeriesApp:
|
||||
self.serie_scanner = SerieScanner(
|
||||
directory_to_search,
|
||||
self.loader,
|
||||
db_lookup=db_lookup,
|
||||
scan_key_overrides=settings.scan_key_overrides,
|
||||
)
|
||||
# Skip automatic loading from data files - series will be loaded
|
||||
# from database by the service layer during application setup
|
||||
self.list = SerieList(self.directory_to_search, skip_load=True)
|
||||
# Series will be loaded from database by the service layer during application setup
|
||||
self.list = SerieList(self.directory_to_search)
|
||||
self.series_list: List[Any] = []
|
||||
# Initialize empty list - series loaded later via load_series_from_list()
|
||||
# No need to call _init_list_sync() anymore
|
||||
@@ -660,7 +653,7 @@ class SeriesApp:
|
||||
"""
|
||||
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.
|
||||
|
||||
@@ -671,7 +664,7 @@ class SeriesApp:
|
||||
"attack-on-titan")
|
||||
|
||||
Returns:
|
||||
The Serie instance if found, None otherwise
|
||||
The AnimeSeries instance if found, None otherwise
|
||||
|
||||
Note:
|
||||
This method uses the SerieList.get_by_key() method which
|
||||
@@ -679,25 +672,25 @@ class SeriesApp:
|
||||
"""
|
||||
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.
|
||||
|
||||
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
|
||||
contexts.
|
||||
|
||||
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
|
||||
exist.
|
||||
|
||||
Example:
|
||||
series_app = SeriesApp("/path/to/anime")
|
||||
all_series = series_app.get_all_series_from_data_files()
|
||||
for serie in all_series:
|
||||
print(f"Found: {serie.name} (key={serie.key})")
|
||||
for anime in all_series:
|
||||
print(f"Found: {anime.name} (key={anime.key})")
|
||||
"""
|
||||
logger.info(
|
||||
"Scanning for data files in directory: %s",
|
||||
@@ -708,10 +701,7 @@ class SeriesApp:
|
||||
# This ensures we get all series from data files without
|
||||
# interfering with the main instance's state
|
||||
try:
|
||||
temp_list = SerieList(
|
||||
self.directory_to_search,
|
||||
skip_load=False # Allow automatic loading
|
||||
)
|
||||
temp_list = SerieList(self.directory_to_search)
|
||||
except (OSError, ValueError) as e:
|
||||
logger.error(
|
||||
"Failed to scan directory for data files: %s",
|
||||
@@ -8,8 +8,8 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.core.entities.series import Serie
|
||||
from src.core.utils.key_utils import generate_key_from_folder, is_valid_key
|
||||
from src.server.database.models import AnimeSeries
|
||||
from src.server.utils.key_utils import generate_key_from_folder, is_valid_key
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
from src.server.exceptions import (
|
||||
BadRequestError,
|
||||
@@ -896,18 +896,18 @@ async def add_series(
|
||||
|
||||
# Step D: Add to SerieList (in-memory only, no folder creation)
|
||||
if series_app and hasattr(series_app, "list"):
|
||||
serie = Serie(
|
||||
from src.server.database.models import AnimeSeries
|
||||
anime = AnimeSeries(
|
||||
key=key,
|
||||
name=name,
|
||||
site="aniworld.to",
|
||||
folder=folder,
|
||||
episodeDict={},
|
||||
year=year
|
||||
)
|
||||
|
||||
# Add to in-memory cache without creating folder on disk
|
||||
if hasattr(series_app.list, 'keyDict'):
|
||||
series_app.list.keyDict[key] = serie
|
||||
series_app.list.keyDict[key] = anime
|
||||
logger.info(
|
||||
"Added series to in-memory cache: %s (key=%s, folder=%s, year=%s)",
|
||||
name,
|
||||
|
||||
@@ -4,7 +4,7 @@ This module provides functions to generate tvshow.nfo XML files from
|
||||
TVShowNFO Pydantic models, adapted from the scraper project.
|
||||
|
||||
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)
|
||||
>>> xml_string = generate_tvshow_nfo(nfo)
|
||||
"""
|
||||
@@ -15,7 +15,7 @@ from typing import Optional
|
||||
from lxml import etree
|
||||
|
||||
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__)
|
||||
|
||||
@@ -11,7 +11,7 @@ import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from src.core.entities.nfo_models import (
|
||||
from src.server.entities.nfo_models import (
|
||||
ActorInfo,
|
||||
ImageInfo,
|
||||
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,
|
||||
UserSessionService,
|
||||
)
|
||||
from src.server.database.SerieList import SerieList
|
||||
from src.server.database.system_settings_service import SystemSettingsService
|
||||
|
||||
__all__ = [
|
||||
@@ -79,4 +80,6 @@ __all__ = [
|
||||
"DownloadQueueService",
|
||||
"SystemSettingsService",
|
||||
"UserSessionService",
|
||||
# SerieList
|
||||
"SerieList",
|
||||
]
|
||||
|
||||
@@ -190,6 +190,54 @@ class AnimeSeries(Base, TimestampMixin):
|
||||
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):
|
||||
"""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
|
||||
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
||||
|
||||
from src.core.providers.health_monitor import get_health_monitor
|
||||
from src.core.providers.provider_config import DEFAULT_PROVIDERS
|
||||
from src.server.providers.health_monitor import get_health_monitor
|
||||
from src.server.providers.provider_config import DEFAULT_PROVIDERS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -7,8 +7,8 @@ import logging
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from src.core.providers.base_provider import Loader
|
||||
from src.core.providers.health_monitor import get_health_monitor
|
||||
from src.server.providers.base_provider import Loader
|
||||
from src.server.providers.health_monitor import get_health_monitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Optional
|
||||
|
||||
import structlog
|
||||
|
||||
from src.core.SeriesApp import SeriesApp
|
||||
from src.server.SeriesApp import SeriesApp
|
||||
from src.server.services.progress_service import (
|
||||
ProgressService,
|
||||
ProgressType,
|
||||
@@ -942,7 +942,6 @@ class AnimeService:
|
||||
in-memory episodeDict, so downloaded episodes are not shown
|
||||
as missing.
|
||||
"""
|
||||
from src.core.entities.series import Serie
|
||||
from src.server.database.connection import get_db_session
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
|
||||
@@ -951,38 +950,8 @@ class AnimeService:
|
||||
db, with_episodes=True
|
||||
)
|
||||
|
||||
# Convert to Serie objects
|
||||
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)
|
||||
# Load AnimeSeries objects directly into SeriesApp
|
||||
self._app.load_series_from_list(anime_series_list)
|
||||
|
||||
async def sync_episodes_to_db(self, series_key: str) -> int:
|
||||
"""
|
||||
@@ -1178,17 +1147,17 @@ class AnimeService:
|
||||
|
||||
async def add_series_to_db(
|
||||
self,
|
||||
serie,
|
||||
anime,
|
||||
db
|
||||
):
|
||||
"""
|
||||
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.
|
||||
|
||||
Args:
|
||||
serie: The Serie instance to add
|
||||
anime: The AnimeSeries instance to add
|
||||
db: Database session for async operations
|
||||
|
||||
Returns:
|
||||
@@ -1197,41 +1166,40 @@ class AnimeService:
|
||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||
|
||||
# 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:
|
||||
logger.debug(
|
||||
"Series already exists in database: %s (key=%s)",
|
||||
serie.name,
|
||||
serie.key
|
||||
anime.name,
|
||||
anime.key
|
||||
)
|
||||
return None
|
||||
|
||||
# Create new series in database
|
||||
anime_series = await AnimeSeriesService.create(
|
||||
db=db,
|
||||
key=serie.key,
|
||||
name=serie.name,
|
||||
site=serie.site,
|
||||
folder=serie.folder,
|
||||
year=serie.year if hasattr(serie, 'year') else None,
|
||||
key=anime.key,
|
||||
name=anime.name,
|
||||
site=anime.site,
|
||||
folder=anime.folder,
|
||||
year=anime.year if hasattr(anime, 'year') else None,
|
||||
)
|
||||
|
||||
# Create Episode records for each episode in episodeDict
|
||||
if serie.episodeDict:
|
||||
for season, episode_numbers in serie.episodeDict.items():
|
||||
for episode_number in episode_numbers:
|
||||
# Create Episode records for each episode in episodes relationship
|
||||
if anime.episodes:
|
||||
for episode in anime.episodes:
|
||||
await EpisodeService.create(
|
||||
db=db,
|
||||
series_id=anime_series.id,
|
||||
season=season,
|
||||
episode_number=episode_number,
|
||||
season=episode.season,
|
||||
episode_number=episode.episode_number,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Added series to database: %s (key=%s, year=%s)",
|
||||
serie.name,
|
||||
serie.key,
|
||||
serie.year if hasattr(serie, 'year') else None
|
||||
anime.name,
|
||||
anime.key,
|
||||
anime.year if hasattr(anime, 'year') else None
|
||||
)
|
||||
|
||||
return anime_series
|
||||
|
||||
@@ -14,7 +14,7 @@ import structlog
|
||||
from lxml import etree
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ except Exception: # pragma: no cover - optional dependency
|
||||
AsyncSession = object
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -58,16 +58,16 @@ _RATE_LIMIT_WINDOW_SECONDS = 60.0
|
||||
|
||||
|
||||
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
|
||||
series whose ``folder`` column matches the given name, and converts the
|
||||
ORM row to a ``Serie`` domain object. Returns ``None`` when the DB is not
|
||||
yet initialised or no matching row is found.
|
||||
series whose ``folder`` column matches the given name, and returns the
|
||||
AnimeSeries ORM object. Returns ``None`` when the DB is not yet initialised
|
||||
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:
|
||||
from src.server.database.connection import get_sync_session
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
@@ -78,16 +78,7 @@ def _make_db_lookup():
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
return Serie(
|
||||
key=row.key,
|
||||
name=row.name or "",
|
||||
site=row.site,
|
||||
folder=row.folder,
|
||||
episodeDict={},
|
||||
year=row.year,
|
||||
)
|
||||
return row
|
||||
except RuntimeError:
|
||||
# DB not initialised yet (e.g. first boot before init_db())
|
||||
return None
|
||||
|
||||
@@ -289,7 +289,7 @@ class TestNfoRepair:
|
||||
self, authenticated_client, override_dependencies
|
||||
):
|
||||
"""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:
|
||||
mock_path = Mock()
|
||||
|
||||
@@ -131,7 +131,7 @@ def mock_series_app_download(monkeypatch):
|
||||
"""
|
||||
# Mock the loader download method
|
||||
try:
|
||||
from src.core.SeriesApp import SeriesApp
|
||||
from src.server.SeriesApp import SeriesApp
|
||||
|
||||
# Patch the loader.download method for all SeriesApp instances
|
||||
original_init = SeriesApp.__init__
|
||||
|
||||
@@ -60,7 +60,7 @@ class TestCacheConsistency:
|
||||
|
||||
def test_provider_cache_key_uniqueness(self):
|
||||
"""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.cache = {}
|
||||
|
||||
@@ -19,8 +19,8 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
from src.core.SeriesApp import SeriesApp
|
||||
from src.server.database.models import AnimeSeries
|
||||
from src.server.SeriesApp import SeriesApp
|
||||
|
||||
|
||||
class TestGetAllSeriesFromDataFiles:
|
||||
@@ -29,8 +29,8 @@ class TestGetAllSeriesFromDataFiles:
|
||||
def test_returns_empty_list_for_empty_directory(self):
|
||||
"""Test that empty directory returns empty list."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
with patch('src.core.SeriesApp.Loaders'), \
|
||||
patch('src.core.SeriesApp.SerieScanner'):
|
||||
with patch('src.server.SeriesApp.Loaders'), \
|
||||
patch('src.server.SeriesApp.SerieScanner'):
|
||||
app = SeriesApp(tmp_dir)
|
||||
result = app.get_all_series_from_data_files()
|
||||
|
||||
@@ -56,8 +56,8 @@ class TestGetAllSeriesFromDataFiles:
|
||||
episodes={1: [1]}
|
||||
)
|
||||
|
||||
with patch('src.core.SeriesApp.Loaders'), \
|
||||
patch('src.core.SeriesApp.SerieScanner'):
|
||||
with patch('src.server.SeriesApp.Loaders'), \
|
||||
patch('src.server.SeriesApp.SerieScanner'):
|
||||
app = SeriesApp(tmp_dir)
|
||||
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:
|
||||
f.write("this is not valid json {{{")
|
||||
|
||||
with patch('src.core.SeriesApp.Loaders'), \
|
||||
patch('src.core.SeriesApp.SerieScanner'):
|
||||
with patch('src.server.SeriesApp.Loaders'), \
|
||||
patch('src.server.SeriesApp.SerieScanner'):
|
||||
app = SeriesApp(tmp_dir)
|
||||
result = app.get_all_series_from_data_files()
|
||||
|
||||
@@ -101,8 +101,8 @@ class TestGetAllSeriesFromDataFiles:
|
||||
"""Test that non-existent directory returns empty list."""
|
||||
non_existent_dir = "/non/existent/directory/path"
|
||||
|
||||
with patch('src.core.SeriesApp.Loaders'), \
|
||||
patch('src.core.SeriesApp.SerieScanner'):
|
||||
with patch('src.server.SeriesApp.Loaders'), \
|
||||
patch('src.server.SeriesApp.SerieScanner'):
|
||||
app = SeriesApp(non_existent_dir)
|
||||
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
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
with patch('src.core.SeriesApp.Loaders'), \
|
||||
patch('src.core.SeriesApp.SerieScanner'):
|
||||
with patch('src.server.SeriesApp.Loaders'), \
|
||||
patch('src.server.SeriesApp.SerieScanner'):
|
||||
count = await sync_legacy_series_to_db(tmp_dir)
|
||||
|
||||
assert count == 0
|
||||
@@ -147,8 +147,8 @@ class TestSyncSeriesToDatabase:
|
||||
)
|
||||
|
||||
# First verify that we can load the series from files
|
||||
with patch('src.core.SeriesApp.Loaders'), \
|
||||
patch('src.core.SeriesApp.SerieScanner'):
|
||||
with patch('src.server.SeriesApp.Loaders'), \
|
||||
patch('src.server.SeriesApp.SerieScanner'):
|
||||
app = SeriesApp(tmp_dir)
|
||||
series = app.get_all_series_from_data_files()
|
||||
assert len(series) == 1
|
||||
@@ -156,8 +156,8 @@ class TestSyncSeriesToDatabase:
|
||||
|
||||
# Now test that the sync function loads series and handles DB
|
||||
# gracefully (even if DB operations fail, it should not crash)
|
||||
with patch('src.core.SeriesApp.Loaders'), \
|
||||
patch('src.core.SeriesApp.SerieScanner'):
|
||||
with patch('src.server.SeriesApp.Loaders'), \
|
||||
patch('src.server.SeriesApp.SerieScanner'):
|
||||
# The function should return 0 because DB isn't available
|
||||
# but should not crash
|
||||
count = await sync_legacy_series_to_db(tmp_dir)
|
||||
@@ -173,10 +173,10 @@ class TestSyncSeriesToDatabase:
|
||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||
|
||||
# Make SeriesApp raise an exception during initialization
|
||||
with patch('src.core.SeriesApp.Loaders'), \
|
||||
patch('src.core.SeriesApp.SerieScanner'), \
|
||||
with patch('src.server.SeriesApp.Loaders'), \
|
||||
patch('src.server.SeriesApp.SerieScanner'), \
|
||||
patch(
|
||||
'src.core.SeriesApp.SerieList',
|
||||
'src.server.SeriesApp.SerieList',
|
||||
side_effect=Exception("Test error")
|
||||
):
|
||||
count = await sync_legacy_series_to_db("/fake/path")
|
||||
@@ -210,8 +210,8 @@ class TestEndToEndSync:
|
||||
)
|
||||
|
||||
# Use SeriesApp to load series from files
|
||||
with patch('src.core.SeriesApp.Loaders'), \
|
||||
patch('src.core.SeriesApp.SerieScanner'):
|
||||
with patch('src.server.SeriesApp.Loaders'), \
|
||||
patch('src.server.SeriesApp.SerieScanner'):
|
||||
app = SeriesApp(tmp_dir)
|
||||
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:
|
||||
- In-memory Serie.episodeDict is updated
|
||||
- Deprecated data file is updated (if it exists)
|
||||
- In-memory AnimeSeries.episodeDict is updated
|
||||
- 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 json
|
||||
@@ -14,12 +15,24 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
from src.core.SeriesApp import SeriesApp
|
||||
from src.server.database.models import AnimeSeries
|
||||
from src.server.SeriesApp import SeriesApp
|
||||
from src.server.models.download import DownloadItem, DownloadPriority, DownloadStatus
|
||||
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:
|
||||
"""Verify episode no longer appears in missing list after download completes."""
|
||||
|
||||
@@ -35,18 +48,17 @@ class TestEpisodeRemovedFromMissingListAfterDownload:
|
||||
anime_service = MagicMock()
|
||||
anime_service._directory = str(temp_dir)
|
||||
|
||||
# Create mock app withSerie with missing episodes
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="test-series",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="Test Series",
|
||||
episodeDict={1: [1, 2, 3]},
|
||||
episode_dict={1: [1, 2, 3]},
|
||||
)
|
||||
mock_app = MagicMock()
|
||||
mock_app.list.keyDict = {"test-series": serie}
|
||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
||||
mock_app.series_list = [serie]
|
||||
mock_app.list.keyDict = {"test-series": anime}
|
||||
mock_app.list.GetMissingEpisode.return_value = [anime]
|
||||
mock_app.series_list = [anime]
|
||||
anime_service._app = mock_app
|
||||
anime_service._cached_list_missing = MagicMock()
|
||||
anime_service._broadcast_series_updated = AsyncMock()
|
||||
@@ -57,179 +69,6 @@ class TestEpisodeRemovedFromMissingListAfterDownload:
|
||||
def mock_download_service(self, mock_anime_service):
|
||||
"""Create download service with mocked dependencies."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
service = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
queue_repository=MagicMock(),
|
||||
max_retries=3,
|
||||
)
|
||||
service._directory = tmp
|
||||
yield service
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_episode_removed_from_missing_list_after_download(
|
||||
self, mock_download_service, mock_anime_service
|
||||
):
|
||||
"""Verify episode no longer appears in missing list after download completes."""
|
||||
serie = mock_anime_service._app.list.keyDict["test-series"]
|
||||
|
||||
# Verify episode starts in missing list
|
||||
assert 2 in serie.episodeDict[1], "Episode should start in missing list"
|
||||
|
||||
# Simulate download completion by calling _remove_episode_from_memory
|
||||
mock_download_service._remove_episode_from_memory("test-series", 1, 2)
|
||||
|
||||
# Episode should be removed from episodeDict
|
||||
assert 2 not in serie.episodeDict[1], "Episode should be removed from missing list"
|
||||
assert serie.episodeDict[1] == [1, 3]
|
||||
|
||||
# series_list should be refreshed
|
||||
mock_anime_service._app.list.GetMissingEpisode.assert_called()
|
||||
|
||||
|
||||
class TestDownloadUpdatesInMemoryCache:
|
||||
"""Verify in-memory Serie.episodeDict is updated after download."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anime_service(self):
|
||||
"""Create mock anime service with app."""
|
||||
anime_service = MagicMock()
|
||||
anime_service._directory = "/tmp/test"
|
||||
|
||||
# Create mock app with series having multiple seasons and episodes
|
||||
serie = Serie(
|
||||
key="multi-season-series",
|
||||
name="Multi Season Series",
|
||||
site="https://example.com",
|
||||
folder="Multi Season Series",
|
||||
episodeDict={
|
||||
1: [1, 2, 3, 4, 5],
|
||||
2: [1, 2, 3],
|
||||
},
|
||||
)
|
||||
mock_app = MagicMock()
|
||||
mock_app.list.keyDict = {"multi-season-series": 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."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
service = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
queue_repository=MagicMock(),
|
||||
max_retries=3,
|
||||
)
|
||||
service._directory = tmp
|
||||
yield service
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_updates_in_memory_cache(
|
||||
self, mock_download_service, mock_anime_service
|
||||
):
|
||||
"""Verify in-memory Serie.episodeDict is updated after download."""
|
||||
# First reset to known state (remove the defaults first call might have set)
|
||||
serie = mock_anime_service._app.list.keyDict["multi-season-series"]
|
||||
|
||||
# Put back episodes after the fixture setup
|
||||
serie.episodeDict = {1: [1, 2, 3, 4, 5], 2: [1, 2, 3]}
|
||||
|
||||
# Verify preconditions
|
||||
assert 1 in serie.episodeDict[1]
|
||||
assert 3 in serie.episodeDict[2]
|
||||
|
||||
# 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, 3)
|
||||
mock_download_service._remove_episode_from_memory("multi-season-series", 2, 2)
|
||||
|
||||
# Verify episodes removed
|
||||
assert 1 not in serie.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 2 in serie.episodeDict[1], "Episode 2 of season 1 should remain"
|
||||
assert 3 in serie.episodeDict[2], "Episode 3 of season 2 should remain"
|
||||
assert 2 not in serie.episodeDict[2], "Episode 2 of season 2 should be removed"
|
||||
|
||||
# Verify seasons with no episodes are cleaned up
|
||||
assert 2 in serie.episodeDict, "Season 2 should still exist (has episode 1, 3)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_last_episode_removes_season(
|
||||
self, mock_download_service, mock_anime_service
|
||||
):
|
||||
"""Verify that removing last episode in a season removes the season key."""
|
||||
# Modify the series so season 1 only has episode 2 left
|
||||
serie = mock_anime_service._app.list.keyDict["multi-season-series"]
|
||||
# Reset and set to proper test state
|
||||
serie.episodeDict = {1: [2], 2: [1, 2, 3]} # Season 1 only has episode 2
|
||||
|
||||
# Verify initial state
|
||||
assert 2 in serie.episodeDict[1]
|
||||
assert 2 in serie.episodeDict[2]
|
||||
|
||||
# Remove last episode of season 1 (episode 2)
|
||||
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 2)
|
||||
|
||||
# Season 1 should be completely removed
|
||||
assert 1 not in serie.episodeDict, "Season 1 should be removed"
|
||||
# Season 2 should still exist
|
||||
assert 2 in serie.episodeDict, "Season 2 should still exist"
|
||||
|
||||
|
||||
class TestDataFileUpdatedAfterDownload:
|
||||
"""Verify data file is updated after download (when it exists)."""
|
||||
|
||||
@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(),
|
||||
@@ -239,35 +78,120 @@ class TestDataFileUpdatedAfterDownload:
|
||||
yield service
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_data_file_updated_after_download(
|
||||
self, mock_download_service, mock_anime_service, temp_dir
|
||||
async def test_episode_removed_from_missing_list_after_download(
|
||||
self, mock_download_service, mock_anime_service
|
||||
):
|
||||
"""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 episode no longer appears in missing list after download completes."""
|
||||
anime = mock_anime_service._app.list.keyDict["test-series"]
|
||||
|
||||
# Verify data file exists before test
|
||||
assert data_path.exists(), "Data file should exist before test"
|
||||
# Verify episode starts in missing list
|
||||
assert 2 in anime.episodeDict[1], "Episode should start in missing list"
|
||||
|
||||
# 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 by calling _remove_episode_from_memory
|
||||
mock_download_service._remove_episode_from_memory("test-series", 1, 2)
|
||||
|
||||
# Simulate download completion
|
||||
mock_download_service._remove_episode_from_memory("test-series-with-data", 1, 2)
|
||||
# Episode should be removed from episodeDict
|
||||
assert 2 not in anime.episodeDict[1], "Episode should be removed from missing list"
|
||||
assert anime.episodeDict[1] == [1, 3]
|
||||
|
||||
# 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]
|
||||
# series_list should be refreshed
|
||||
mock_anime_service._app.list.GetMissingEpisode.assert_called()
|
||||
|
||||
|
||||
class TestDataFileNotRequiredForDownload:
|
||||
"""Verify downloads work even when data file doesn't exist."""
|
||||
class TestDownloadUpdatesInMemoryCache:
|
||||
"""Verify in-memory AnimeSeries.episodeDict is updated after download."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anime_service(self):
|
||||
"""Create mock anime service with app."""
|
||||
anime_service = MagicMock()
|
||||
anime_service._directory = "/tmp/test"
|
||||
|
||||
anime = make_anime(
|
||||
key="multi-season-series",
|
||||
name="Multi Season Series",
|
||||
site="https://example.com",
|
||||
folder="Multi Season Series",
|
||||
episode_dict={
|
||||
1: [1, 2, 3, 4, 5],
|
||||
2: [1, 2, 3],
|
||||
},
|
||||
)
|
||||
mock_app = MagicMock()
|
||||
mock_app.list.keyDict = {"multi-season-series": anime}
|
||||
mock_app.list.GetMissingEpisode.return_value = [anime]
|
||||
mock_app.series_list = [anime]
|
||||
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."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
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_download_updates_in_memory_cache(
|
||||
self, mock_download_service, mock_anime_service
|
||||
):
|
||||
"""Verify in-memory AnimeSeries.episodeDict is updated after download."""
|
||||
anime = mock_anime_service._app.list.keyDict["multi-season-series"]
|
||||
|
||||
# Put back episodes after the fixture setup
|
||||
anime.episodeDict = {1: [1, 2, 3, 4, 5], 2: [1, 2, 3]}
|
||||
|
||||
# Verify preconditions
|
||||
assert 1 in anime.episodeDict[1]
|
||||
assert 3 in anime.episodeDict[2]
|
||||
|
||||
# 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, 3)
|
||||
mock_download_service._remove_episode_from_memory("multi-season-series", 2, 2)
|
||||
|
||||
# Verify episodes removed
|
||||
assert 1 not in anime.episodeDict[1], "Episode 1 of season 1 should be removed"
|
||||
assert 3 not in anime.episodeDict[1], "Episode 3 of season 1 should be removed"
|
||||
assert 2 in anime.episodeDict[1], "Episode 2 of season 1 should remain"
|
||||
assert 3 in anime.episodeDict[2], "Episode 3 of season 2 should remain"
|
||||
assert 2 not in anime.episodeDict[2], "Episode 2 of season 2 should be removed"
|
||||
|
||||
# Verify seasons with no episodes are cleaned up
|
||||
assert 2 in anime.episodeDict, "Season 2 should still exist (has episode 1, 3)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_last_episode_removes_season(
|
||||
self, mock_download_service, mock_anime_service
|
||||
):
|
||||
"""Verify that removing last episode in a season removes the season key."""
|
||||
anime = mock_anime_service._app.list.keyDict["multi-season-series"]
|
||||
# Reset and set to proper test state
|
||||
anime.episodeDict = {1: [2], 2: [1, 2, 3]} # Season 1 only has episode 2
|
||||
|
||||
# Verify initial state
|
||||
assert 2 in anime.episodeDict[1]
|
||||
assert 2 in anime.episodeDict[2]
|
||||
|
||||
# Remove last episode of season 1 (episode 2)
|
||||
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 2)
|
||||
|
||||
# Season 1 should be completely removed
|
||||
assert 1 not in anime.episodeDict, "Season 1 should be removed"
|
||||
# Season 2 should still exist
|
||||
assert 2 in anime.episodeDict, "Season 2 should still exist"
|
||||
|
||||
|
||||
class TestDownloadWithoutDataFile:
|
||||
"""Verify downloads work without data file (in-memory only)."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self):
|
||||
@@ -281,19 +205,18 @@ class TestDataFileNotRequiredForDownload:
|
||||
anime_service = MagicMock()
|
||||
anime_service._directory = str(temp_dir)
|
||||
|
||||
# Create series with NO data file on disk (only in memory)
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="memory-only-series",
|
||||
name="Memory Only Series",
|
||||
site="https://example.com",
|
||||
folder="Memory Only Series",
|
||||
episodeDict={1: [1, 2, 3]},
|
||||
episode_dict={1: [1, 2, 3]},
|
||||
)
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.list.keyDict = {"memory-only-series": serie}
|
||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
||||
mock_app.series_list = [serie]
|
||||
mock_app.list.keyDict = {"memory-only-series": anime}
|
||||
mock_app.list.GetMissingEpisode.return_value = [anime]
|
||||
mock_app.series_list = [anime]
|
||||
anime_service._app = mock_app
|
||||
anime_service._cached_list_missing = MagicMock()
|
||||
anime_service._broadcast_series_updated = AsyncMock()
|
||||
@@ -316,7 +239,7 @@ class TestDataFileNotRequiredForDownload:
|
||||
self, mock_download_service, mock_anime_service
|
||||
):
|
||||
"""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"
|
||||
|
||||
# Verify no data file exists
|
||||
@@ -327,7 +250,7 @@ class TestDataFileNotRequiredForDownload:
|
||||
mock_download_service._remove_episode_from_memory("memory-only-series", 1, 2)
|
||||
|
||||
# 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)
|
||||
assert not data_path.exists(), "No data file should be created"
|
||||
@@ -5,12 +5,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.providers.failover import (
|
||||
from src.server.providers.failover import (
|
||||
ProviderFailover,
|
||||
configure_failover,
|
||||
get_failover,
|
||||
)
|
||||
from src.core.providers.health_monitor import ProviderHealthMonitor
|
||||
from src.server.providers.health_monitor import ProviderHealthMonitor
|
||||
|
||||
|
||||
class TestProviderFailoverScenarios:
|
||||
@@ -132,7 +132,7 @@ class TestProviderFailoverScenarios:
|
||||
assert "provider1" not in monitor.get_available_providers()
|
||||
|
||||
with patch(
|
||||
"src.core.providers.failover.get_health_monitor",
|
||||
"src.server.providers.failover.get_health_monitor",
|
||||
return_value=monitor,
|
||||
):
|
||||
failover = ProviderFailover(
|
||||
@@ -236,7 +236,7 @@ class TestFailoverStats:
|
||||
monitor.record_request("p2", False, 200, error_message="fail")
|
||||
|
||||
with patch(
|
||||
"src.core.providers.failover.get_health_monitor",
|
||||
"src.server.providers.failover.get_health_monitor",
|
||||
return_value=monitor,
|
||||
):
|
||||
failover = ProviderFailover(
|
||||
@@ -253,7 +253,7 @@ class TestConfigureFailover:
|
||||
|
||||
def test_configure_failover(self):
|
||||
"""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
|
||||
|
||||
failover = configure_failover(
|
||||
@@ -271,7 +271,7 @@ class TestConfigureFailover:
|
||||
|
||||
def test_get_failover_singleton(self):
|
||||
"""get_failover should return same instance."""
|
||||
import src.core.providers.failover as fo
|
||||
import src.server.providers.failover as fo
|
||||
fo._failover = None
|
||||
|
||||
first = get_failover()
|
||||
|
||||
@@ -4,9 +4,9 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.providers.config_manager import ProviderConfigManager, ProviderSettings
|
||||
from src.core.providers.failover import ProviderFailover
|
||||
from src.core.providers.health_monitor import (
|
||||
from src.server.providers.config_manager import ProviderConfigManager, ProviderSettings
|
||||
from src.server.providers.failover import ProviderFailover
|
||||
from src.server.providers.health_monitor import (
|
||||
ProviderHealthMetrics,
|
||||
ProviderHealthMonitor,
|
||||
)
|
||||
@@ -174,7 +174,7 @@ class TestProviderSelectionWithFailover:
|
||||
monitor.record_request("p2", True, 50)
|
||||
|
||||
with patch(
|
||||
"src.core.providers.failover.get_health_monitor",
|
||||
"src.server.providers.failover.get_health_monitor",
|
||||
return_value=monitor,
|
||||
):
|
||||
failover = ProviderFailover(
|
||||
|
||||
@@ -6,13 +6,33 @@ special characters, Unicode names, and malformed folder structures.
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
from src.core.providers.base_provider import Loader
|
||||
from src.core.SerieScanner import SerieScanner
|
||||
from src.server.database.models import AnimeSeries
|
||||
from src.server.providers.base_provider import Loader
|
||||
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
|
||||
@@ -133,112 +153,112 @@ class TestSpecialCharacters:
|
||||
|
||||
def test_colon_in_name(self, temp_anime_dir, mock_loader):
|
||||
"""Test series name with colon."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="re-zero",
|
||||
name="Re:Zero - Starting Life in Another World",
|
||||
site="aniworld.to",
|
||||
folder="Re Zero",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
# Sanitized folder should remove colon
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
assert ":" not in sanitized
|
||||
assert "Re" in sanitized
|
||||
assert "Zero" in sanitized
|
||||
|
||||
def test_slash_in_name(self, temp_anime_dir, mock_loader):
|
||||
"""Test series name with slash."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="fate-stay-night",
|
||||
name="Fate/Stay Night: Unlimited Blade Works",
|
||||
site="aniworld.to",
|
||||
folder="Fate Stay Night",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
assert "/" not in sanitized
|
||||
assert "\\" not in sanitized
|
||||
|
||||
def test_question_mark_in_name(self, temp_anime_dir, mock_loader):
|
||||
"""Test series name with question mark."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="is-it-wrong",
|
||||
name="Is It Wrong to Try to Pick Up Girls in a Dungeon?",
|
||||
site="aniworld.to",
|
||||
folder="Is It Wrong",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
assert "?" not in sanitized
|
||||
|
||||
def test_asterisk_in_name(self, temp_anime_dir, mock_loader):
|
||||
"""Test series name with asterisk."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="series",
|
||||
name="Series * Special",
|
||||
site="aniworld.to",
|
||||
folder="Series Special",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
assert "*" not in sanitized
|
||||
|
||||
def test_pipe_in_name(self, temp_anime_dir, mock_loader):
|
||||
"""Test series name with pipe character."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="series",
|
||||
name="Series | Part 2",
|
||||
site="aniworld.to",
|
||||
folder="Series Part 2",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
assert "|" not in sanitized
|
||||
|
||||
def test_quotes_in_name(self, temp_anime_dir, mock_loader):
|
||||
"""Test series name with quotes."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="series",
|
||||
name='Series "Subtitle" Edition',
|
||||
site="aniworld.to",
|
||||
folder="Series Subtitle Edition",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
# Quotes should be removed or replaced
|
||||
assert '"' not in sanitized or sanitized.count('"') == 0
|
||||
|
||||
def test_less_greater_than_in_name(self, temp_anime_dir, mock_loader):
|
||||
"""Test series name with < and >."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="series",
|
||||
name="Series <Special> Edition",
|
||||
site="aniworld.to",
|
||||
folder="Series Special Edition",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
assert "<" not in sanitized
|
||||
assert ">" not in sanitized
|
||||
|
||||
def test_multiple_special_chars(self, temp_anime_dir, mock_loader):
|
||||
"""Test series name with multiple special characters."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="complex",
|
||||
name="Re:Zero / Fate * Special? <Edition>",
|
||||
site="aniworld.to",
|
||||
folder="Re Zero Fate Special Edition",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
# Should remove all special chars
|
||||
invalid_chars = [':', '/', '*', '?', '<', '>']
|
||||
for char in invalid_chars:
|
||||
@@ -250,45 +270,45 @@ class TestMultipleSpaces:
|
||||
|
||||
def test_double_spaces(self, temp_anime_dir, mock_loader):
|
||||
"""Test series name with double spaces."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="series",
|
||||
name="Attack on Titan",
|
||||
site="aniworld.to",
|
||||
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
|
||||
assert "Attack" in sanitized
|
||||
assert "Titan" in sanitized
|
||||
|
||||
def test_leading_trailing_spaces(self, temp_anime_dir, mock_loader):
|
||||
"""Test series name with leading/trailing spaces."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="series",
|
||||
name=" Attack on Titan ",
|
||||
site="aniworld.to",
|
||||
folder="Attack on Titan",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
# Leading/trailing spaces should be stripped
|
||||
assert not sanitized.startswith(" ")
|
||||
assert not sanitized.endswith(" ")
|
||||
|
||||
def test_tabs_in_name(self, temp_anime_dir, mock_loader):
|
||||
"""Test series name with tab characters."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="series",
|
||||
name="Attack\ton\tTitan",
|
||||
site="aniworld.to",
|
||||
folder="Attack on Titan",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
# Tabs should be handled (removed or replaced)
|
||||
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):
|
||||
"""Test series name in Japanese."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="shingeki",
|
||||
name="進撃の巨人",
|
||||
site="aniworld.to",
|
||||
folder="進撃の巨人",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
# Unicode should be preserved
|
||||
assert "進撃の巨人" in sanitized
|
||||
|
||||
def test_chinese_name(self, temp_anime_dir, mock_loader):
|
||||
"""Test series name in Chinese."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="series",
|
||||
name="进击的巨人",
|
||||
site="aniworld.to",
|
||||
folder="进击的巨人",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
assert "进击的巨人" in sanitized
|
||||
|
||||
def test_korean_name(self, temp_anime_dir, mock_loader):
|
||||
"""Test series name in Korean."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="series",
|
||||
name="진격의 거인",
|
||||
site="aniworld.to",
|
||||
folder="진격의 거인",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
assert "진격의" in sanitized
|
||||
|
||||
def test_arabic_name(self, temp_anime_dir, mock_loader):
|
||||
"""Test series name in Arabic."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="series",
|
||||
name="هجوم العمالقة",
|
||||
site="aniworld.to",
|
||||
folder="هجوم العمالقة",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
assert "هجوم" in sanitized
|
||||
|
||||
def test_cyrillic_name(self, temp_anime_dir, mock_loader):
|
||||
"""Test series name in Cyrillic."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="series",
|
||||
name="Атака Титанов",
|
||||
site="aniworld.to",
|
||||
folder="Атака Титанов",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
assert "Атака" in sanitized
|
||||
|
||||
def test_mixed_languages(self, temp_anime_dir, mock_loader):
|
||||
"""Test series name with mixed languages."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="series",
|
||||
name="Attack on Titan - 進撃の巨人",
|
||||
site="aniworld.to",
|
||||
folder="Attack on Titan",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
assert "Attack" in sanitized
|
||||
assert "進撃の巨人" in sanitized
|
||||
|
||||
def test_emoji_in_name(self, temp_anime_dir, mock_loader):
|
||||
"""Test series name with emoji."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="series",
|
||||
name="Series ⚔️ Special",
|
||||
site="aniworld.to",
|
||||
folder="Series Special",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
# Emoji should be handled gracefully
|
||||
assert "Series" in sanitized
|
||||
|
||||
@@ -418,16 +438,16 @@ class TestMalformedFolderStructures:
|
||||
def test_very_long_folder_name(self, temp_anime_dir, mock_loader):
|
||||
"""Test handling of very long folder names."""
|
||||
long_name = "A" * 300 # Very long name
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="long",
|
||||
name=long_name,
|
||||
site="aniworld.to",
|
||||
folder=long_name,
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
# Should handle long names without error
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
assert len(sanitized) > 0
|
||||
|
||||
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):
|
||||
"""Test folder name with underscores."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="series",
|
||||
name="Attack_on_Titan",
|
||||
site="aniworld.to",
|
||||
folder="Attack_on_Titan",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
# Underscores are valid filesystem chars
|
||||
assert "Attack" in sanitized
|
||||
|
||||
|
||||
class TestNameWithYearProperty:
|
||||
"""Test Serie.name_with_year property."""
|
||||
"""Test AnimeSeries.name_with_year property."""
|
||||
|
||||
def test_name_with_year_adds_year(self):
|
||||
"""Test that name_with_year adds year in parentheses."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="dororo",
|
||||
name="Dororo",
|
||||
site="aniworld.to",
|
||||
folder="Dororo",
|
||||
episodeDict={},
|
||||
episode_dict={},
|
||||
year=2025
|
||||
)
|
||||
|
||||
assert serie.name_with_year == "Dororo (2025)"
|
||||
assert anime.name_with_year == "Dororo (2025)"
|
||||
|
||||
def test_name_with_year_no_year(self):
|
||||
"""Test name_with_year without year returns just name."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="dororo",
|
||||
name="Dororo",
|
||||
site="aniworld.to",
|
||||
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):
|
||||
"""Test that sanitized_folder uses name_with_year."""
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="attack",
|
||||
name="Attack on Titan",
|
||||
site="aniworld.to",
|
||||
folder="Attack on Titan",
|
||||
episodeDict={},
|
||||
episode_dict={},
|
||||
year=2013
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
assert "(2013)" 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:
|
||||
"""Test Serie.ensure_folder_with_year method."""
|
||||
|
||||
def test_ensure_folder_adds_year_when_missing(self):
|
||||
"""Test that ensure_folder_with_year adds year to folder."""
|
||||
serie = Serie(
|
||||
def test_sanitized_folder_uses_name_with_year(self):
|
||||
"""Test that sanitized_folder uses name_with_year."""
|
||||
anime = make_anime(
|
||||
key="attack",
|
||||
name="Attack on Titan",
|
||||
site="aniworld.to",
|
||||
folder="Attack on Titan",
|
||||
episodeDict={},
|
||||
episode_dict={},
|
||||
year=2013
|
||||
)
|
||||
|
||||
result = serie.ensure_folder_with_year()
|
||||
|
||||
assert "(2013)" in result
|
||||
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
|
||||
sanitized = anime.sanitized_folder
|
||||
assert "(2013)" in sanitized
|
||||
assert "Attack on Titan" in sanitized
|
||||
|
||||
|
||||
class TestRealWorldScenarios:
|
||||
@@ -576,15 +549,15 @@ class TestRealWorldScenarios:
|
||||
]
|
||||
|
||||
for key, name, expected_part in test_cases:
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key=key,
|
||||
name=name,
|
||||
site="aniworld.to",
|
||||
folder="old-folder",
|
||||
episodeDict={}
|
||||
episode_dict={}
|
||||
)
|
||||
|
||||
sanitized = serie.sanitized_folder
|
||||
sanitized = anime.sanitized_folder
|
||||
# Check that expected part is in sanitized name
|
||||
assert any(word in sanitized for word in expected_part.split())
|
||||
# Check invalid chars removed (< > : " / \ | ? *)
|
||||
|
||||
@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import aiohttp
|
||||
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):
|
||||
|
||||
@@ -11,20 +11,20 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
from src.core.SeriesApp import SeriesApp
|
||||
from src.core.SerieScanner import SerieScanner
|
||||
from src.server.database.models import AnimeSeries
|
||||
from src.server.SeriesApp import SeriesApp
|
||||
from src.server.SerieScanner import SerieScanner
|
||||
|
||||
|
||||
def _mock_read_data(folder_name):
|
||||
"""Create a mock Serie from a folder name for scanner patching."""
|
||||
serie = Mock(spec=Serie)
|
||||
serie.key = f"key_{folder_name}"
|
||||
serie.name = f"Series {folder_name}"
|
||||
serie.folder = folder_name
|
||||
serie.year = 2024
|
||||
serie.episodeDict = {}
|
||||
return serie
|
||||
"""Create a mock AnimeSeries from a folder name for scanner patching."""
|
||||
anime = Mock(spec=AnimeSeries)
|
||||
anime.key = f"key_{folder_name}"
|
||||
anime.name = f"Series {folder_name}"
|
||||
anime.folder = folder_name
|
||||
anime.year = 2024
|
||||
anime.episodeDict = {}
|
||||
return anime
|
||||
|
||||
|
||||
def _scanner_patches(scanner):
|
||||
@@ -273,12 +273,12 @@ class TestMemoryUsageDuringScans:
|
||||
series_dict = {}
|
||||
|
||||
for i in range(num_series):
|
||||
serie = Mock(spec=Serie)
|
||||
serie.key = f"series_key_{i:04d}"
|
||||
serie.name = f"Test Series {i}"
|
||||
serie.folder = f"Series_{i:04d}"
|
||||
serie.episodeDict = {}
|
||||
series_dict[serie.key] = serie
|
||||
anime = Mock(spec=AnimeSeries)
|
||||
anime.key = f"series_key_{i:04d}"
|
||||
anime.name = f"Test Series {i}"
|
||||
anime.folder = f"Series_{i:04d}"
|
||||
anime.episodeDict = {}
|
||||
series_dict[anime.key] = anime
|
||||
|
||||
dict_size = sys.getsizeof(series_dict)
|
||||
avg_size_per_series = dict_size / num_series
|
||||
|
||||
@@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
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.models.nfo import NFOBatchCreateRequest
|
||||
|
||||
@@ -297,7 +297,7 @@ class TestTMDBAPIBatchingOptimization:
|
||||
|
||||
# Simulate rate limit on 5th call
|
||||
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")
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
@@ -13,10 +13,22 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
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
|
||||
def mock_series_app():
|
||||
"""Create a mock SeriesApp with scanner."""
|
||||
@@ -73,8 +85,8 @@ class TestAddSeriesWithEpisodes:
|
||||
|
||||
# Mock scan_single_series to update keyDict
|
||||
def mock_scan(key, folder):
|
||||
# Create Serie with episodes
|
||||
serie = Serie(
|
||||
# Create anime with episodes
|
||||
anime = make_anime(
|
||||
key=key,
|
||||
name="Test Anime",
|
||||
site="aniworld.to",
|
||||
@@ -83,7 +95,7 @@ class TestAddSeriesWithEpisodes:
|
||||
year=2024
|
||||
)
|
||||
# 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]}
|
||||
|
||||
mock_series_app.serie_scanner.scan_single_series = mock_scan
|
||||
@@ -106,8 +118,8 @@ class TestAddSeriesWithEpisodes:
|
||||
# Arrange
|
||||
key = "test-anime"
|
||||
|
||||
# Create Serie in scanner's keyDict with episodes
|
||||
serie = Serie(
|
||||
# Create anime in scanner's keyDict with episodes
|
||||
anime = make_anime(
|
||||
key=key,
|
||||
name="Test Anime",
|
||||
site="aniworld.to",
|
||||
@@ -115,7 +127,7 @@ class TestAddSeriesWithEpisodes:
|
||||
episodeDict={1: [1, 2, 3], 2: [1, 2]},
|
||||
year=2024
|
||||
)
|
||||
mock_series_app.serie_scanner.keyDict[key] = serie
|
||||
mock_series_app.serie_scanner.keyDict[key] = anime
|
||||
|
||||
# Mock the database save method
|
||||
with patch.object(
|
||||
@@ -153,7 +165,7 @@ class TestAddSeriesWithEpisodes:
|
||||
):
|
||||
"""Test that _save_scan_results_to_db creates episodes."""
|
||||
# Arrange
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="test-anime",
|
||||
name="Test Anime",
|
||||
site="aniworld.to",
|
||||
@@ -193,7 +205,7 @@ class TestAddSeriesWithEpisodes:
|
||||
mock_episode_service.create = AsyncMock(side_effect=track_episode_create)
|
||||
|
||||
# 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 result == 1 # One series saved
|
||||
@@ -217,7 +229,7 @@ class TestAddSeriesWithEpisodes:
|
||||
):
|
||||
"""Test that _update_series_in_db adds new missing episodes."""
|
||||
# Arrange
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key="test-anime",
|
||||
name="Test Anime",
|
||||
site="aniworld.to",
|
||||
@@ -269,7 +281,7 @@ class TestAddSeriesWithEpisodes:
|
||||
mock_episode_service.delete = AsyncMock()
|
||||
|
||||
# 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 result == 1
|
||||
@@ -292,7 +304,7 @@ class TestAddSeriesWithEpisodes:
|
||||
|
||||
# Setup mock scanner to populate keyDict
|
||||
def mock_scan(key, folder):
|
||||
serie = Serie(
|
||||
anime = make_anime(
|
||||
key=key,
|
||||
name="Test Anime",
|
||||
site="aniworld.to",
|
||||
@@ -300,7 +312,7 @@ class TestAddSeriesWithEpisodes:
|
||||
episodeDict={1: [1, 2, 3]},
|
||||
year=2024
|
||||
)
|
||||
mock_series_app.serie_scanner.keyDict[key] = serie
|
||||
mock_series_app.serie_scanner.keyDict[key] = anime
|
||||
return {1: [1, 2, 3]}
|
||||
|
||||
mock_series_app.serie_scanner.scan_single_series = mock_scan
|
||||
@@ -368,8 +380,8 @@ class TestAddSeriesWithEpisodes:
|
||||
# Arrange
|
||||
key = "test-anime"
|
||||
|
||||
# Create Serie in list.keyDict with episodes
|
||||
serie = Serie(
|
||||
# Create anime in list.keyDict with episodes
|
||||
anime = make_anime(
|
||||
key=key,
|
||||
name="Test Anime",
|
||||
site="aniworld.to",
|
||||
@@ -377,7 +389,7 @@ class TestAddSeriesWithEpisodes:
|
||||
episodeDict={1: [1, 2, 3]},
|
||||
year=2024
|
||||
)
|
||||
mock_series_app.list.keyDict[key] = serie
|
||||
mock_series_app.list.keyDict[key] = anime
|
||||
|
||||
# Mock database AnimeSeries with NFO data
|
||||
mock_db_series = AnimeSeries(
|
||||
|
||||
@@ -7,12 +7,26 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
from src.core.SeriesApp import SeriesApp
|
||||
from src.server.SeriesApp import SeriesApp
|
||||
from src.server.database.models import AnimeSeries, Episode
|
||||
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:
|
||||
"""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]
|
||||
assert len(called_series) == 2
|
||||
|
||||
# Verify Serie objects have correct attributes
|
||||
assert all(isinstance(s, Serie) for s in called_series)
|
||||
# Verify AnimeSeries objects have correct attributes
|
||||
assert all(isinstance(s, AnimeSeries) for s in called_series)
|
||||
assert called_series[0].key == "test-anime-1"
|
||||
assert called_series[0].name == "Test Anime 1"
|
||||
assert called_series[0].folder == "Test Anime 1 (2023)"
|
||||
@@ -140,14 +154,14 @@ class TestAnimeListLoading:
|
||||
|
||||
# Create test series
|
||||
test_series = [
|
||||
Serie(
|
||||
make_anime(
|
||||
key="test-1",
|
||||
name="Test Series 1",
|
||||
site="aniworld.to",
|
||||
folder="Test Series 1 (2023)",
|
||||
episodeDict={1: [1, 2, 3]}
|
||||
),
|
||||
Serie(
|
||||
make_anime(
|
||||
key="test-2",
|
||||
name="Test Series 2",
|
||||
site="aniworld.to",
|
||||
@@ -295,7 +309,7 @@ class TestAnimeListLoading:
|
||||
"With skip_load=True, list should be empty initially"
|
||||
|
||||
# Test that manual loading works
|
||||
test_serie = Serie(
|
||||
test_serie = make_anime(
|
||||
key="test",
|
||||
name="Test",
|
||||
site="aniworld.to",
|
||||
|
||||
@@ -7,13 +7,13 @@ from unittest.mock import MagicMock, Mock, patch
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from src.core.providers.aniworld_provider import AniworldLoader
|
||||
from src.server.providers.aniworld_provider import AniworldLoader
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loader():
|
||||
"""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"
|
||||
instance = AniworldLoader()
|
||||
instance.session = MagicMock()
|
||||
@@ -390,7 +390,7 @@ class TestAniworldProviderParsing:
|
||||
class TestAniworldSeasonEpisodeCount:
|
||||
"""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):
|
||||
"""get_season_episode_count should return correct counts."""
|
||||
# Main page with 2 seasons
|
||||
@@ -421,7 +421,7 @@ class TestAniworldSeasonEpisodeCount:
|
||||
result = loader.get_season_episode_count("naruto")
|
||||
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):
|
||||
"""get_season_episode_count should return empty dict when no seasons."""
|
||||
html = "<html><body></body></html>"
|
||||
@@ -616,7 +616,7 @@ class TestAniworldDownloadFailover:
|
||||
return ydl
|
||||
|
||||
with patch(
|
||||
"src.core.providers.aniworld_provider.YoutubeDL",
|
||||
"src.server.providers.aniworld_provider.YoutubeDL",
|
||||
side_effect=fake_ytdl,
|
||||
):
|
||||
result = patched_loader.download(
|
||||
@@ -649,7 +649,7 @@ class TestAniworldDownloadFailover:
|
||||
return ydl
|
||||
|
||||
with patch(
|
||||
"src.core.providers.aniworld_provider.YoutubeDL",
|
||||
"src.server.providers.aniworld_provider.YoutubeDL",
|
||||
side_effect=fake_ytdl,
|
||||
):
|
||||
result = patched_loader.download(
|
||||
@@ -670,7 +670,7 @@ class TestAniworldDownloadFailover:
|
||||
patched_loader._try_direct_stream.side_effect = write_direct
|
||||
|
||||
with patch(
|
||||
"src.core.providers.aniworld_provider.YoutubeDL"
|
||||
"src.server.providers.aniworld_provider.YoutubeDL"
|
||||
) as mock_ydl:
|
||||
result = patched_loader.download(
|
||||
str(tmp_path), "Anime", 1, 1, "k", "German Dub"
|
||||
@@ -682,7 +682,7 @@ class TestAniworldDownloadFailover:
|
||||
self, patched_loader, tmp_path, caplog
|
||||
):
|
||||
with patch(
|
||||
"src.core.providers.aniworld_provider.YoutubeDL",
|
||||
"src.server.providers.aniworld_provider.YoutubeDL",
|
||||
side_effect=Exception("HTTP 404"),
|
||||
):
|
||||
result = patched_loader.download(
|
||||
@@ -728,7 +728,7 @@ class TestDecodeHtmlContent:
|
||||
|
||||
def test_decodes_utf8_content(self):
|
||||
"""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>'
|
||||
content = html.encode('utf-8')
|
||||
result = _decode_html_content(content)
|
||||
@@ -736,7 +736,7 @@ class TestDecodeHtmlContent:
|
||||
|
||||
def test_decodes_latin1_content(self):
|
||||
"""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
|
||||
html = '<html><body><h1>CafÉ and more text here</h1></body></html>'
|
||||
content = html.encode('latin-1')
|
||||
@@ -745,13 +745,13 @@ class TestDecodeHtmlContent:
|
||||
|
||||
def test_replaces_invalid_bytes(self):
|
||||
"""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'
|
||||
result = _decode_html_content(content)
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_handles_empty_content(self):
|
||||
"""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'')
|
||||
assert result == ''
|
||||
|
||||
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.providers.base_provider import Loader
|
||||
from src.server.providers.base_provider import Loader
|
||||
|
||||
|
||||
class TestLoaderAbstractInterface:
|
||||
|
||||
@@ -7,7 +7,7 @@ functionality.
|
||||
|
||||
import unittest
|
||||
|
||||
from src.core.interfaces.callbacks import (
|
||||
from src.server.interfaces.callbacks import (
|
||||
CallbackManager,
|
||||
CompletionCallback,
|
||||
CompletionContext,
|
||||
|
||||
@@ -535,7 +535,7 @@ class TestAnimeServiceScanLock:
|
||||
@pytest.mark.asyncio
|
||||
async def test_anime_service_ignores_concurrent_rescan_requests(self):
|
||||
"""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
|
||||
|
||||
# Mock database
|
||||
|
||||
@@ -9,7 +9,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.error_handler import (
|
||||
from src.server.error_handler import (
|
||||
DownloadError,
|
||||
FileCorruptionDetector,
|
||||
NetworkError,
|
||||
@@ -259,7 +259,7 @@ class TestWithErrorRecoveryDecorator:
|
||||
raise RuntimeError("oops")
|
||||
return "ok"
|
||||
|
||||
with patch("src.core.error_handler.logger") as mock_logger:
|
||||
with patch("src.server.error_handler.logger") as mock_logger:
|
||||
fail_once()
|
||||
# Should have logged a warning with context
|
||||
mock_logger.warning.assert_called()
|
||||
|
||||
@@ -430,7 +430,7 @@ class TestExponentialBackoff:
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -699,61 +699,58 @@ class TestErrorHandling:
|
||||
class TestRemoveEpisodeFromMissingList:
|
||||
"""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
|
||||
async def test_remove_episode_from_memory(self, download_service):
|
||||
"""Test _remove_episode_from_memory updates in-memory state."""
|
||||
from src.core.entities.series import Serie
|
||||
|
||||
# Set up in-memory series with missing episodes
|
||||
serie = Serie(
|
||||
key="test-series",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="Test Series (2024)",
|
||||
episodeDict={1: [1, 2, 3], 2: [1, 2]},
|
||||
)
|
||||
anime = self.make_anime("test-series", "Test Series", "Test Series (2024)", {1: [1, 2, 3], 2: [1, 2]})
|
||||
mock_app = MagicMock()
|
||||
mock_app.list.keyDict = {"test-series": serie}
|
||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
||||
mock_app.series_list = [serie]
|
||||
mock_app.list.keyDict = {"test-series": anime}
|
||||
mock_app.list.GetMissingEpisode.return_value = [anime]
|
||||
mock_app.series_list = [anime]
|
||||
download_service._anime_service._app = mock_app
|
||||
|
||||
# Remove episode S01E02
|
||||
download_service._remove_episode_from_memory("test-series", 1, 2)
|
||||
|
||||
# Episode should be removed from episodeDict
|
||||
assert 2 not in serie.episodeDict[1]
|
||||
assert serie.episodeDict[1] == [1, 3]
|
||||
assert 2 not in anime.episodeDict[1]
|
||||
assert anime.episodeDict[1] == [1, 3]
|
||||
# Season 2 should be untouched
|
||||
assert serie.episodeDict[2] == [1, 2]
|
||||
assert anime.episodeDict[2] == [1, 2]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_last_episode_in_season_removes_season(
|
||||
self, download_service
|
||||
):
|
||||
"""Test removing the last episode in a season removes the season key."""
|
||||
from src.core.entities.series import Serie
|
||||
|
||||
serie = Serie(
|
||||
key="test-series",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="Test Series (2024)",
|
||||
episodeDict={1: [5], 2: [1, 2]},
|
||||
)
|
||||
anime = self.make_anime("test-series", "Test Series", "Test Series (2024)", {1: [5], 2: [1, 2]})
|
||||
mock_app = MagicMock()
|
||||
mock_app.list.keyDict = {"test-series": serie}
|
||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
||||
mock_app.series_list = [serie]
|
||||
mock_app.list.keyDict = {"test-series": anime}
|
||||
mock_app.list.GetMissingEpisode.return_value = [anime]
|
||||
mock_app.series_list = [anime]
|
||||
download_service._anime_service._app = mock_app
|
||||
|
||||
# Remove the only episode in season 1
|
||||
download_service._remove_episode_from_memory("test-series", 1, 5)
|
||||
|
||||
# Season 1 should be completely removed
|
||||
assert 1 not in serie.episodeDict
|
||||
assert 1 not in anime.episodeDict
|
||||
# Season 2 untouched
|
||||
assert serie.episodeDict[2] == [1, 2]
|
||||
assert anime.episodeDict[2] == [1, 2]
|
||||
# GetMissingEpisode should have been called to refresh
|
||||
mock_app.list.GetMissingEpisode.assert_called()
|
||||
|
||||
@@ -778,20 +775,12 @@ class TestRemoveEpisodeFromMissingList:
|
||||
"""Test _remove_episode_from_missing_list updates both DB and memory."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
|
||||
# Set up in-memory state
|
||||
serie = Serie(
|
||||
key="test-series",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="Test Series (2024)",
|
||||
episodeDict={1: [1, 2, 3]},
|
||||
)
|
||||
anime = self.make_anime("test-series", "Test Series", "Test Series (2024)", {1: [1, 2, 3]})
|
||||
mock_app = MagicMock()
|
||||
mock_app.list.keyDict = {"test-series": serie}
|
||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
||||
mock_app.series_list = [serie]
|
||||
mock_app.list.keyDict = {"test-series": anime}
|
||||
mock_app.list.GetMissingEpisode.return_value = [anime]
|
||||
mock_app.series_list = [anime]
|
||||
download_service._anime_service._app = mock_app
|
||||
download_service._anime_service._cached_list_missing = MagicMock()
|
||||
|
||||
@@ -845,8 +834,8 @@ class TestRemoveEpisodeFromMissingList:
|
||||
),
|
||||
)
|
||||
# In-memory update happened
|
||||
assert 2 not in serie.episodeDict[1]
|
||||
assert serie.episodeDict[1] == [1, 3]
|
||||
assert 2 not in anime.episodeDict[1]
|
||||
assert anime.episodeDict[1] == [1, 3]
|
||||
# Cache was cleared
|
||||
download_service._anime_service._cached_list_missing.cache_clear.assert_called()
|
||||
# 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."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
|
||||
# Setup mock anime service to return success
|
||||
download_service._anime_service.download = AsyncMock(
|
||||
return_value=True
|
||||
)
|
||||
|
||||
# Set up in-memory series state
|
||||
serie = Serie(
|
||||
key="series-1",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="series",
|
||||
episodeDict={1: [1, 2, 3]},
|
||||
)
|
||||
anime = self.make_anime("series-1", "Test Series", "series", {1: [1, 2, 3]})
|
||||
mock_app = MagicMock()
|
||||
mock_app.list.keyDict = {"series-1": serie}
|
||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
||||
mock_app.series_list = [serie]
|
||||
mock_app.list.keyDict = {"series-1": anime}
|
||||
mock_app.list.GetMissingEpisode.return_value = [anime]
|
||||
mock_app.series_list = [anime]
|
||||
download_service._anime_service._app = mock_app
|
||||
download_service._anime_service._cached_list_missing = MagicMock()
|
||||
|
||||
@@ -936,8 +917,8 @@ class TestRemoveEpisodeFromMissingList:
|
||||
assert download_service._completed_items[0].status == DownloadStatus.COMPLETED
|
||||
|
||||
# Episode 2 should be removed from in-memory missing list
|
||||
assert 2 not in serie.episodeDict[1]
|
||||
assert serie.episodeDict[1] == [1, 3]
|
||||
assert 2 not in anime.episodeDict[1]
|
||||
assert anime.episodeDict[1] == [1, 3]
|
||||
|
||||
|
||||
class TestQueueDeduplication:
|
||||
|
||||
@@ -7,16 +7,16 @@ from unittest.mock import MagicMock, Mock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.error_handler import (
|
||||
from src.server.error_handler import (
|
||||
DownloadError,
|
||||
NetworkError,
|
||||
NonRetryableError,
|
||||
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
|
||||
from src.core.providers.enhanced_provider import EnhancedAniWorldLoader
|
||||
from src.server.providers.enhanced_provider import EnhancedAniWorldLoader
|
||||
|
||||
|
||||
class ConcreteEnhancedLoader(EnhancedAniWorldLoader):
|
||||
@@ -50,9 +50,9 @@ class ConcreteEnhancedLoader(EnhancedAniWorldLoader):
|
||||
def enhanced_loader():
|
||||
"""Create ConcreteEnhancedLoader with mocked externals."""
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.UserAgent"
|
||||
"src.server.providers.enhanced_provider.UserAgent"
|
||||
) 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"
|
||||
loader = ConcreteEnhancedLoader()
|
||||
@@ -360,7 +360,7 @@ class TestDownloadStatistics:
|
||||
class TestEnhancedDownloadValidation:
|
||||
"""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(
|
||||
self, mock_integrity, enhanced_loader
|
||||
):
|
||||
@@ -368,7 +368,7 @@ class TestEnhancedDownloadValidation:
|
||||
with pytest.raises((ValueError, DownloadError)):
|
||||
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(
|
||||
self, mock_integrity, enhanced_loader
|
||||
):
|
||||
@@ -376,7 +376,7 @@ class TestEnhancedDownloadValidation:
|
||||
with pytest.raises((ValueError, DownloadError)):
|
||||
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(
|
||||
self, mock_integrity, enhanced_loader
|
||||
):
|
||||
@@ -384,7 +384,7 @@ class TestEnhancedDownloadValidation:
|
||||
with pytest.raises((ValueError, DownloadError)):
|
||||
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(
|
||||
self, mock_integrity, enhanced_loader
|
||||
):
|
||||
@@ -392,7 +392,7 @@ class TestEnhancedDownloadValidation:
|
||||
with pytest.raises((ValueError, DownloadError)):
|
||||
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(
|
||||
self, mock_integrity, enhanced_loader
|
||||
):
|
||||
@@ -459,7 +459,7 @@ class TestFetchAnimeListWithRecovery:
|
||||
mock_response.text = json.dumps([{"title": "Naruto"}])
|
||||
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
||||
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||
) as mock_rs:
|
||||
mock_rs.handle_network_failure.return_value = mock_response
|
||||
result = enhanced_loader._fetch_anime_list_with_recovery(
|
||||
@@ -476,7 +476,7 @@ class TestFetchAnimeListWithRecovery:
|
||||
mock_response.status_code = 404
|
||||
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
||||
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||
) as mock_rs:
|
||||
mock_rs.handle_network_failure.return_value = mock_response
|
||||
with pytest.raises(NonRetryableError, match="not found"):
|
||||
@@ -491,7 +491,7 @@ class TestFetchAnimeListWithRecovery:
|
||||
mock_response.status_code = 403
|
||||
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
||||
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||
) as mock_rs:
|
||||
mock_rs.handle_network_failure.return_value = mock_response
|
||||
with pytest.raises(NonRetryableError, match="forbidden"):
|
||||
@@ -506,7 +506,7 @@ class TestFetchAnimeListWithRecovery:
|
||||
mock_response.status_code = 500
|
||||
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
||||
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||
) as mock_rs:
|
||||
mock_rs.handle_network_failure.return_value = mock_response
|
||||
with pytest.raises(RetryableError, match="Server error"):
|
||||
@@ -519,7 +519,7 @@ class TestFetchAnimeListWithRecovery:
|
||||
import requests as req
|
||||
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
||||
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||
) as mock_rs:
|
||||
mock_rs.handle_network_failure.side_effect = (
|
||||
req.RequestException("timeout")
|
||||
@@ -548,7 +548,7 @@ class TestGetKeyHTML:
|
||||
mock_response.ok = True
|
||||
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
||||
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||
) as mock_rs:
|
||||
mock_rs.handle_network_failure.return_value = mock_response
|
||||
result = enhanced_loader._GetKeyHTML("new-key")
|
||||
@@ -563,7 +563,7 @@ class TestGetKeyHTML:
|
||||
mock_response.status_code = 404
|
||||
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
||||
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||
) as mock_rs:
|
||||
mock_rs.handle_network_failure.return_value = mock_response
|
||||
with pytest.raises(NonRetryableError, match="not found"):
|
||||
@@ -628,7 +628,7 @@ class TestGetEmbeddedLink:
|
||||
"_get_redirect_link",
|
||||
return_value=("https://aniworld.to/redirect/100", "VOE"),
|
||||
), patch(
|
||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
||||
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||
) as mock_rs:
|
||||
mock_rs.handle_network_failure.return_value = mock_response
|
||||
result = enhanced_loader._get_embeded_link(
|
||||
@@ -718,11 +718,11 @@ class TestDownloadWithRecovery:
|
||||
return True
|
||||
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
||||
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||
) as mock_rs, patch(
|
||||
"src.core.providers.enhanced_provider.file_corruption_detector"
|
||||
"src.server.providers.enhanced_provider.file_corruption_detector"
|
||||
) as mock_fcd, patch(
|
||||
"src.core.providers.enhanced_provider.get_integrity_manager"
|
||||
"src.server.providers.enhanced_provider.get_integrity_manager"
|
||||
) as mock_im:
|
||||
mock_rs.handle_network_failure.return_value = (
|
||||
"https://direct.example.com/v.mp4",
|
||||
@@ -746,7 +746,7 @@ class TestDownloadWithRecovery:
|
||||
output_path = str(tmp_path / "output.mp4")
|
||||
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
||||
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||
) as mock_rs:
|
||||
mock_rs.handle_network_failure.side_effect = Exception("fail")
|
||||
|
||||
@@ -769,9 +769,9 @@ class TestDownloadWithRecovery:
|
||||
return True
|
||||
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
||||
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||
) as mock_rs, patch(
|
||||
"src.core.providers.enhanced_provider.file_corruption_detector"
|
||||
"src.server.providers.enhanced_provider.file_corruption_detector"
|
||||
) as mock_fcd:
|
||||
mock_rs.handle_network_failure.return_value = (
|
||||
"https://direct.example.com/v.mp4",
|
||||
@@ -816,7 +816,7 @@ class TestGetSeasonEpisodeCount:
|
||||
]
|
||||
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
||||
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||
) as mock_rs:
|
||||
mock_rs.handle_network_failure.side_effect = responses
|
||||
result = enhanced_loader.get_season_episode_count("test")
|
||||
@@ -828,7 +828,7 @@ class TestGetSeasonEpisodeCount:
|
||||
base_html = b"<html><body>No seasons</body></html>"
|
||||
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
||||
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||
) as mock_rs:
|
||||
mock_rs.handle_network_failure.return_value = MagicMock(
|
||||
content=base_html
|
||||
@@ -844,7 +844,7 @@ class TestPerformYtdlDownload:
|
||||
def test_success(self, enhanced_loader):
|
||||
"""Should return True on successful download."""
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.YoutubeDL"
|
||||
"src.server.providers.enhanced_provider.YoutubeDL"
|
||||
) as MockYDL:
|
||||
mock_ydl = MagicMock()
|
||||
MockYDL.return_value.__enter__ = MagicMock(return_value=mock_ydl)
|
||||
@@ -858,7 +858,7 @@ class TestPerformYtdlDownload:
|
||||
def test_failure_raises_download_error(self, enhanced_loader):
|
||||
"""yt-dlp failure should raise DownloadError."""
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.YoutubeDL"
|
||||
"src.server.providers.enhanced_provider.YoutubeDL"
|
||||
) as MockYDL:
|
||||
mock_ydl = MagicMock()
|
||||
mock_ydl.download.side_effect = Exception("yt-dlp crash")
|
||||
@@ -873,7 +873,7 @@ class TestPerformYtdlDownload:
|
||||
class TestDownloadFlow:
|
||||
"""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(
|
||||
self, mock_integrity, enhanced_loader, tmp_path
|
||||
):
|
||||
@@ -889,7 +889,7 @@ class TestDownloadFlow:
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.file_corruption_detector"
|
||||
"src.server.providers.enhanced_provider.file_corruption_detector"
|
||||
) as mock_fcd:
|
||||
mock_fcd.is_valid_video_file.return_value = True
|
||||
mock_integrity.return_value.has_checksum.return_value = False
|
||||
@@ -901,7 +901,7 @@ class TestDownloadFlow:
|
||||
assert result is True
|
||||
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(
|
||||
self, mock_integrity, enhanced_loader, tmp_path
|
||||
):
|
||||
@@ -915,7 +915,7 @@ class TestAniworldLoaderCompat:
|
||||
|
||||
def test_inherits_from_enhanced(self):
|
||||
"""AniworldLoader should extend EnhancedAniWorldLoader."""
|
||||
from src.core.providers.enhanced_provider import AniworldLoader
|
||||
from src.server.providers.enhanced_provider import AniworldLoader
|
||||
|
||||
assert issubclass(AniworldLoader, EnhancedAniWorldLoader)
|
||||
|
||||
@@ -936,11 +936,11 @@ class TestFfmpegHlsOptions:
|
||||
return True
|
||||
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
||||
"src.server.providers.enhanced_provider.recovery_strategies"
|
||||
) as mock_rs, patch(
|
||||
"src.core.providers.enhanced_provider.file_corruption_detector"
|
||||
"src.server.providers.enhanced_provider.file_corruption_detector"
|
||||
) as mock_fcd, patch(
|
||||
"src.core.providers.enhanced_provider.get_integrity_manager"
|
||||
"src.server.providers.enhanced_provider.get_integrity_manager"
|
||||
) as mock_im:
|
||||
mock_rs.handle_network_failure.return_value = (
|
||||
"https://direct.example.com/v.mp4",
|
||||
@@ -969,7 +969,7 @@ class TestHlsUrlDetection:
|
||||
def test_voe_hls_pattern_extracts_hls_url(self):
|
||||
"""HLS_PATTERN should extract HLS URL from VOE embedded player HTML."""
|
||||
import re
|
||||
from src.core.providers.streaming.voe import HLS_PATTERN
|
||||
from src.server.providers.streaming.voe import HLS_PATTERN
|
||||
|
||||
html_with_hls = """
|
||||
var playerConfig = {
|
||||
@@ -984,7 +984,7 @@ class TestHlsUrlDetection:
|
||||
def test_voe_hls_pattern_returns_none_when_no_hls(self):
|
||||
"""HLS_PATTERN should return None when no HLS URL in HTML."""
|
||||
import re
|
||||
from src.core.providers.streaming.voe import HLS_PATTERN
|
||||
from src.server.providers.streaming.voe import HLS_PATTERN
|
||||
|
||||
html_no_hls = """
|
||||
var playerConfig = {
|
||||
@@ -997,7 +997,7 @@ class TestHlsUrlDetection:
|
||||
def test_hls_url_detection_in_provider_flow(self, enhanced_loader, tmp_path):
|
||||
"""Provider should detect and handle HLS URLs from VOE extractor."""
|
||||
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)
|
||||
encoded_hls = "aHR0cHM6Ly9leGFtcGxlLmNvbS92aWRlby5tM3U4"
|
||||
|
||||
@@ -107,8 +107,8 @@ class TestSerieScannerIgnorePatterns:
|
||||
|
||||
def test_scanner_skips_ignored_folders(self, tmp_path):
|
||||
"""Test scanner skips folders matching ignore patterns."""
|
||||
from src.core.SerieScanner import SerieScanner
|
||||
from src.core.providers.aniworld_provider import AniworldLoader
|
||||
from src.server.SerieScanner import SerieScanner
|
||||
from src.server.providers.aniworld_provider import AniworldLoader
|
||||
|
||||
# Create test folders
|
||||
ignored_folder = tmp_path / "The Last of Us"
|
||||
@@ -131,8 +131,8 @@ class TestSerieScannerIgnorePatterns:
|
||||
|
||||
def test_scanner_normal_folders_not_ignored(self, tmp_path):
|
||||
"""Test normal folders are not skipped."""
|
||||
from src.core.SerieScanner import SerieScanner
|
||||
from src.core.providers.aniworld_provider import AniworldLoader
|
||||
from src.server.SerieScanner import SerieScanner
|
||||
from src.server.providers.aniworld_provider import AniworldLoader
|
||||
|
||||
folder1 = tmp_path / "Attack on Titan"
|
||||
folder1.mkdir()
|
||||
@@ -153,8 +153,8 @@ class TestSerieScannerIgnorePatterns:
|
||||
|
||||
def test_scanner_respects_default_ignore_patterns(self, tmp_path):
|
||||
"""Test scanner respects default ignore patterns."""
|
||||
from src.core.SerieScanner import SerieScanner
|
||||
from src.core.providers.aniworld_provider import AniworldLoader
|
||||
from src.server.SerieScanner import SerieScanner
|
||||
from src.server.providers.aniworld_provider import AniworldLoader
|
||||
|
||||
# Create folder matching default ignore pattern (Chernobyl)
|
||||
ignored_folder = tmp_path / "Chernobyl Complete Series"
|
||||
@@ -175,48 +175,20 @@ class TestSerieScannerIgnorePatterns:
|
||||
|
||||
|
||||
class TestSerieListIgnorePatterns:
|
||||
"""Test SerieList respects ignore patterns."""
|
||||
"""Test SerieList ignore pattern filtering - DB mode tests removed.
|
||||
|
||||
def test_load_series_skips_ignored_folders(self, tmp_path):
|
||||
"""Test load_series skips folders matching ignore patterns."""
|
||||
from src.core.entities.SerieList import SerieList
|
||||
from src.core.entities.series import Serie
|
||||
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.
|
||||
"""
|
||||
|
||||
# Create ignored folder with data file
|
||||
ignored_folder = tmp_path / "The Last of Us"
|
||||
ignored_folder.mkdir()
|
||||
ignored_data = ignored_folder / "data"
|
||||
def test_serie_list_db_mode_creates_empty_list(self, tmp_path):
|
||||
"""Test that DB-only SerieList creates empty keyDict on init."""
|
||||
from src.server.database.SerieList import SerieList
|
||||
|
||||
ignored_serie = Serie(
|
||||
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
|
||||
# DB-only SerieList doesn't auto-load from disk
|
||||
serie_list = SerieList(str(tmp_path))
|
||||
|
||||
# Verify ignored folder was skipped
|
||||
assert serie_list.contains("attack-on-titan") is True
|
||||
assert serie_list.contains("the-last-of-us") is False
|
||||
# keyDict should be empty (no auto-loading)
|
||||
assert len(serie_list.keyDict) == 0
|
||||
assert not serie_list.contains("attack-on-titan")
|
||||
@@ -8,7 +8,7 @@ import aiohttp
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src.core.utils.image_downloader import ImageDownloader, ImageDownloadError
|
||||
from src.server.utils.image_downloader import ImageDownloader, ImageDownloadError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -3,7 +3,7 @@ Unit tests for key generation utilities.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from src.core.utils.key_utils import (
|
||||
from src.server.utils.key_utils import (
|
||||
generate_key_from_folder,
|
||||
normalize_key,
|
||||
is_valid_key,
|
||||
|
||||
@@ -5,8 +5,8 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.providers.base_provider import Loader
|
||||
from src.core.providers.monitored_provider import (
|
||||
from src.server.providers.base_provider import Loader
|
||||
from src.server.providers.monitored_provider import (
|
||||
MonitoredProviderWrapper,
|
||||
wrap_provider,
|
||||
)
|
||||
@@ -84,7 +84,7 @@ def mock_health_monitor():
|
||||
def monitored_wrapper(mock_provider, mock_health_monitor):
|
||||
"""Create a monitored wrapper with mock health monitor."""
|
||||
with patch(
|
||||
"src.core.providers.monitored_provider.get_health_monitor",
|
||||
"src.server.providers.monitored_provider.get_health_monitor",
|
||||
return_value=mock_health_monitor,
|
||||
):
|
||||
wrapper = ConcreteMonitoredWrapper(
|
||||
@@ -100,7 +100,7 @@ class TestMonitoredProviderWrapperInit:
|
||||
def test_wrapper_stores_provider(self, mock_provider):
|
||||
"""Wrapper should store the wrapped provider."""
|
||||
with patch(
|
||||
"src.core.providers.monitored_provider.get_health_monitor"
|
||||
"src.server.providers.monitored_provider.get_health_monitor"
|
||||
):
|
||||
wrapper = ConcreteMonitoredWrapper(mock_provider)
|
||||
assert wrapper._provider is mock_provider
|
||||
@@ -108,7 +108,7 @@ class TestMonitoredProviderWrapperInit:
|
||||
def test_wrapper_monitoring_enabled_by_default(self, mock_provider):
|
||||
"""Monitoring should be enabled by default."""
|
||||
with patch(
|
||||
"src.core.providers.monitored_provider.get_health_monitor"
|
||||
"src.server.providers.monitored_provider.get_health_monitor"
|
||||
):
|
||||
wrapper = ConcreteMonitoredWrapper(mock_provider)
|
||||
assert wrapper._enable_monitoring is True
|
||||
@@ -320,7 +320,7 @@ class TestWrapProviderFunction:
|
||||
def test_wrap_creates_monitored_wrapper(self, mock_provider):
|
||||
"""wrap_provider should return MonitoredProviderWrapper."""
|
||||
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
|
||||
# instantiated directly due to missing abstract methods.
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.providers.config_manager import (
|
||||
from src.server.providers.config_manager import (
|
||||
ProviderConfigManager,
|
||||
ProviderSettings,
|
||||
get_config_manager,
|
||||
@@ -407,7 +407,7 @@ class TestGetConfigManagerSingleton:
|
||||
def test_returns_instance(self):
|
||||
"""get_config_manager should return a ProviderConfigManager."""
|
||||
# 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
|
||||
|
||||
manager = get_config_manager()
|
||||
@@ -418,7 +418,7 @@ class TestGetConfigManagerSingleton:
|
||||
|
||||
def test_returns_same_instance(self):
|
||||
"""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
|
||||
|
||||
first = get_config_manager()
|
||||
|
||||
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.providers.aniworld_provider import AniworldLoader
|
||||
from src.server.providers.aniworld_provider import AniworldLoader
|
||||
|
||||
|
||||
def _mock_response(content: str) -> MagicMock:
|
||||
@@ -202,7 +202,7 @@ class TestEmptyResponses:
|
||||
"""No season meta tag returns empty dict or zero."""
|
||||
loader = _loader()
|
||||
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")
|
||||
# Either empty dict or {1: 0} depending on implementation
|
||||
assert isinstance(result, (dict, int))
|
||||
|
||||
@@ -4,21 +4,21 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.providers.base_provider import Loader
|
||||
from src.core.providers.provider_factory import Loaders
|
||||
from src.server.providers.base_provider import Loader
|
||||
from src.server.providers.provider_factory import Loaders
|
||||
|
||||
|
||||
class TestLoadersInit:
|
||||
"""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):
|
||||
"""Factory should register aniworld.to provider by default."""
|
||||
mock_aniworld.return_value = MagicMock(spec=Loader)
|
||||
factory = Loaders()
|
||||
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):
|
||||
"""Factory dict values should be Loader instances."""
|
||||
mock_instance = MagicMock(spec=Loader)
|
||||
@@ -31,7 +31,7 @@ class TestLoadersInit:
|
||||
class TestLoadersGetLoader:
|
||||
"""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):
|
||||
"""GetLoader should return provider for known key."""
|
||||
mock_instance = MagicMock(spec=Loader)
|
||||
@@ -40,7 +40,7 @@ class TestLoadersGetLoader:
|
||||
loader = factory.GetLoader("aniworld.to")
|
||||
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):
|
||||
"""GetLoader should raise KeyError for unknown provider key."""
|
||||
mock_aniworld.return_value = MagicMock(spec=Loader)
|
||||
@@ -48,7 +48,7 @@ class TestLoadersGetLoader:
|
||||
with pytest.raises(KeyError):
|
||||
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):
|
||||
"""GetLoader should return same instance on repeated calls."""
|
||||
mock_instance = MagicMock(spec=Loader)
|
||||
@@ -58,7 +58,7 @@ class TestLoadersGetLoader:
|
||||
second = factory.GetLoader("aniworld.to")
|
||||
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):
|
||||
"""GetLoader should raise KeyError for empty string key."""
|
||||
mock_aniworld.return_value = MagicMock(spec=Loader)
|
||||
@@ -70,14 +70,14 @@ class TestLoadersGetLoader:
|
||||
class TestLoadersProviderRegistry:
|
||||
"""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):
|
||||
"""Factory should have exactly one default provider."""
|
||||
mock_aniworld.return_value = MagicMock(spec=Loader)
|
||||
factory = Loaders()
|
||||
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):
|
||||
"""Custom providers can be added to the factory registry."""
|
||||
mock_aniworld.return_value = MagicMock(spec=Loader)
|
||||
@@ -86,7 +86,7 @@ class TestLoadersProviderRegistry:
|
||||
factory.dict["custom.provider"] = 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):
|
||||
"""Existing providers can be overridden in the registry."""
|
||||
mock_aniworld.return_value = MagicMock(spec=Loader)
|
||||
@@ -95,7 +95,7 @@ class TestLoadersProviderRegistry:
|
||||
factory.dict["aniworld.to"] = 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):
|
||||
"""Multiple factory instances should have independent registries."""
|
||||
mock_aniworld.return_value = MagicMock(spec=Loader)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Unit tests for provider failover system."""
|
||||
import pytest
|
||||
|
||||
from src.core.providers.failover import (
|
||||
from src.server.providers.failover import (
|
||||
ProviderFailover,
|
||||
configure_failover,
|
||||
get_failover,
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.providers.health_monitor import (
|
||||
from src.server.providers.health_monitor import (
|
||||
ProviderHealthMetrics,
|
||||
ProviderHealthMonitor,
|
||||
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."""
|
||||
# pylint: disable=redefined-outer-name
|
||||
"""Tests for SerieList class - DB-only operations."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import warnings
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.entities.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
|
||||
from src.server.database.SerieList import SerieList
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_serie():
|
||||
"""Create a sample Serie for testing."""
|
||||
return 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]}
|
||||
)
|
||||
"""Create a sample AnimeSeries mock for testing."""
|
||||
anime = MagicMock()
|
||||
anime.key = "attack-on-titan"
|
||||
anime.name = "Attack on Titan"
|
||||
anime.site = "https://aniworld.to/anime/stream/attack-on-titan"
|
||||
anime.folder = "Attack on Titan (2013)"
|
||||
anime.year = 2013
|
||||
anime.nfo_path = None
|
||||
anime.episodeDict = {1: [1, 2, 3]}
|
||||
return anime
|
||||
|
||||
|
||||
class TestSerieListKeyBasedStorage:
|
||||
"""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."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
serie_list = SerieList(str(tmp_path))
|
||||
assert hasattr(serie_list, 'keyDict')
|
||||
assert isinstance(serie_list.keyDict, dict)
|
||||
assert len(serie_list.keyDict) == 0
|
||||
|
||||
def test_add_stores_by_key(self, temp_directory, 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):
|
||||
def test_contains_checks_by_key(self, tmp_path, sample_serie):
|
||||
"""Test contains() checks by key."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
serie_list.add(sample_serie)
|
||||
serie_list = SerieList(str(tmp_path))
|
||||
serie_list.keyDict[sample_serie.key] = sample_serie
|
||||
|
||||
assert serie_list.contains(sample_serie.key)
|
||||
assert not serie_list.contains("nonexistent-key")
|
||||
|
||||
def test_add_prevents_duplicates_by_key(
|
||||
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
|
||||
):
|
||||
def test_get_by_key_returns_correct_serie(self, tmp_path, sample_serie):
|
||||
"""Test get_by_key() retrieves series correctly."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
serie_list.add(sample_serie)
|
||||
serie_list = SerieList(str(tmp_path))
|
||||
serie_list.keyDict[sample_serie.key] = sample_serie
|
||||
|
||||
result = serie_list.get_by_key(sample_serie.key)
|
||||
assert result is not None
|
||||
assert result.key == sample_serie.key
|
||||
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."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
serie_list = SerieList(str(tmp_path))
|
||||
|
||||
result = serie_list.get_by_key("nonexistent-key")
|
||||
assert result is None
|
||||
|
||||
def test_get_by_folder_backward_compatibility(
|
||||
self, temp_directory, sample_serie
|
||||
):
|
||||
def test_get_by_folder_backward_compatibility(self, tmp_path, sample_serie):
|
||||
"""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():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
serie_list.add(sample_serie)
|
||||
result = serie_list.get_by_folder(sample_serie.folder)
|
||||
|
||||
assert result is not None
|
||||
assert result.key == sample_serie.key
|
||||
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."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
import warnings
|
||||
serie_list = SerieList(str(tmp_path))
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
result = serie_list.get_by_folder("Nonexistent Folder")
|
||||
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."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
serie_list = SerieList(str(tmp_path))
|
||||
|
||||
serie2 = Serie(
|
||||
key="naruto",
|
||||
name="Naruto",
|
||||
site="https://aniworld.to/anime/stream/naruto",
|
||||
folder="Naruto (2002)",
|
||||
episodeDict={1: [1, 2]}
|
||||
)
|
||||
serie2 = MagicMock()
|
||||
serie2.key = "naruto"
|
||||
serie2.name = "Naruto"
|
||||
serie2.site = "https://aniworld.to/anime/stream/naruto"
|
||||
serie2.folder = "Naruto (2002)"
|
||||
serie2.episodeDict = {1: [1, 2]}
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
serie_list.add(sample_serie)
|
||||
serie_list.add(serie2)
|
||||
serie_list.keyDict[sample_serie.key] = sample_serie
|
||||
serie_list.keyDict[serie2.key] = serie2
|
||||
|
||||
all_series = serie_list.get_all()
|
||||
assert len(all_series) == 2
|
||||
assert sample_serie in all_series
|
||||
assert serie2 in all_series
|
||||
|
||||
def test_get_missing_episodes_filters_by_episode_dict(
|
||||
self, temp_directory
|
||||
):
|
||||
def test_get_missing_episodes_filters_by_episode_dict(self, tmp_path):
|
||||
"""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 = Serie(
|
||||
key="serie-with-episodes",
|
||||
name="Serie With Episodes",
|
||||
site="https://aniworld.to/anime/stream/serie-with-episodes",
|
||||
folder="Serie With Episodes (2020)",
|
||||
episodeDict={1: [1, 2, 3]}
|
||||
)
|
||||
serie_with_episodes = MagicMock()
|
||||
serie_with_episodes.key = "serie-with-episodes"
|
||||
serie_with_episodes.name = "Serie With Episodes"
|
||||
serie_with_episodes.episodeDict = {1: [1, 2, 3]}
|
||||
|
||||
# Serie without missing episodes
|
||||
serie_without_episodes = Serie(
|
||||
key="serie-without-episodes",
|
||||
name="Serie Without Episodes",
|
||||
site="https://aniworld.to/anime/stream/serie-without-episodes",
|
||||
folder="Serie Without Episodes (2021)",
|
||||
episodeDict={}
|
||||
)
|
||||
serie_without_episodes = MagicMock()
|
||||
serie_without_episodes.key = "serie-without-episodes"
|
||||
serie_without_episodes.name = "Serie Without Episodes"
|
||||
serie_without_episodes.episodeDict = {}
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
serie_list.add(serie_with_episodes)
|
||||
serie_list.add(serie_without_episodes)
|
||||
serie_list.keyDict[serie_with_episodes.key] = serie_with_episodes
|
||||
serie_list.keyDict[serie_without_episodes.key] = serie_without_episodes
|
||||
|
||||
missing = serie_list.get_missing_episodes()
|
||||
assert len(missing) == 1
|
||||
assert serie_with_episodes 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:
|
||||
"""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."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
serie_list = SerieList(str(tmp_path))
|
||||
|
||||
# Test add (suppress deprecation warning for test)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
serie_list.add(sample_serie)
|
||||
# Add directly to keyDict (simulating DB load)
|
||||
serie_list.keyDict[sample_serie.key] = sample_serie
|
||||
|
||||
# Test contains
|
||||
assert serie_list.contains(sample_serie.key)
|
||||
@@ -219,30 +143,17 @@ class TestSerieListPublicAPI:
|
||||
assert len(serie_list.GetMissingEpisode()) == 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
|
||||
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:
|
||||
"""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(
|
||||
self, temp_directory, sample_serie
|
||||
):
|
||||
def test_get_by_folder_raises_deprecation_warning(self, tmp_path, sample_serie):
|
||||
"""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
|
||||
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
@@ -255,267 +166,15 @@ class TestSerieListDeprecationWarnings:
|
||||
assert "get_by_key()" in str(w[0].message)
|
||||
|
||||
|
||||
class TestSerieListBackwardCompatibility:
|
||||
"""Test backward compatibility of file-based operations."""
|
||||
class TestInvalidateCache:
|
||||
"""Test invalidate_cache method."""
|
||||
|
||||
def test_file_based_mode_still_works(
|
||||
self, temp_directory, sample_serie
|
||||
):
|
||||
"""Test file-based mode still works without db_session."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
def test_invalidate_cache_clears_keydict(self, tmp_path, sample_serie):
|
||||
"""Verify invalidate_cache clears the in-memory cache."""
|
||||
serie_list = SerieList(str(tmp_path))
|
||||
serie_list.keyDict[sample_serie.key] = sample_serie
|
||||
assert len(serie_list.keyDict) == 1
|
||||
|
||||
# Add should still work (with deprecation warning)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
serie_list.add(sample_serie)
|
||||
serie_list.invalidate_cache()
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
from src.server.database.models import AnimeSeries
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -24,27 +24,49 @@ def sample_anime_series():
|
||||
mock.site = "aniworld.to"
|
||||
mock.folder = "Attack on Titan (2013)"
|
||||
mock.year = 2013
|
||||
mock.episodes = [
|
||||
MagicMock(season=1, episode_number=1),
|
||||
MagicMock(season=1, episode_number=2),
|
||||
MagicMock(season=1, episode_number=3),
|
||||
MagicMock(season=2, episode_number=1),
|
||||
MagicMock(season=2, episode_number=2),
|
||||
]
|
||||
|
||||
# Create properly configured episode mocks that work with iteration
|
||||
episode1 = MagicMock(season=1, episode_number=1)
|
||||
episode2 = MagicMock(season=1, episode_number=2)
|
||||
episode3 = MagicMock(season=1, episode_number=3)
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_serie():
|
||||
"""Create a sample Serie for testing."""
|
||||
return Serie(
|
||||
key="attack-on-titan",
|
||||
name="Attack on Titan",
|
||||
site="aniworld.to",
|
||||
folder="Attack on Titan (2013)",
|
||||
episodeDict={1: [1, 2, 3], 2: [1, 2]},
|
||||
year=2013
|
||||
)
|
||||
"""Create a sample AnimeSeries mock for testing."""
|
||||
anime = MagicMock(spec=AnimeSeries)
|
||||
anime.key = "attack-on-titan"
|
||||
anime.name = "Attack on Titan"
|
||||
anime.site = "aniworld.to"
|
||||
anime.folder = "Attack on Titan (2013)"
|
||||
anime.year = 2013
|
||||
anime.episodeDict = {1: [1, 2, 3], 2: [1, 2]}
|
||||
return anime
|
||||
|
||||
|
||||
class TestLoadAllFromDb:
|
||||
@@ -63,9 +85,9 @@ class TestLoadAllFromDb:
|
||||
"src.server.database.service.AnimeSeriesService.get_all",
|
||||
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()
|
||||
|
||||
assert count == 1
|
||||
@@ -98,9 +120,9 @@ class TestLoadAllFromDb:
|
||||
"src.server.database.service.AnimeSeriesService.get_all",
|
||||
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()
|
||||
|
||||
assert count == 2
|
||||
@@ -122,9 +144,9 @@ class TestLoadAllFromDb:
|
||||
"src.server.database.service.AnimeSeriesService.get_all",
|
||||
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()
|
||||
|
||||
serie = serie_list.keyDict["attack-on-titan"]
|
||||
@@ -146,9 +168,9 @@ class TestLoadAllFromDb:
|
||||
"src.server.database.service.AnimeSeriesService.get_all",
|
||||
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()
|
||||
|
||||
assert count == 0
|
||||
@@ -167,9 +189,9 @@ class TestLoadAllFromDb:
|
||||
"src.server.database.service.AnimeSeriesService.get_all",
|
||||
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()
|
||||
|
||||
assert count == 0
|
||||
@@ -194,9 +216,9 @@ class TestLoadSingleSeriesFromDb:
|
||||
"src.server.database.service.AnimeSeriesService.get_by_folder",
|
||||
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)")
|
||||
|
||||
assert serie is not None
|
||||
@@ -218,9 +240,9 @@ class TestLoadSingleSeriesFromDb:
|
||||
"src.server.database.service.AnimeSeriesService.get_by_folder",
|
||||
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")
|
||||
|
||||
assert serie is None
|
||||
@@ -241,9 +263,9 @@ class TestLoadSingleSeriesFromDb:
|
||||
"src.server.database.service.AnimeSeriesService.get_by_folder",
|
||||
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")
|
||||
|
||||
assert serie is None
|
||||
@@ -254,9 +276,9 @@ class TestInvalidateCache:
|
||||
|
||||
def test_invalidate_cache_clears_keydict(self, sample_serie):
|
||||
"""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
|
||||
assert len(serie_list.keyDict) == 1
|
||||
|
||||
@@ -276,9 +298,9 @@ class TestInvalidateCache:
|
||||
"src.server.database.service.AnimeSeriesService.get_all",
|
||||
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.invalidate_cache()
|
||||
|
||||
@@ -7,8 +7,8 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
from src.core.SerieScanner import SerieScanner
|
||||
from src.server.database.models import AnimeSeries
|
||||
from src.server.SerieScanner import SerieScanner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -40,14 +40,16 @@ def mock_loader():
|
||||
|
||||
@pytest.fixture
|
||||
def sample_serie():
|
||||
"""Create a sample Serie for testing."""
|
||||
return Serie(
|
||||
key="attack-on-titan",
|
||||
name="Attack on Titan",
|
||||
site="aniworld.to",
|
||||
folder="Attack on Titan (2013)",
|
||||
episodeDict={1: [2, 3, 4]}
|
||||
)
|
||||
"""Create a sample AnimeSeries mock for testing."""
|
||||
anime = MagicMock(spec=AnimeSeries)
|
||||
anime.key = "attack-on-titan"
|
||||
anime.name = "Attack on Titan"
|
||||
anime.site = "aniworld.to"
|
||||
anime.folder = "Attack on Titan (2013)"
|
||||
anime.year = None
|
||||
anime.nfo_path = None
|
||||
anime.episodeDict = {1: [2, 3, 4]}
|
||||
return anime
|
||||
|
||||
|
||||
class TestSerieScannerInitialization:
|
||||
@@ -134,7 +136,9 @@ class TestSerieScannerScan:
|
||||
'_SerieScanner__get_missing_episodes_and_season',
|
||||
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()
|
||||
|
||||
assert sample_serie.key in scanner.keyDict
|
||||
@@ -519,61 +523,17 @@ class TestFindMp4Files:
|
||||
class TestReadDataFromFile:
|
||||
"""Test __read_data_from_file method."""
|
||||
|
||||
def test_reads_data_file(self, mock_loader):
|
||||
"""Should read Serie from 'data' file when no DB entry exists."""
|
||||
import tempfile
|
||||
def test_empty_folder_name_returns_none(self, temp_directory, mock_loader):
|
||||
"""Empty folder name -> returns None (no DB lookup attempted)."""
|
||||
scanner = SerieScanner(temp_directory, mock_loader)
|
||||
result = scanner._SerieScanner__read_data_from_file("")
|
||||
assert result is None
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
anime_folder = os.path.join(tmpdir, "SomeAnime")
|
||||
os.makedirs(anime_folder)
|
||||
|
||||
# Create a data file
|
||||
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:
|
||||
def test_nonexistent_folder_no_exception(self, temp_directory, mock_loader):
|
||||
"""Folder doesn't exist -> returns None without raising."""
|
||||
scanner = SerieScanner(temp_directory, mock_loader)
|
||||
result = scanner._SerieScanner__read_data_from_file("Nonexistent Folder")
|
||||
assert result is None
|
||||
"""Test reinit method."""
|
||||
|
||||
def test_clears_keydict(self, temp_directory, mock_loader):
|
||||
@@ -640,12 +600,10 @@ class TestScanProgressEvents:
|
||||
call_data = completion_handler.call_args[0][0]
|
||||
assert call_data["success"] is True
|
||||
|
||||
def test_scan_emits_error_on_no_key(
|
||||
def test_scan_emits_error(
|
||||
self, temp_directory, mock_loader
|
||||
):
|
||||
"""Should emit on_error when NoKeyFoundException occurs."""
|
||||
from src.core.exceptions.Exceptions import NoKeyFoundException
|
||||
|
||||
"""Should emit on_error when an exception occurs."""
|
||||
scanner = SerieScanner(temp_directory, mock_loader)
|
||||
error_handler = MagicMock()
|
||||
scanner.subscribe_on_error(error_handler)
|
||||
@@ -657,7 +615,7 @@ class TestScanProgressEvents:
|
||||
), \
|
||||
patch.object(
|
||||
scanner, '_SerieScanner__read_data_from_file',
|
||||
side_effect=NoKeyFoundException("no key"),
|
||||
side_effect=RuntimeError("DB error"),
|
||||
):
|
||||
scanner.scan()
|
||||
|
||||
@@ -666,186 +624,4 @@ class TestScanProgressEvents:
|
||||
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
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
from src.core.SerieScanner import SerieScanner
|
||||
from src.server.database.models import AnimeSeries
|
||||
from src.server.SerieScanner import SerieScanner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -51,7 +51,7 @@ class TestGetSerieFromFolderDbLookup:
|
||||
mock_anime_series.episodes = []
|
||||
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)
|
||||
result = scanner._SerieScanner__read_data_from_file("Rooster Fighter (2026)")
|
||||
|
||||
@@ -60,48 +60,30 @@ class TestGetSerieFromFolderDbLookup:
|
||||
assert result.name == "Rooster Fighter"
|
||||
assert result.year == 2026
|
||||
|
||||
def test_db_miss_falls_back_to_provider_callback(self, temp_directory, mock_loader):
|
||||
"""DB miss -> _db_lookup callback called."""
|
||||
lookup = MagicMock(return_value=Serie(
|
||||
key="rooster-fighter",
|
||||
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)")
|
||||
|
||||
assert result is not 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)
|
||||
def test_db_miss_returns_none(self, temp_directory, mock_loader):
|
||||
"""DB miss -> returns None (no fallback)."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.execute.return_value.scalar_one_or_none.return_value = None
|
||||
|
||||
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)")
|
||||
|
||||
result = scanner._SerieScanner__read_data_from_file("Legacy Series")
|
||||
|
||||
assert result is not None
|
||||
assert result.key == "legacy-series"
|
||||
assert result.folder == "Legacy Series"
|
||||
assert result is None
|
||||
|
||||
def test_db_lookup_exception_caught_and_logged(self, temp_directory, mock_loader):
|
||||
"""DB exception -> fallback to provider callback."""
|
||||
def bad_lookup(folder):
|
||||
raise RuntimeError("DB connection failed")
|
||||
"""DB exception -> returns None without raising."""
|
||||
with patch(
|
||||
"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.core.SerieScanner"), "warning") as mock_warning:
|
||||
with patch.object(logging.getLogger("src.server.SerieScanner"), "warning") as mock_warning:
|
||||
result = scanner._SerieScanner__read_data_from_file("Rooster Fighter (2026)")
|
||||
mock_warning.assert_called()
|
||||
assert any("DB lookup failed" in str(c) for c in mock_warning.call_args_list)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetSerieFromFolderEdgeCases:
|
||||
|
||||
@@ -4,8 +4,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
from src.core.SerieScanner import SerieScanner
|
||||
from src.server.database.models import AnimeSeries
|
||||
from src.server.SerieScanner import SerieScanner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -18,15 +18,15 @@ def mock_session_factory():
|
||||
|
||||
@pytest.fixture
|
||||
def sample_serie():
|
||||
"""Create a sample Serie for testing."""
|
||||
return Serie(
|
||||
key="attack-on-titan",
|
||||
name="Attack on Titan",
|
||||
site="aniworld.to",
|
||||
folder="Attack on Titan (2013)",
|
||||
episodeDict={1: [1, 2, 3], 2: [1, 2]},
|
||||
year=2013
|
||||
)
|
||||
"""Create a sample AnimeSeries mock for testing."""
|
||||
anime = MagicMock(spec=AnimeSeries)
|
||||
anime.key = "attack-on-titan"
|
||||
anime.name = "Attack on Titan"
|
||||
anime.site = "aniworld.to"
|
||||
anime.folder = "Attack on Titan (2013)"
|
||||
anime.year = 2013
|
||||
anime.episodeDict = {1: [1, 2, 3], 2: [1, 2]}
|
||||
return anime
|
||||
|
||||
|
||||
class TestPersistSerieToDb:
|
||||
|
||||
@@ -10,19 +10,19 @@ Tests the functionality of SeriesApp including:
|
||||
- Error handling
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.SeriesApp import SeriesApp
|
||||
from src.server.SeriesApp import SeriesApp
|
||||
|
||||
|
||||
class TestSeriesAppInitialization:
|
||||
"""Test SeriesApp initialization."""
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
def test_init_success(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
@@ -37,7 +37,7 @@ class TestSeriesAppInitialization:
|
||||
mock_loaders.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):
|
||||
"""Test that initialization failure raises error."""
|
||||
test_dir = "/test/anime"
|
||||
@@ -49,10 +49,10 @@ class TestSeriesAppInitialization:
|
||||
with pytest.raises(RuntimeError):
|
||||
SeriesApp(test_dir)
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.core.SeriesApp.settings')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.settings')
|
||||
def test_init_uses_config_fallback_for_nfo_service(
|
||||
self,
|
||||
mock_settings,
|
||||
@@ -71,9 +71,9 @@ class TestSeriesAppSearch:
|
||||
"""Test search functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
async def test_search_success(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
@@ -96,9 +96,9 @@ class TestSeriesAppSearch:
|
||||
app.loader.search.assert_called_once_with("test anime")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
async def test_search_failure_raises_error(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
@@ -120,9 +120,9 @@ class TestSeriesAppDownload:
|
||||
"""Test download functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
async def test_download_success(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders, tmp_path
|
||||
):
|
||||
@@ -157,9 +157,9 @@ class TestSeriesAppDownload:
|
||||
assert os.path.exists(folder_path)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
async def test_download_with_progress_callback(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders, tmp_path
|
||||
):
|
||||
@@ -197,9 +197,9 @@ class TestSeriesAppDownload:
|
||||
app.loader.download.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
async def test_download_cancellation(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders, tmp_path
|
||||
):
|
||||
@@ -234,9 +234,9 @@ class TestSeriesAppDownload:
|
||||
assert app._events.download_status.called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
async def test_download_failure(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
@@ -268,9 +268,9 @@ class TestSeriesAppReScan:
|
||||
"""Test directory scanning functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
async def test_rescan_success(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
@@ -295,9 +295,9 @@ class TestSeriesAppReScan:
|
||||
app.serie_scanner.scan.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
async def test_rescan_with_events(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
@@ -327,9 +327,9 @@ class TestSeriesAppReScan:
|
||||
app.serie_scanner.unsubscribe_on_progress.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
async def test_rescan_cancellation(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
@@ -359,9 +359,9 @@ class TestSeriesAppReScan:
|
||||
class TestSeriesAppCancellation:
|
||||
"""Test operation cancellation."""
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
def test_cancel_operation_when_running(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
@@ -373,9 +373,9 @@ class TestSeriesAppCancellation:
|
||||
# as the cancel mechanism may have changed
|
||||
pass
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
def test_cancel_operation_when_idle(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
@@ -387,9 +387,9 @@ class TestSeriesAppCancellation:
|
||||
class TestSeriesAppGetters:
|
||||
"""Test getter methods."""
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
def test_get_series_list(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
@@ -400,9 +400,9 @@ class TestSeriesAppGetters:
|
||||
# Verify app was created
|
||||
assert app is not None
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
def test_get_operation_status(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
@@ -410,9 +410,9 @@ class TestSeriesAppGetters:
|
||||
# Skip - operation status API may have changed
|
||||
pass
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
def test_get_current_operation(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
@@ -424,9 +424,9 @@ class TestSeriesAppGetters:
|
||||
class TestSeriesAppDatabaseInit:
|
||||
"""Test SeriesApp initialization (no database support in core)."""
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
def test_init_creates_components(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
@@ -446,14 +446,14 @@ class TestSeriesAppDatabaseInit:
|
||||
class TestSeriesAppLoadSeriesFromList:
|
||||
"""Test SeriesApp load_series_from_list method."""
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
def test_load_series_from_list_populates_keydict(
|
||||
self, mock_serie_list, mock_scanner, mock_loaders
|
||||
):
|
||||
"""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"
|
||||
mock_list = Mock()
|
||||
@@ -464,23 +464,17 @@ class TestSeriesAppLoadSeriesFromList:
|
||||
# Create app
|
||||
app = SeriesApp(test_dir)
|
||||
|
||||
# Create test series
|
||||
test_series = [
|
||||
Serie(
|
||||
key="anime1",
|
||||
name="Anime 1",
|
||||
site="aniworld.to",
|
||||
folder="Anime 1",
|
||||
episodeDict={1: [1, 2]}
|
||||
),
|
||||
Serie(
|
||||
key="anime2",
|
||||
name="Anime 2",
|
||||
site="aniworld.to",
|
||||
folder="Anime 2",
|
||||
episodeDict={1: [1]}
|
||||
),
|
||||
]
|
||||
# Create test series (AnimeSeries mocks)
|
||||
def make_anime(key, name, folder):
|
||||
anime = MagicMock(spec=AnimeSeries)
|
||||
anime.key = key
|
||||
anime.name = name
|
||||
anime.site = "aniworld.to"
|
||||
anime.folder = folder
|
||||
anime.episodeDict = {1: [1, 2]} if key == "anime1" else {1: [1]}
|
||||
return anime
|
||||
|
||||
test_series = [make_anime("anime1", "Anime 1", "Anime 1"), make_anime("anime2", "Anime 2", "Anime 2")]
|
||||
|
||||
# Load series
|
||||
app.load_series_from_list(test_series)
|
||||
@@ -493,33 +487,30 @@ class TestSeriesAppLoadSeriesFromList:
|
||||
class TestSeriesAppGetAllSeriesFromDataFiles:
|
||||
"""Test get_all_series_from_data_files() functionality."""
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
def test_returns_list_of_series(
|
||||
self, mock_serie_list_class, mock_scanner, mock_loaders
|
||||
):
|
||||
"""Test that get_all_series_from_data_files returns a list of Serie."""
|
||||
from src.core.entities.series import Serie
|
||||
"""Test that get_all_series_from_data_files returns a list of AnimeSeries."""
|
||||
from src.server.database.models import AnimeSeries
|
||||
|
||||
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 = [
|
||||
Serie(
|
||||
key="anime1",
|
||||
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]}
|
||||
),
|
||||
make_anime("anime1", "Anime 1", "Anime 1 (2020)"),
|
||||
make_anime("anime2", "Anime 2", "Anime 2 (2021)"),
|
||||
]
|
||||
|
||||
# Setup mock for the main SerieList instance (constructor call)
|
||||
@@ -539,16 +530,16 @@ class TestSeriesAppGetAllSeriesFromDataFiles:
|
||||
# Call the method
|
||||
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 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[1].key == "anime2"
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
def test_returns_empty_list_when_no_data_files(
|
||||
self, mock_serie_list_class, mock_scanner, mock_loaders
|
||||
):
|
||||
@@ -575,9 +566,9 @@ class TestSeriesAppGetAllSeriesFromDataFiles:
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 0
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
def test_handles_exception_gracefully(
|
||||
self, mock_serie_list_class, mock_scanner, mock_loaders
|
||||
):
|
||||
@@ -604,13 +595,13 @@ class TestSeriesAppGetAllSeriesFromDataFiles:
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 0
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
def test_uses_file_based_loading(
|
||||
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"
|
||||
|
||||
# Setup mock for the main SerieList instance
|
||||
@@ -629,24 +620,23 @@ class TestSeriesAppGetAllSeriesFromDataFiles:
|
||||
# Call the method
|
||||
app.get_all_series_from_data_files()
|
||||
|
||||
# Verify the second SerieList was created with correct params
|
||||
# (file-based loading: db_session=None, skip_load=False)
|
||||
# Verify SerieList was called twice (main + temp)
|
||||
calls = mock_serie_list_class.call_args_list
|
||||
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]
|
||||
assert second_call.kwargs.get('db_session') is None
|
||||
assert second_call.kwargs.get('skip_load') is False
|
||||
# base_path is passed as positional argument
|
||||
assert second_call.args[0] == test_dir
|
||||
|
||||
@patch('src.core.SeriesApp.Loaders')
|
||||
@patch('src.core.SeriesApp.SerieScanner')
|
||||
@patch('src.core.SeriesApp.SerieList')
|
||||
@patch('src.server.SeriesApp.Loaders')
|
||||
@patch('src.server.SeriesApp.SerieScanner')
|
||||
@patch('src.server.SeriesApp.SerieList')
|
||||
def test_does_not_modify_main_list(
|
||||
self, mock_serie_list_class, mock_scanner, mock_loaders
|
||||
):
|
||||
"""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"
|
||||
|
||||
@@ -657,15 +647,13 @@ class TestSeriesAppGetAllSeriesFromDataFiles:
|
||||
|
||||
# Setup mock for the temporary SerieList
|
||||
mock_temp_list = Mock()
|
||||
mock_temp_list.get_all.return_value = [
|
||||
Serie(
|
||||
key="anime1",
|
||||
name="Anime 1",
|
||||
site="https://aniworld.to",
|
||||
folder="Anime 1",
|
||||
episodeDict={}
|
||||
)
|
||||
]
|
||||
anime = MagicMock(spec=AnimeSeries)
|
||||
anime.key = "anime1"
|
||||
anime.name = "Anime 1"
|
||||
anime.site = "https://aniworld.to"
|
||||
anime.folder = "Anime 1"
|
||||
anime.episodeDict = {}
|
||||
mock_temp_list.get_all.return_value = [anime]
|
||||
|
||||
mock_serie_list_class.side_effect = [mock_main_list, mock_temp_list]
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import aiohttp
|
||||
import pytest
|
||||
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
|
||||
|
||||
@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import aiohttp
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user