diff --git a/alfred/domain/release/services.py b/alfred/domain/release/services.py index 10cb5e8..2d0b1f5 100644 --- a/alfred/domain/release/services.py +++ b/alfred/domain/release/services.py @@ -16,7 +16,9 @@ from .value_objects import ( _RESOLUTIONS, _SOURCES, _VIDEO_META, + MediaTypeToken, ParsedRelease, + ParsePath, ) @@ -39,12 +41,12 @@ def parse_release(name: str) -> ParsedRelease: and run token-level matchers (season/episode, tech, languages, audio, video, edition, title, year). """ - parse_path = "direct" + parse_path = ParsePath.DIRECT.value # Always try to extract a bracket-enclosed site tag first. clean, site_tag = _strip_site_tag(name) if site_tag is not None: - parse_path = "sanitized" + parse_path = ParsePath.SANITIZED.value if not _is_well_formed(clean): return ParsedRelease( @@ -60,9 +62,9 @@ def parse_release(name: str) -> ParsedRelease: codec=None, group="UNKNOWN", tech_string="", - media_type="unknown", + media_type=MediaTypeToken.UNKNOWN.value, site_tag=site_tag, - parse_path="ai", + parse_path=ParsePath.AI.value, ) name = clean @@ -137,19 +139,19 @@ def _infer_media_type( integrale_tokens = {t.upper() for t in _MEDIA_TYPE_TOKENS.get("integrale", [])} if upper_tokens & doc_tokens: - return "documentary" + return MediaTypeToken.DOCUMENTARY.value if upper_tokens & concert_tokens: - return "concert" + return MediaTypeToken.CONCERT.value if ( edition in {"COMPLETE", "INTEGRALE", "COLLECTION"} or upper_tokens & integrale_tokens ) and season is None: - return "tv_complete" + return MediaTypeToken.TV_COMPLETE.value if season is not None: - return "tv_show" + return MediaTypeToken.TV_SHOW.value if any([quality, source, codec, year]): - return "movie" - return "unknown" + return MediaTypeToken.MOVIE.value + return MediaTypeToken.UNKNOWN.value def _is_well_formed(name: str) -> bool: diff --git a/alfred/domain/release/value_objects.py b/alfred/domain/release/value_objects.py index ee5ee3b..e42b3be 100644 --- a/alfred/domain/release/value_objects.py +++ b/alfred/domain/release/value_objects.py @@ -3,7 +3,9 @@ from __future__ import annotations from dataclasses import dataclass, field +from enum import Enum +from ..shared.exceptions import ValidationError from .knowledge import ( load_audio, load_codecs, @@ -50,6 +52,38 @@ def _sanitize_for_fs(text: str) -> str: return text.translate(_WIN_FORBIDDEN_TABLE) +class MediaTypeToken(str, Enum): + """ + Canonical values for ``ParsedRelease.media_type``. + + Inherits from ``str`` so existing string-based comparisons (``== "movie"``, + JSON serialization, TMDB DTO interop) keep working unchanged. The enum + serves both as documentation and as the set of valid values for + ``__post_init__`` validation. + """ + + MOVIE = "movie" + TV_SHOW = "tv_show" + TV_COMPLETE = "tv_complete" + DOCUMENTARY = "documentary" + CONCERT = "concert" + OTHER = "other" + UNKNOWN = "unknown" + + +class ParsePath(str, Enum): + """How a ``ParsedRelease`` was produced. ``str``-backed for the same + reasons as :class:`MediaTypeToken`.""" + + DIRECT = "direct" + SANITIZED = "sanitized" + AI = "ai" + + +_VALID_MEDIA_TYPES: frozenset[str] = frozenset(m.value for m in MediaTypeToken) +_VALID_PARSE_PATHS: frozenset[str] = frozenset(p.value for p in ParsePath) + + def _strip_episode_from_normalized(normalized: str) -> str: """ Remove all episode parts (Exx) from a normalized release name, keeping Sxx. @@ -85,13 +119,11 @@ class ParsedRelease: codec: str | None # x265, HEVC, … group: str # release group, "UNKNOWN" if missing tech_string: str # quality.source.codec joined with dots - media_type: str = ( - "unknown" # "movie" | "tv_show" | "tv_complete" | "other" | "unknown" - ) + media_type: str = MediaTypeToken.UNKNOWN.value # one of MediaTypeToken values site_tag: str | None = ( None # site watermark stripped from name, e.g. "TGx", "OxTorrent.vc" ) - parse_path: str = "direct" # "direct" | "sanitized" | "ai" + parse_path: str = ParsePath.DIRECT.value # one of ParsePath values languages: list[str] = field(default_factory=list) # ["MULTI", "VFF"], ["FRENCH"], … audio_codec: str | None = None # "DTS-HD.MA", "DDP", "EAC3", … audio_channels: str | None = None # "5.1", "7.1", "2.0", … @@ -99,6 +131,44 @@ class ParsedRelease: hdr_format: str | None = None # "DV", "HDR10", "DV.HDR10", … edition: str | None = None # "UNRATED", "EXTENDED", "DIRECTORS.CUT", … + def __post_init__(self) -> None: + if not self.raw: + raise ValidationError("ParsedRelease.raw cannot be empty") + if not self.group: + raise ValidationError("ParsedRelease.group cannot be empty") + if self.year is not None and not (1888 <= self.year <= 2100): + raise ValidationError( + f"ParsedRelease.year out of range: {self.year}" + ) + if self.season is not None and not (0 <= self.season <= 100): + raise ValidationError( + f"ParsedRelease.season out of range: {self.season}" + ) + if self.episode is not None and not (0 <= self.episode <= 9999): + raise ValidationError( + f"ParsedRelease.episode out of range: {self.episode}" + ) + if self.episode_end is not None: + if not (0 <= self.episode_end <= 9999): + raise ValidationError( + f"ParsedRelease.episode_end out of range: {self.episode_end}" + ) + if self.episode is not None and self.episode_end < self.episode: + raise ValidationError( + f"ParsedRelease.episode_end ({self.episode_end}) < " + f"episode ({self.episode})" + ) + if self.media_type not in _VALID_MEDIA_TYPES: + raise ValidationError( + f"ParsedRelease.media_type invalid: {self.media_type!r} " + f"(expected one of {sorted(_VALID_MEDIA_TYPES)})" + ) + if self.parse_path not in _VALID_PARSE_PATHS: + raise ValidationError( + f"ParsedRelease.parse_path invalid: {self.parse_path!r} " + f"(expected one of {sorted(_VALID_PARSE_PATHS)})" + ) + @property def is_season_pack(self) -> bool: return self.season is not None and self.episode is None