- Add sanitize_folder_name utility for filesystem-safe folder names - Add sanitized_folder property to Serie entity - Update SerieList.add() to use sanitized display names for folders - Add scan_single_series() method for targeted episode scanning - Enhance add_series endpoint: DB save -> folder create -> targeted scan - Update response to include missing_episodes and total_missing - Add comprehensive unit tests for new functionality - Update API tests with proper mock support
232 lines
7.1 KiB
Python
232 lines
7.1 KiB
Python
import json
|
|
import warnings
|
|
|
|
from src.server.utils.filesystem import sanitize_folder_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
|
|
|
|
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]]
|
|
):
|
|
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
|
|
|
|
def __str__(self):
|
|
"""String representation of Serie object"""
|
|
return (
|
|
f"Serie(key='{self.key}', name='{self.name}', "
|
|
f"site='{self.site}', folder='{self.folder}', "
|
|
f"episodeDict={self.episodeDict})"
|
|
)
|
|
|
|
@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 sanitized_folder(self) -> str:
|
|
"""
|
|
Get a filesystem-safe folder name derived from the display name.
|
|
|
|
This property returns a sanitized version of the series name
|
|
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
|
|
|
|
Example:
|
|
>>> serie = Serie("attack-on-titan", "Attack on Titan: Final", ...)
|
|
>>> serie.sanitized_folder
|
|
'Attack on Titan Final'
|
|
"""
|
|
# Use name if available, fall back to folder, then key
|
|
name_to_sanitize = self._name 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 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()
|
|
}
|
|
}
|
|
|
|
@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
|
|
)
|
|
|
|
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)
|