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:
2026-06-04 21:11:53 +02:00
parent 09d454d4c0
commit 5526ab884a
76 changed files with 1186 additions and 3574 deletions

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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:
await EpisodeService.create(
db=db,
series_id=anime_series.id,
season=season,
episode_number=ep
)
for ep in anime.episodes:
await EpisodeService.create(
db=db,
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,

View File

@@ -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",

View File

@@ -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,

View File

@@ -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__)

View File

@@ -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,

View 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")

View File

@@ -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",
]

View File

@@ -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.

View File

@@ -0,0 +1,3 @@
from src.server.exceptions.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
__all__ = ["MatchNotFoundError", "NoKeyFoundException"]

View File

@@ -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__)

View File

@@ -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__)

View File

@@ -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,47 +942,16 @@ 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
async with get_db_session() as db:
anime_series_list = await AnimeSeriesService.get_all(
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:
await EpisodeService.create(
db=db,
series_id=anime_series.id,
season=season,
episode_number=episode_number,
)
# Create Episode records for each episode in episodes relationship
if anime.episodes:
for episode in anime.episodes:
await EpisodeService.create(
db=db,
series_id=anime_series.id,
season=episode.season,
episode_number=episode.episode_number,
)
logger.info(
"Added series to database: %s (key=%s, year=%s)",
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

View File

@@ -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__)

View File

@@ -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

View File

@@ -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()

View File

@@ -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__

View File

@@ -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 = {}

View File

@@ -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()

View File

@@ -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()
@@ -62,7 +74,7 @@ class TestEpisodeRemovedFromMissingListAfterDownload:
queue_repository=MagicMock(),
max_retries=3,
)
service._directory = tmp
service._directory = str(mock_anime_service._directory)
yield service
@pytest.mark.asyncio
@@ -70,24 +82,24 @@ class TestEpisodeRemovedFromMissingListAfterDownload:
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"]
anime = 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"
assert 2 in anime.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]
assert 2 not in anime.episodeDict[1], "Episode should be removed from missing list"
assert anime.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."""
"""Verify in-memory AnimeSeries.episodeDict is updated after download."""
@pytest.fixture
def mock_anime_service(self):
@@ -95,21 +107,20 @@ class TestDownloadUpdatesInMemoryCache:
anime_service = MagicMock()
anime_service._directory = "/tmp/test"
# Create mock app with series having multiple seasons and episodes
serie = Serie(
anime = make_anime(
key="multi-season-series",
name="Multi Season Series",
site="https://example.com",
folder="Multi Season Series",
episodeDict={
episode_dict={
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]
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()
@@ -125,23 +136,22 @@ class TestDownloadUpdatesInMemoryCache:
queue_repository=MagicMock(),
max_retries=3,
)
service._directory = tmp
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 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"]
"""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
serie.episodeDict = {1: [1, 2, 3, 4, 5], 2: [1, 2, 3]}
anime.episodeDict = {1: [1, 2, 3, 4, 5], 2: [1, 2, 3]}
# Verify preconditions
assert 1 in serie.episodeDict[1]
assert 3 in serie.episodeDict[2]
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)
@@ -149,125 +159,39 @@ class TestDownloadUpdatesInMemoryCache:
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"
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 serie.episodeDict, "Season 2 should still exist (has episode 1, 3)"
assert 2 in anime.episodeDict, "Season 2 should still exist (has episode 1, 3)"
@pytest.mark.asyncio
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"]
anime = 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
anime.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]
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 serie.episodeDict, "Season 1 should be removed"
assert 1 not in anime.episodeDict, "Season 1 should be removed"
# Season 2 should still exist
assert 2 in serie.episodeDict, "Season 2 should still exist"
assert 2 in anime.episodeDict, "Season 2 should still exist"
class TestDataFileUpdatedAfterDownload:
"""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(),
max_retries=3,
)
service._directory = str(mock_anime_service._directory)
yield service
@pytest.mark.asyncio
async def test_data_file_updated_after_download(
self, mock_download_service, mock_anime_service, temp_dir
):
"""Verify data file is updated after download when data file exists."""
serie = mock_anime_service._app.list.keyDict["test-series-with-data"]
data_path = temp_dir / "Test Series" / "data"
# Verify data file exists before test
assert data_path.exists(), "Data file should exist before test"
# Read original data file
with open(data_path) as f:
original_data = json.load(f)
assert 2 in original_data["episodeDict"]["1"], "Episode should be in original data"
# Simulate download completion
mock_download_service._remove_episode_from_memory("test-series-with-data", 1, 2)
# Read updated data file
with open(data_path) as f:
updated_data = json.load(f)
# Verify episode 2 was removed from data file
assert 2 not in updated_data["episodeDict"]["1"], "Episode should be removed from data file"
assert updated_data["episodeDict"]["1"] == [1, 3]
class TestDataFileNotRequiredForDownload:
"""Verify downloads work even when data file doesn't exist."""
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"
assert not data_path.exists(), "No data file should be created"

View File

@@ -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()

View File

@@ -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(

View File

@@ -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 (< > : " / \ | ? *)

View File

@@ -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):

View File

@@ -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

View File

@@ -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)

View File

@@ -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(

View File

@@ -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",

View File

@@ -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 == ''

View File

@@ -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:

View File

@@ -7,7 +7,7 @@ functionality.
import unittest
from src.core.interfaces.callbacks import (
from src.server.interfaces.callbacks import (
CallbackManager,
CompletionCallback,
CompletionContext,

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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:

View File

@@ -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"

View File

@@ -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.
Note: File-based load_series() has been removed from SerieList.
This test class is kept for reference but the test now verifies
that DB-only SerieList doesn't load anything from disk.
"""
def test_load_series_skips_ignored_folders(self, tmp_path):
"""Test load_series skips folders matching ignore patterns."""
from src.core.entities.SerieList import SerieList
from src.core.entities.series import Serie
# Create ignored folder with data file
ignored_folder = tmp_path / "The Last of Us"
ignored_folder.mkdir()
ignored_data = ignored_folder / "data"
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")

View File

@@ -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

View File

@@ -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,

View File

@@ -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.

View File

@@ -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()

View File

@@ -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))

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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"
)

View File

@@ -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)
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
result = scanner._SerieScanner__read_data_from_file("Rooster Fighter (2026)")
with patch("src.server.SerieScanner.get_sync_session", return_value=mock_session):
scanner = SerieScanner(temp_directory, mock_loader)
result = scanner._SerieScanner__read_data_from_file("Unknown Series (2026)")
assert result is not None
assert result.key == "rooster-fighter"
lookup.assert_called_once_with("Rooster Fighter (2026)")
def test_no_db_no_callback_generates_key_from_folder_name(self, temp_directory, mock_loader):
"""No DB entry, no callback -> key generated from folder name."""
folder = os.path.join(temp_directory, "Legacy Series")
os.makedirs(folder, exist_ok=True)
# No key file, no data file - should fall through to Step 4 (key generation)
scanner = SerieScanner(temp_directory, mock_loader)
result = scanner._SerieScanner__read_data_from_file("Legacy Series")
assert result is not None
assert result.key == "legacy-series"
assert result.folder == "Legacy Series"
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:
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)
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:

View File

@@ -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:

View File

@@ -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,45 +446,39 @@ 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()
mock_list.GetMissingEpisode.return_value = []
mock_list.keyDict = {}
mock_serie_list.return_value = mock_list
# 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)
# Verify series were loaded
assert "anime1" in mock_list.keyDict
assert "anime2" in mock_list.keyDict
@@ -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]

View File

@@ -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

View File

@@ -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):