refactor(tmdb): ACL pass — push VOs into DTOs, split search per media type

Anti-corruption boundary tightened on the TMDB adapter:

* TmdbMovieInfo / TmdbShowInfo now carry domain VOs (TmdbId, ImdbId,
  MovieTitle, ReleaseYear, ShowStatus) instead of raw scalars —
  validation happens at the boundary, not three layers later.
* ShowStatus enum added (domain/tv_shows/value_objects) with a
  from_tmdb() mapper that falls back to UNKNOWN + logs a warning on
  unrecognized values. TVShow.status is now ShowStatus, not str.
* MovieTitle cap raised from 100 to 150 chars.
* MediaResult / ExternalIds dropped. Replaced by per-media search
  DTOs: TmdbMovieSearchResult and TmdbShowSearchResult. Neither
  carries imdb_id — search no longer enriches with external_ids
  (callers needing imdb_id follow up with get_movie_info /
  get_tv_show_info on the chosen tmdb_id).
* TMDBClient: search_multi / search_media / _parse_result removed.
  search_movies (/search/movie) and search_shows (/search/tv) added,
  each parsing hits into VO-typed DTOs.
* SearchMovieUseCase returns a list of MovieHit (flattened to
  primitives for the agent). New symmetric SearchShowUseCase +
  ShowHit / SearchShowResponse DTOs.
* agent/tools/api.py: find_media_imdb_id → search_movies +
  search_shows wrappers.
* FileEntry moved from domain/shared/ports/filesystem_scanner.py to
  domain/shared/file_entry.py (it's a DTO, not a Protocol); size_kb
  (float) → size (int bytes). Scanner and SubtitleIdentifier
  updated.

Tests: 79/79 pass on tests/infrastructure/api/ +
tests/application/test_search_movie.py +
tests/application/test_search_show.py.
This commit is contained in:
2026-05-26 05:45:30 +02:00
parent cffafa2e60
commit c62ae81275
29 changed files with 735 additions and 490 deletions
+24 -12
View File
@@ -5,6 +5,7 @@ from typing import Any
from alfred.application.movies import SearchMovieUseCase from alfred.application.movies import SearchMovieUseCase
from alfred.application.torrents import AddTorrentUseCase, SearchTorrentsUseCase from alfred.application.torrents import AddTorrentUseCase, SearchTorrentsUseCase
from alfred.application.tv_shows import SearchShowUseCase
from alfred.infrastructure.api.knaben import knaben_client from alfred.infrastructure.api.knaben import knaben_client
from alfred.infrastructure.api.qbittorrent import qbittorrent_client from alfred.infrastructure.api.qbittorrent import qbittorrent_client
from alfred.infrastructure.api.tmdb import tmdb_client from alfred.infrastructure.api.tmdb import tmdb_client
@@ -13,25 +14,36 @@ from alfred.infrastructure.persistence import get_memory
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def find_media_imdb_id(media_title: str) -> dict[str, Any]: def search_movies(media_title: str) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/find_media_imdb_id.yaml.""" """Thin tool wrapper — semantics live in alfred/agent/tools/specs/search_movies.yaml."""
use_case = SearchMovieUseCase(tmdb_client) use_case = SearchMovieUseCase(tmdb_client)
response = use_case.execute(media_title) response = use_case.execute(media_title)
result = response.to_dict() result = response.to_dict()
if result.get("status") == "ok": if result.get("status") == "ok":
memory = get_memory() memory = get_memory()
memory.stm.set_entity( memory.stm.set_entity("last_movie_search", {"hits": result.get("hits", [])})
"last_media_search", memory.stm.set_topic("searching_movie")
{ logger.debug(
"title": result.get("title"), f"Stored movie search result in STM: {len(result.get('hits', []))} hits"
"imdb_id": result.get("imdb_id"), )
"media_type": result.get("media_type"),
"tmdb_id": result.get("tmdb_id"), return result
},
def search_shows(show_title: str) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/search_shows.yaml."""
use_case = SearchShowUseCase(tmdb_client)
response = use_case.execute(show_title)
result = response.to_dict()
if result.get("status") == "ok":
memory = get_memory()
memory.stm.set_entity("last_show_search", {"hits": result.get("hits", [])})
memory.stm.set_topic("searching_show")
logger.debug(
f"Stored show search result in STM: {len(result.get('hits', []))} hits"
) )
memory.stm.set_topic("searching_media")
logger.debug(f"Stored media search result in STM: {result.get('title')}")
return result return result
+1 -5
View File
@@ -7,8 +7,7 @@ from .dto import (
ManageSubtitlesResponse, ManageSubtitlesResponse,
MoveMediaResponse, MoveMediaResponse,
PlacedSubtitle, PlacedSubtitle,
SetFolderPathResponse, )
)
from .list_folder import ListFolderUseCase from .list_folder import ListFolderUseCase
from .manage_subtitles import ManageSubtitlesUseCase from .manage_subtitles import ManageSubtitlesUseCase
from .move_media import MoveMediaUseCase from .move_media import MoveMediaUseCase
@@ -22,10 +21,8 @@ from .resolve_destination import (
resolve_season_destination, resolve_season_destination,
resolve_series_destination, resolve_series_destination,
) )
from .set_folder_path import SetFolderPathUseCase
__all__ = [ __all__ = [
"SetFolderPathUseCase",
"ListFolderUseCase", "ListFolderUseCase",
"CreateSeedLinksUseCase", "CreateSeedLinksUseCase",
"MoveMediaUseCase", "MoveMediaUseCase",
@@ -38,7 +35,6 @@ __all__ = [
"resolve_episode_destination", "resolve_episode_destination",
"resolve_movie_destination", "resolve_movie_destination",
"resolve_series_destination", "resolve_series_destination",
"SetFolderPathResponse",
"ListFolderResponse", "ListFolderResponse",
"CreateSeedLinksResponse", "CreateSeedLinksResponse",
"MoveMediaResponse", "MoveMediaResponse",
+2 -4
View File
@@ -1,12 +1,10 @@
"""Movie use cases.""" """Movie use cases."""
from .dto import SearchMovieResponse from .dto import MovieHit, SearchMovieResponse
from .rescan import MovieRescanFailed, rescan_movie
from .search_movie import SearchMovieUseCase from .search_movie import SearchMovieUseCase
__all__ = [ __all__ = [
"MovieRescanFailed", "MovieHit",
"SearchMovieResponse", "SearchMovieResponse",
"SearchMovieUseCase", "SearchMovieUseCase",
"rescan_movie",
] ]
+19 -17
View File
@@ -1,6 +1,21 @@
"""Movie application DTOs.""" """Movie application DTOs."""
from dataclasses import dataclass from dataclasses import dataclass, field
@dataclass(frozen=True)
class MovieHit:
"""One movie hit, flattened for transport to the agent."""
tmdb_id: int
title: str
release_year: int | None = None
def to_dict(self) -> dict:
out: dict = {"tmdb_id": self.tmdb_id, "title": self.title}
if self.release_year is not None:
out["release_year"] = self.release_year
return out
@dataclass @dataclass
@@ -8,31 +23,18 @@ class SearchMovieResponse:
"""Response from searching for a movie.""" """Response from searching for a movie."""
status: str status: str
imdb_id: str | None = None hits: list[MovieHit] = field(default_factory=list)
title: str | None = None
media_type: str | None = None
tmdb_id: int | None = None
release_date: str | None = None
error: str | None = None error: str | None = None
message: str | None = None message: str | None = None
def to_dict(self): def to_dict(self):
"""Convert to dict for agent compatibility.""" """Convert to dict for agent compatibility."""
result = {"status": self.status} result: dict = {"status": self.status}
if self.error: if self.error:
result["error"] = self.error result["error"] = self.error
result["message"] = self.message result["message"] = self.message
else: else:
if self.imdb_id: result["hits"] = [h.to_dict() for h in self.hits]
result["imdb_id"] = self.imdb_id
if self.title:
result["title"] = self.title
if self.media_type:
result["media_type"] = self.media_type
if self.tmdb_id:
result["tmdb_id"] = self.tmdb_id
if self.release_date:
result["release_date"] = self.release_date
return result return result
+17 -48
View File
@@ -6,71 +6,40 @@ from alfred.infrastructure.api.tmdb import (
TMDBAPIError, TMDBAPIError,
TMDBClient, TMDBClient,
TMDBConfigurationError, TMDBConfigurationError,
TMDBNotFoundError,
) )
from .dto import SearchMovieResponse from .dto import MovieHit, SearchMovieResponse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SearchMovieUseCase: class SearchMovieUseCase:
""" """List movies matching a free-text query via TMDB ``/search/movie``.
Use case for searching a movie and retrieving its IMDb ID.
This orchestrates the TMDB API client to find movie information. The use case is a thin orchestrator: it asks the client for hits,
flattens domain VOs into agent-friendly primitives, and wraps
errors. It deliberately does **not** look up ``imdb_id`` —
enrichment is the caller's job (via :meth:`TMDBClient.get_movie_info`
on a chosen ``tmdb_id``).
""" """
def __init__(self, tmdb_client: TMDBClient): def __init__(self, tmdb_client: TMDBClient):
"""
Initialize use case.
Args:
tmdb_client: TMDB API client
"""
self.tmdb_client = tmdb_client self.tmdb_client = tmdb_client
def execute(self, media_title: str) -> SearchMovieResponse: def execute(self, media_title: str) -> SearchMovieResponse:
"""
Search for a movie by title.
Args:
media_title: Title of the movie to search for
Returns:
SearchMovieResponse with movie information or error
"""
try: try:
# Use the TMDB client to search for media results = self.tmdb_client.search_movies(media_title)
result = self.tmdb_client.search_media(media_title)
# Check if IMDb ID was found # FUCK THIS hits = [
if result.imdb_id: MovieHit(
logger.info(f"IMDb ID found for '{media_title}': {result.imdb_id}") tmdb_id=r.tmdb_id.value,
return SearchMovieResponse( title=str(r.title),
status="ok", release_year=r.release_year.value if r.release_year else None,
imdb_id=result.imdb_id,
title=result.title,
media_type=result.media_type,
tmdb_id=result.tmdb_id,
release_date=result.release_date,
)
else:
logger.warning(f"No IMDb ID available for '{media_title}'")
return SearchMovieResponse(
status="ok",
title=result.title,
media_type=result.media_type,
tmdb_id=result.tmdb_id,
error="no_imdb_id",
message=f"No IMDb ID available for '{result.title}'",
)
except TMDBNotFoundError as e:
logger.info(f"Media not found: {e}")
return SearchMovieResponse(
status="error", error="not_found", message=str(e)
) )
for r in results
]
logger.info(f"search_movies({media_title!r}) → {len(hits)} hits")
return SearchMovieResponse(status="ok", hits=hits)
except TMDBConfigurationError as e: except TMDBConfigurationError as e:
logger.error(f"TMDB configuration error: {e}") logger.error(f"TMDB configuration error: {e}")
+10 -2
View File
@@ -7,7 +7,15 @@ reusing the existing release pipeline (``inspect_release``) rather
than duplicating its parse/probe logic. than duplicating its parse/probe logic.
""" """
from .rescan import rescan_show from .dto import SearchShowResponse, ShowHit
from .search_show import SearchShowUseCase
from .walker import SeasonFolder, ShowTree, walk_show from .walker import SeasonFolder, ShowTree, walk_show
__all__ = ["SeasonFolder", "ShowTree", "rescan_show", "walk_show"] __all__ = [
"SearchShowResponse",
"SearchShowUseCase",
"SeasonFolder",
"ShowHit",
"ShowTree",
"walk_show",
]
+39
View File
@@ -0,0 +1,39 @@
"""TV show application DTOs."""
from dataclasses import dataclass, field
@dataclass(frozen=True)
class ShowHit:
"""One TV-show hit, flattened for transport to the agent."""
tmdb_id: int
name: str
first_air_year: int | None = None
def to_dict(self) -> dict:
out: dict = {"tmdb_id": self.tmdb_id, "name": self.name}
if self.first_air_year is not None:
out["first_air_year"] = self.first_air_year
return out
@dataclass
class SearchShowResponse:
"""Response from searching for a TV show."""
status: str
hits: list[ShowHit] = field(default_factory=list)
error: str | None = None
message: str | None = None
def to_dict(self):
result: dict = {"status": self.status}
if self.error:
result["error"] = self.error
result["message"] = self.message
else:
result["hits"] = [h.to_dict() for h in self.hits]
return result
@@ -0,0 +1,59 @@
"""Search TV show use case."""
import logging
from alfred.infrastructure.api.tmdb import (
TMDBAPIError,
TMDBClient,
TMDBConfigurationError,
)
from .dto import SearchShowResponse, ShowHit
logger = logging.getLogger(__name__)
class SearchShowUseCase:
"""List TV shows matching a free-text query via TMDB ``/search/tv``.
Symmetric to :class:`alfred.application.movies.SearchMovieUseCase`:
thin orchestrator, flattens domain VOs into agent-friendly
primitives, no ``imdb_id`` enrichment (caller follows up with
:meth:`TMDBClient.get_tv_show_info` on a chosen ``tmdb_id``).
"""
def __init__(self, tmdb_client: TMDBClient):
self.tmdb_client = tmdb_client
def execute(self, show_title: str) -> SearchShowResponse:
try:
results = self.tmdb_client.search_shows(show_title)
hits = [
ShowHit(
tmdb_id=r.tmdb_id.value,
name=r.name,
first_air_year=r.first_air_year,
)
for r in results
]
logger.info(f"search_shows({show_title!r}) → {len(hits)} hits")
return SearchShowResponse(status="ok", hits=hits)
except TMDBConfigurationError as e:
logger.error(f"TMDB configuration error: {e}")
return SearchShowResponse(
status="error", error="configuration_error", message=str(e)
)
except TMDBAPIError as e:
logger.error(f"TMDB API error: {e}")
return SearchShowResponse(
status="error", error="api_error", message=str(e)
)
except ValueError as e:
logger.error(f"Validation error: {e}")
return SearchShowResponse(
status="error", error="validation_failed", message=str(e)
)
+2 -2
View File
@@ -55,9 +55,9 @@ class MovieTitle:
f"Movie title must be a string, got {type(self.value)}" f"Movie title must be a string, got {type(self.value)}"
) )
if len(self.value) > 100: if len(self.value) > 150:
raise ValidationError( raise ValidationError(
f"Movie title too long: {len(self.value)} characters (max 100)" f"Movie title too long: {len(self.value)} characters (max 150)"
) )
+2
View File
@@ -1,11 +1,13 @@
"""Shared kernel - Common domain concepts used across subdomains.""" """Shared kernel - Common domain concepts used across subdomains."""
from .exceptions import DomainException, ValidationError from .exceptions import DomainException, ValidationError
from .file_entry import FileEntry
from .value_objects import FilePath, FileSize, ImdbId, Language from .value_objects import FilePath, FileSize, ImdbId, Language
__all__ = [ __all__ = [
"DomainException", "DomainException",
"ValidationError", "ValidationError",
"FileEntry",
"ImdbId", "ImdbId",
"FilePath", "FilePath",
"FileSize", "FileSize",
+39
View File
@@ -0,0 +1,39 @@
"""FileEntry — frozen snapshot of one filesystem entry.
Produced by a ``FilesystemScanner`` adapter and consumed by the domain.
The domain never calls ``Path.iterdir``, ``Path.is_file``, ``Path.stat``
or ``open()`` directly; it reasons from these snapshots only. One scan =
one I/O round-trip; no callbacks back to disk.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class FileEntry:
"""Frozen snapshot of one filesystem entry, taken at scan time.
The entry carries enough metadata for the domain to classify and order
files without re-querying the OS. ``size`` is expressed in bytes and is
``None`` for directories and for files whose size could not be read.
"""
path: Path
is_file: bool
is_dir: bool
size: int | None
@property
def name(self) -> str:
return self.path.name
@property
def stem(self) -> str:
return self.path.stem
@property
def suffix(self) -> str:
return self.path.suffix
+1 -2
View File
@@ -6,12 +6,11 @@ injection and calls it. Tests can pass in-memory fakes that satisfy the
Protocol without going through real I/O. Protocol without going through real I/O.
""" """
from .filesystem_scanner import FileEntry, FilesystemScanner from .filesystem_scanner import FilesystemScanner
from .language_repository import LanguageRepository from .language_repository import LanguageRepository
from .media_prober import MediaProber, SubtitleStreamInfo from .media_prober import MediaProber, SubtitleStreamInfo
__all__ = [ __all__ = [
"FileEntry",
"FilesystemScanner", "FilesystemScanner",
"LanguageRepository", "LanguageRepository",
"MediaProber", "MediaProber",
@@ -7,36 +7,10 @@ reasons from there. One scan = one I/O round-trip; no callbacks back to disk.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Protocol from typing import Protocol
from alfred.domain.shared.file_entry import FileEntry
@dataclass(frozen=True)
class FileEntry:
"""Frozen snapshot of one filesystem entry, taken at scan time.
The entry carries enough metadata for the domain to classify and order
files without re-querying the OS. ``size_kb`` is ``None`` for directories
and for files whose size could not be read.
"""
path: Path
is_file: bool
is_dir: bool
size_kb: float | None
@property
def name(self) -> str:
return self.path.name
@property
def stem(self) -> str:
return self.path.stem
@property
def suffix(self) -> str:
return self.path.suffix
class FilesystemScanner(Protocol): class FilesystemScanner(Protocol):
@@ -259,7 +259,7 @@ class SubtitleIdentifier:
subtitle_type=subtitle_type, subtitle_type=subtitle_type,
is_embedded=False, is_embedded=False,
file_path=entry.path, file_path=entry.path,
file_size_kb=entry.size_kb, file_size_kb=(entry.size / 1024) if entry.size is not None else None,
entry_count=entry_count, entry_count=entry_count,
confidence=confidence, confidence=confidence,
raw_tokens=tokens, raw_tokens=tokens,
+5 -5
View File
@@ -11,7 +11,7 @@ Typical usage during a TMDB hydration::
tmdb_id=TmdbId(1396), tmdb_id=TmdbId(1396),
title="Breaking Bad", title="Breaking Bad",
imdb_id=ImdbId("tt0903747"), imdb_id=ImdbId("tt0903747"),
status="Ended", status=ShowStatus.ENDED,
) )
builder.season_builder(1).set_episode_count(7).add_episode(Episode( builder.season_builder(1).set_episode_count(7).add_episode(Episode(
season_number=SeasonNumber(1), season_number=SeasonNumber(1),
@@ -45,7 +45,7 @@ from __future__ import annotations
from ..shared.value_objects import ImdbId, TmdbId from ..shared.value_objects import ImdbId, TmdbId
from .entities import Episode, Season, TVShow from .entities import Episode, Season, TVShow
from .value_objects import EpisodeNumber, SeasonNumber from .value_objects import EpisodeNumber, SeasonNumber, ShowStatus
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
# SeasonBuilder # SeasonBuilder
@@ -139,7 +139,7 @@ class TVShowBuilder:
tmdb_id: TmdbId, tmdb_id: TmdbId,
title: str, title: str,
imdb_id: ImdbId | None = None, imdb_id: ImdbId | None = None,
status: str = "unknown", status: ShowStatus = ShowStatus.UNKNOWN,
) -> None: ) -> None:
if not isinstance(tmdb_id, TmdbId): if not isinstance(tmdb_id, TmdbId):
raise ValueError( raise ValueError(
@@ -152,7 +152,7 @@ class TVShowBuilder:
self._tmdb_id: TmdbId = tmdb_id self._tmdb_id: TmdbId = tmdb_id
self._title: str = title self._title: str = title
self._imdb_id: ImdbId | None = imdb_id self._imdb_id: ImdbId | None = imdb_id
self._status: str = status self._status: ShowStatus = status
self._season_builders: dict[SeasonNumber, SeasonBuilder] = {} self._season_builders: dict[SeasonNumber, SeasonBuilder] = {}
@classmethod @classmethod
@@ -184,7 +184,7 @@ class TVShowBuilder:
self._imdb_id = imdb_id self._imdb_id = imdb_id
return self return self
def set_status(self, status: str) -> TVShowBuilder: def set_status(self, status: ShowStatus) -> TVShowBuilder:
self._status = status self._status = status
return self return self
+5 -7
View File
@@ -42,7 +42,7 @@ from ..shared.value_objects import (
TmdbId, TmdbId,
to_dot_folder_name, to_dot_folder_name,
) )
from .value_objects import EpisodeNumber, SeasonNumber from .value_objects import EpisodeNumber, SeasonNumber, ShowStatus
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
# Episode # Episode
@@ -208,17 +208,15 @@ class TVShow:
Identity is carried by ``tmdb_id`` (primary key, required) and Identity is carried by ``tmdb_id`` (primary key, required) and
``imdb_id`` (optional secondary anchor — some TMDB items lack one). ``imdb_id`` (optional secondary anchor — some TMDB items lack one).
:attr:`status` mirrors the raw TMDB status string verbatim :attr:`status` is a :class:`ShowStatus` — the TMDB-native lifecycle
(``"Returning Series"`` / ``"Ended"`` / ``"Canceled"`` …). No enum. :attr:`ShowStatus.UNKNOWN` is the default placeholder used by
taxonomy of our own — callers that need a normalized view can map the v2 library index when no status has been synced yet.
it themselves. The ``"unknown"`` default matches the auto-heal
placeholder used by the v2 library index.
""" """
tmdb_id: TmdbId tmdb_id: TmdbId
title: str title: str
imdb_id: ImdbId | None = None imdb_id: ImdbId | None = None
status: str = "unknown" status: ShowStatus = ShowStatus.UNKNOWN
seasons: tuple[Season, ...] = () seasons: tuple[Season, ...] = ()
def __post_init__(self) -> None: def __post_init__(self) -> None:
+42
View File
@@ -2,11 +2,53 @@
from __future__ import annotations from __future__ import annotations
import logging
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from ..shared.exceptions import ValidationError from ..shared.exceptions import ValidationError
logger = logging.getLogger(__name__)
class ShowStatus(Enum):
"""Lifecycle status of a TV show.
Values mirror TMDB's ``status`` strings verbatim so the boundary
code (``parse_tv_show_info``) maps one-to-one without an opaque
translation table buried elsewhere. If TMDB ships a new status,
:meth:`from_tmdb` falls back to :attr:`UNKNOWN` and emits a
warning — sync keeps working, the gap is visible in logs.
"""
RETURNING_SERIES = "Returning Series"
PLANNED = "Planned"
IN_PRODUCTION = "In Production"
ENDED = "Ended"
CANCELED = "Canceled"
PILOT = "Pilot"
UNKNOWN = "Unknown"
@classmethod
def from_tmdb(cls, raw: str) -> ShowStatus:
"""Map a TMDB ``status`` string to a :class:`ShowStatus`.
Unknown strings fall back to :attr:`UNKNOWN` with a logged
warning — TMDB occasionally adds new lifecycle states and we
prefer to keep syncing rather than break the whole pipeline.
"""
for member in cls:
if member.value == raw:
return member
logger.warning(
f"ShowStatus: unrecognized TMDB status {raw!r}, "
f"falling back to ShowStatus.UNKNOWN"
)
return cls.UNKNOWN
def __str__(self) -> str:
return self.value
class SeasonMode(Enum): class SeasonMode(Enum):
""" """
+19 -6
View File
@@ -1,7 +1,15 @@
"""TMDB API client.""" """TMDB API client."""
from .client import TMDBClient from .client import TMDBClient
from .dto import ExternalIds, MediaResult from .dto import (
TmdbMovieInfo,
TmdbMovieSearchResult,
TmdbSeasonInfo,
TmdbShowInfo,
TmdbShowSearchResult,
parse_movie_info,
parse_tv_show_info,
)
from .exceptions import ( from .exceptions import (
TMDBAPIError, TMDBAPIError,
TMDBConfigurationError, TMDBConfigurationError,
@@ -13,12 +21,17 @@ from .exceptions import (
tmdb_client = TMDBClient() tmdb_client = TMDBClient()
__all__ = [ __all__ = [
"TMDBClient",
"MediaResult",
"ExternalIds",
"TMDBError",
"TMDBConfigurationError",
"TMDBAPIError", "TMDBAPIError",
"TMDBClient",
"TMDBConfigurationError",
"TMDBError",
"TMDBNotFoundError", "TMDBNotFoundError",
"TmdbMovieInfo",
"TmdbMovieSearchResult",
"TmdbSeasonInfo",
"TmdbShowInfo",
"TmdbShowSearchResult",
"parse_movie_info",
"parse_tv_show_info",
"tmdb_client", "tmdb_client",
] ]
+84 -99
View File
@@ -1,6 +1,7 @@
"""TMDB (The Movie Database) API client.""" """TMDB (The Movie Database) API client."""
import logging import logging
from datetime import date
from typing import Any from typing import Any
import requests import requests
@@ -8,10 +9,14 @@ from requests.exceptions import HTTPError, RequestException, Timeout
from alfred.settings import Settings, settings from alfred.settings import Settings, settings
from alfred.domain.movies.value_objects import MovieTitle, ReleaseYear
from alfred.domain.shared.value_objects import TmdbId
from .dto import ( from .dto import (
MediaResult,
TmdbMovieInfo, TmdbMovieInfo,
TmdbMovieSearchResult,
TmdbShowInfo, TmdbShowInfo,
TmdbShowSearchResult,
parse_movie_info, parse_movie_info,
parse_tv_show_info, parse_tv_show_info,
) )
@@ -24,6 +29,22 @@ from .exceptions import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _parse_year(raw: Any) -> int | None:
"""Extract the year from a TMDB ISO date (``YYYY-MM-DD``).
Returns ``None`` if ``raw`` is missing, empty, or not a valid
ISO date — TMDB occasionally returns ``""`` for unscheduled
releases. Symmetric to the parsing done in
:mod:`alfred.infrastructure.api.tmdb.dto`.
"""
if not isinstance(raw, str) or not raw:
return None
try:
return date.fromisoformat(raw).year
except ValueError:
return None
class TMDBClient: class TMDBClient:
""" """
Client for interacting with The Movie Database (TMDB) API. Client for interacting with The Movie Database (TMDB) API.
@@ -33,9 +54,9 @@ class TMDBClient:
Example: Example:
>>> client = TMDBClient() >>> client = TMDBClient()
>>> result = client.search_media("Inception") >>> results = client.search_movies("Inception")
>>> print(result.imdb_id) >>> print(results[0].title)
'tt1375666' MovieTitle('Inception')
""" """
def __init__( def __init__(
@@ -123,34 +144,79 @@ class TMDBClient:
logger.error(f"TMDB API request failed: {e}") logger.error(f"TMDB API request failed: {e}")
raise TMDBAPIError(f"Failed to connect to TMDB API: {e}") from e raise TMDBAPIError(f"Failed to connect to TMDB API: {e}") from e
def search_multi(self, query: str) -> list[dict[str, Any]]: def search_movies(self, query: str) -> list[TmdbMovieSearchResult]:
""" """Search the TMDB ``/search/movie`` endpoint.
Search for movies and TV shows.
Args: Args:
query: Search query (movie or TV show title) query: free-text title query.
Returns: Returns:
List of search results All hits as :class:`TmdbMovieSearchResult` (empty list if
none — no exception). Identity is wrapped in domain VOs
(``TmdbId``, ``MovieTitle``, ``ReleaseYear``). No
``external_ids`` enrichment: callers that need ``imdb_id``
should follow up with :meth:`get_movie_info` on the chosen
hit's ``tmdb_id``.
Raises: Raises:
TMDBAPIError: If request fails TMDBAPIError: if the HTTP call fails.
TMDBNotFoundError: If no results found ValueError: if ``query`` is empty / too long.
""" """
if not query or not isinstance(query, str): if not query or not isinstance(query, str):
raise ValueError("Query must be a non-empty string") raise ValueError("Query must be a non-empty string")
if len(query) > 500: if len(query) > 500:
raise ValueError("Query is too long (max 500 characters)") raise ValueError("Query is too long (max 500 characters)")
data = self._make_request("/search/multi", {"query": query}) data = self._make_request("/search/movie", {"query": query})
raw_results = data.get("results", []) or []
logger.info(f"search_movies({query!r}) → {len(raw_results)} hits")
return [self._parse_movie_hit(r) for r in raw_results]
results = data.get("results", []) def search_shows(self, query: str) -> list[TmdbShowSearchResult]:
if not results: """Search the TMDB ``/search/tv`` endpoint. Symmetric to
raise TMDBNotFoundError(f"No results found for '{query}'") :meth:`search_movies`.
logger.info(f"Found {len(results)} results for '{query}'") Args:
return results query: free-text title query.
Returns:
All hits as :class:`TmdbShowSearchResult` (empty list if
none). ``tmdb_id`` is wrapped as :class:`TmdbId`; ``name``
stays plain ``str`` (no filename-safe VO for shows).
``first_air_year`` is parsed from ``first_air_date``.
Raises:
TMDBAPIError: if the HTTP call fails.
ValueError: if ``query`` is empty / too long.
"""
if not query or not isinstance(query, str):
raise ValueError("Query must be a non-empty string")
if len(query) > 500:
raise ValueError("Query is too long (max 500 characters)")
data = self._make_request("/search/tv", {"query": query})
raw_results = data.get("results", []) or []
logger.info(f"search_shows({query!r}) → {len(raw_results)} hits")
return [self._parse_show_hit(r) for r in raw_results]
@staticmethod
def _parse_movie_hit(raw: dict[str, Any]) -> TmdbMovieSearchResult:
title_raw = raw.get("title") or raw.get("original_title") or "Unknown"
year = _parse_year(raw.get("release_date"))
return TmdbMovieSearchResult(
tmdb_id=TmdbId(raw["id"]),
title=MovieTitle(title_raw),
release_year=ReleaseYear(year) if year is not None else None,
)
@staticmethod
def _parse_show_hit(raw: dict[str, Any]) -> TmdbShowSearchResult:
name_raw = raw.get("name") or raw.get("original_name") or "Unknown"
return TmdbShowSearchResult(
tmdb_id=TmdbId(raw["id"]),
name=name_raw,
first_air_year=_parse_year(raw.get("first_air_date")),
)
def get_external_ids(self, media_type: str, tmdb_id: int) -> dict[str, Any]: def get_external_ids(self, media_type: str, tmdb_id: int) -> dict[str, Any]:
""" """
@@ -174,87 +240,6 @@ class TMDBClient:
endpoint = f"/{media_type}/{tmdb_id}/external_ids" endpoint = f"/{media_type}/{tmdb_id}/external_ids"
return self._make_request(endpoint) return self._make_request(endpoint)
def search_media(self, title: str) -> MediaResult:
"""
Search for a media item and return detailed information including IMDb ID.
This is a convenience method that combines search and external ID lookup.
Args:
title: Title of the movie or TV show
Returns:
MediaResult with all available information
Raises:
TMDBAPIError: If request fails
TMDBNotFoundError: If media not found
"""
# Search for media
results = self.search_multi(title)
# Get the first (most relevant) result
top_result = results[0]
# Validate result structure
if "id" not in top_result or "media_type" not in top_result:
raise TMDBAPIError("Invalid TMDB response structure")
media_type = top_result["media_type"]
# Skip if not movie or TV show
if media_type not in ("movie", "tv"):
logger.warning(f"Skipping result of type: {media_type}")
if len(results) > 1:
# Try next result
return self._parse_result(results[1])
raise TMDBNotFoundError(f"No movie or TV show found for '{title}'")
return self._parse_result(top_result)
def _parse_result(self, result: dict[str, Any]) -> MediaResult:
"""
Parse a TMDB result into a MediaResult object.
Args:
result: Raw TMDB result dict
Returns:
MediaResult object
"""
tmdb_id = result["id"]
media_type = result["media_type"]
title = result.get("title") or result.get("name", "Unknown")
# Get external IDs (including IMDb)
try:
external_ids = self.get_external_ids(media_type, tmdb_id)
imdb_id = external_ids.get("imdb_id")
except TMDBAPIError as e:
logger.warning(f"Failed to get external IDs: {e}")
imdb_id = None
# Extract other useful information
overview = result.get("overview")
release_date = result.get("release_date") or result.get("first_air_date")
poster_path = result.get("poster_path")
vote_average = result.get("vote_average")
logger.info(
f"Found: {title} (Type: {media_type}, TMDB ID: {tmdb_id}, IMDb: {imdb_id})"
)
return MediaResult(
tmdb_id=tmdb_id,
title=title,
media_type=media_type,
imdb_id=imdb_id,
overview=overview,
release_date=release_date,
poster_path=poster_path,
vote_average=vote_average,
)
def get_movie_details(self, movie_id: int) -> dict[str, Any]: def get_movie_details(self, movie_id: int) -> dict[str, Any]:
""" """
Get detailed information about a movie. Get detailed information about a movie.
+81 -50
View File
@@ -6,30 +6,39 @@ from dataclasses import dataclass, field
from datetime import date from datetime import date
from typing import Any from typing import Any
from alfred.domain.movies.value_objects import MovieTitle, ReleaseYear
@dataclass from alfred.domain.shared.value_objects import ImdbId, TmdbId
class MediaResult: from alfred.domain.tv_shows.value_objects import ShowStatus
"""Represents a media search result from TMDB."""
tmdb_id: int
title: str
media_type: str # 'movie' or 'tv'
imdb_id: str | None = None
overview: str | None = None
release_date: str | None = None
poster_path: str | None = None
vote_average: float | None = None
@dataclass @dataclass(frozen=True)
class ExternalIds: class TmdbMovieSearchResult:
"""External IDs for a media item.""" """One movie hit from the TMDB ``/search/movie`` endpoint.
imdb_id: str | None = None Carries the minimal identity + display facts needed by the search
tvdb_id: int | None = None use case. No external-ids enrichment — that requires a separate
facebook_id: str | None = None HTTP call and is out of scope for search results. Callers that
instagram_id: str | None = None need ``imdb_id`` for a specific hit should subsequently call
twitter_id: str | None = None :meth:`TMDBClient.get_movie_info` with the ``tmdb_id``.
"""
tmdb_id: TmdbId
title: MovieTitle
release_year: ReleaseYear | None = None
@dataclass(frozen=True)
class TmdbShowSearchResult:
"""One TV-show hit from the TMDB ``/search/tv`` endpoint.
Symmetric to :class:`TmdbMovieSearchResult`. Uses ``name`` (TMDB's
TV equivalent of ``title``) and ``first_air_year`` (parsed from
``first_air_date``). No external-ids enrichment.
"""
tmdb_id: TmdbId
name: str
first_air_year: int | None = None
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
@@ -58,12 +67,19 @@ class TmdbShowInfo:
Populated by :meth:`TMDBClient.get_tv_show_info`. Carries only the Populated by :meth:`TMDBClient.get_tv_show_info`. Carries only the
fields the v2 library index needs to cache; richer details remain fields the v2 library index needs to cache; richer details remain
on-demand via the raw client methods. on-demand via the raw client methods.
Identity fields are typed as value objects from the domain shared
kernel (``TmdbId``, ``ImdbId``) so validation happens at the
boundary with TMDB. ``status`` is a :class:`ShowStatus` — TMDB's
raw string is mapped via :meth:`ShowStatus.from_tmdb` at parse
time. ``name`` stays plain ``str``: shows don't have a filename-
safe wrapper analogous to :class:`MovieTitle`.
""" """
tmdb_id: int tmdb_id: TmdbId
imdb_id: str | None imdb_id: ImdbId | None
name: str name: str
status: str status: ShowStatus
seasons: tuple[TmdbSeasonInfo, ...] = field(default_factory=tuple) seasons: tuple[TmdbSeasonInfo, ...] = field(default_factory=tuple)
@@ -93,33 +109,37 @@ def parse_tv_show_info(
""" """
ref = today or date.today() ref = today or date.today()
tmdb_id = details.get("id") tmdb_id_raw = details.get("id")
if not isinstance(tmdb_id, int): if not isinstance(tmdb_id_raw, int):
raise ValueError( raise ValueError(
f"TMDB show payload missing/invalid 'id': {tmdb_id!r}" f"TMDB show payload missing/invalid 'id': {tmdb_id_raw!r}"
) )
name = details.get("name") name = details.get("name")
if not isinstance(name, str) or not name: if not isinstance(name, str) or not name:
raise ValueError( raise ValueError(
f"TMDB show payload missing/invalid 'name': {name!r}" f"TMDB show payload missing/invalid 'name': {name!r}"
) )
status = details.get("status") status_raw = details.get("status")
if not isinstance(status, str) or not status: if not isinstance(status_raw, str) or not status_raw:
raise ValueError( raise ValueError(
f"TMDB show payload missing/invalid 'status': {status!r}" f"TMDB show payload missing/invalid 'status': {status_raw!r}"
) )
imdb_id_raw = external_ids.get("imdb_id") imdb_id_raw = external_ids.get("imdb_id")
imdb_id = imdb_id_raw if isinstance(imdb_id_raw, str) and imdb_id_raw else None imdb_id = (
ImdbId(imdb_id_raw)
if isinstance(imdb_id_raw, str) and imdb_id_raw
else None
)
seasons_raw = details.get("seasons", []) or [] seasons_raw = details.get("seasons", []) or []
seasons = tuple(_parse_season(s, ref) for s in seasons_raw) seasons = tuple(_parse_season(s, ref) for s in seasons_raw)
return TmdbShowInfo( return TmdbShowInfo(
tmdb_id=tmdb_id, tmdb_id=TmdbId(tmdb_id_raw),
imdb_id=imdb_id, imdb_id=imdb_id,
name=name, name=name,
status=status, status=ShowStatus.from_tmdb(status_raw),
seasons=seasons, seasons=seasons,
) )
@@ -172,15 +192,22 @@ class TmdbMovieInfo:
remain on-demand via the raw client methods. Symmetric to remain on-demand via the raw client methods. Symmetric to
:class:`TmdbShowInfo`. :class:`TmdbShowInfo`.
Identity fields are typed as value objects from the domain shared
kernel (``TmdbId``, ``ImdbId``) so validation happens at the
boundary with TMDB, not three layers later. Likewise ``title``
and ``release_year`` are wrapped as VOs (``MovieTitle``,
``ReleaseYear``) — purely structural wrappers that just enforce
type and (for title) a 150-char filename-safe cap.
``release_year`` is parsed from TMDB's ``release_date`` (the ``release_year`` is parsed from TMDB's ``release_date`` (the
first 4 chars). It is ``None`` when TMDB has no release date first 4 chars). It is ``None`` when TMDB has no release date
(very old or future-dated titles). (very old or future-dated titles).
""" """
tmdb_id: int tmdb_id: TmdbId
imdb_id: str | None imdb_id: ImdbId | None
title: str title: MovieTitle
release_year: int | None release_year: ReleaseYear | None
def parse_movie_info( def parse_movie_info(
@@ -203,27 +230,31 @@ def parse_movie_info(
ValueError: if a required field (``id``, ``title``) is missing ValueError: if a required field (``id``, ``title``) is missing
from ``details``. from ``details``.
""" """
tmdb_id = details.get("id") tmdb_id_raw = details.get("id")
if not isinstance(tmdb_id, int): if not isinstance(tmdb_id_raw, int):
raise ValueError( raise ValueError(
f"TMDB movie payload missing/invalid 'id': {tmdb_id!r}" f"TMDB movie payload missing/invalid 'id': {tmdb_id_raw!r}"
) )
title = details.get("title") title_raw = details.get("title")
if not isinstance(title, str) or not title: if not isinstance(title_raw, str) or not title_raw:
raise ValueError( raise ValueError(
f"TMDB movie payload missing/invalid 'title': {title!r}" f"TMDB movie payload missing/invalid 'title': {title_raw!r}"
) )
imdb_id_raw = external_ids.get("imdb_id") imdb_id_raw = external_ids.get("imdb_id")
imdb_id = imdb_id_raw if isinstance(imdb_id_raw, str) and imdb_id_raw else None imdb_id = (
ImdbId(imdb_id_raw)
if isinstance(imdb_id_raw, str) and imdb_id_raw
else None
)
release_year = _parse_release_year(details.get("release_date")) release_year_raw = _parse_release_year(details.get("release_date"))
return TmdbMovieInfo( return TmdbMovieInfo(
tmdb_id=tmdb_id, tmdb_id=TmdbId(tmdb_id_raw),
imdb_id=imdb_id, imdb_id=imdb_id,
title=title, title=MovieTitle(title_raw),
release_year=release_year, release_year=ReleaseYear(release_year_raw) if release_year_raw is not None else None,
) )
@@ -232,9 +263,9 @@ def _parse_release_year(release_date_raw: Any) -> int | None:
Returns ``None`` for missing, empty, or unparseable values. Returns ``None`` for missing, empty, or unparseable values.
""" """
if not isinstance(release_date_raw, str) or len(release_date_raw) < 4: if not isinstance(release_date_raw, str) or not release_date_raw:
return None return None
try: try:
return int(release_date_raw[:4]) return date.fromisoformat(release_date_raw).year
except ValueError: except ValueError:
return None return None
+5 -5
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from alfred.domain.shared.ports import FileEntry from alfred.domain.shared import FileEntry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -56,11 +56,11 @@ class PathlibFilesystemScanner:
if not (is_file or is_dir): if not (is_file or is_dir):
return None return None
size_kb: float | None = None size: int | None = None
if is_file: if is_file:
try: try:
size_kb = path.stat().st_size / 1024 size = path.stat().st_size
except OSError: except OSError:
size_kb = None size = None
return FileEntry(path=path, is_file=is_file, is_dir=is_dir, size_kb=size_kb) return FileEntry(path=path, is_file=is_file, is_dir=is_dir, size=size)
@@ -217,10 +217,10 @@ def show_index_entry_from(
for s in info.seasons for s in info.seasons
) )
return ShowIndexEntry( return ShowIndexEntry(
tmdb_id=info.tmdb_id, tmdb_id=info.tmdb_id.value,
imdb_id=info.imdb_id, imdb_id=str(info.imdb_id) if info.imdb_id is not None else None,
name=info.name, name=info.name,
status=info.status, status=info.status.value,
metadata=ShowIndexMetadata(path=path, fetched_at=fetched_at), metadata=ShowIndexMetadata(path=path, fetched_at=fetched_at),
seasons=seasons, seasons=seasons,
) )
@@ -236,11 +236,10 @@ def movie_index_entry_from(
) -> MovieIndexEntry: ) -> MovieIndexEntry:
"""Project a movie release + identity facts into one library-index entry. """Project a movie release + identity facts into one library-index entry.
Movies don't have a ``TmdbMovieInfo`` DTO yet (no per-movie TMDB Identity facts are passed explicitly by the caller (the release
cache surface defined in Phase 2), so identity facts are passed supplies ``tmdb_id`` / ``imdb_id``; ``name`` and ``release_year``
explicitly by the caller. The release supplies ``tmdb_id`` / typically come from a ``TmdbMovieInfo`` lookup that the caller
``imdb_id``; ``name`` and ``release_year`` come from the caller's unwraps to primitives before passing in).
TMDB lookup (or a future ``TmdbMovieInfo`` DTO when one ships).
""" """
return MovieIndexEntry( return MovieIndexEntry(
tmdb_id=release.tmdb_id.value, tmdb_id=release.tmdb_id.value,
@@ -40,6 +40,7 @@ from pydantic import ValidationError
from ....api.tmdb.dto import TmdbShowInfo from ....api.tmdb.dto import TmdbShowInfo
from .....domain.releases.entities import MovieRelease, SeriesRelease from .....domain.releases.entities import MovieRelease, SeriesRelease
from .....domain.shared.value_objects import ImdbId, TmdbId from .....domain.shared.value_objects import ImdbId, TmdbId
from .....domain.tv_shows.value_objects import ShowStatus
from .bridge import ( from .bridge import (
movie_index_entry_from, movie_index_entry_from,
movie_release_from_sidecar, movie_release_from_sidecar,
@@ -455,7 +456,7 @@ class DotAlfredTVShowLibraryIndex:
tmdb_id=release.tmdb_id.value, tmdb_id=release.tmdb_id.value,
imdb_id=str(release.imdb_id) if release.imdb_id else None, imdb_id=str(release.imdb_id) if release.imdb_id else None,
name=folder, # placeholder until TMDB sync supplies the real name name=folder, # placeholder until TMDB sync supplies the real name
status="unknown", # placeholder until TMDB sync supplies status status=ShowStatus.UNKNOWN.value, # placeholder until TMDB sync supplies status
metadata={ metadata={
"path": folder, "path": folder,
"fetched_at": now, "fetched_at": now,
+47 -57
View File
@@ -1,15 +1,16 @@
"""Tests for ``alfred.application.movies.search_movie.SearchMovieUseCase``. """Tests for ``alfred.application.movies.search_movie.SearchMovieUseCase``.
The use case wraps ``TMDBClient.search_media`` and converts results / errors The use case wraps :meth:`TMDBClient.search_movies` and flattens each
into a ``SearchMovieResponse`` envelope (status="ok"|"error"). hit's domain VOs into agent-friendly primitives wrapped in a
:class:`SearchMovieResponse` envelope (status="ok"|"error").
Coverage: Coverage:
- ``TestSuccess`` — full MediaResult with imdb_id → ok+imdb_id; missing - ``TestSuccess`` — list of hits flattened, year present/absent,
imdb_id → ok+no_imdb_id; TV media_type preserved. empty list still ``status="ok"``.
- ``TestErrorTranslation`` — ``TMDBNotFoundError`` → not_found, - ``TestErrorTranslation`` — ``TMDBConfigurationError`` →
``TMDBConfigurationError`` → configuration_error, configuration_error, ``TMDBAPIError`` → api_error, ``ValueError``
``TMDBAPIError`` → api_error, ``ValueError`` → validation_failed. → validation_failed.
- ``TestPassThrough`` — query is forwarded to the client unchanged. - ``TestPassThrough`` — query is forwarded to the client unchanged.
TMDBClient is fully mocked — no real HTTP. TMDBClient is fully mocked — no real HTTP.
@@ -22,11 +23,12 @@ from unittest.mock import MagicMock
import pytest import pytest
from alfred.application.movies.search_movie import SearchMovieUseCase from alfred.application.movies.search_movie import SearchMovieUseCase
from alfred.infrastructure.api.tmdb.dto import MediaResult from alfred.domain.movies.value_objects import MovieTitle, ReleaseYear
from alfred.domain.shared.value_objects import TmdbId
from alfred.infrastructure.api.tmdb.dto import TmdbMovieSearchResult
from alfred.infrastructure.api.tmdb.exceptions import ( from alfred.infrastructure.api.tmdb.exceptions import (
TMDBAPIError, TMDBAPIError,
TMDBConfigurationError, TMDBConfigurationError,
TMDBNotFoundError,
) )
@@ -40,19 +42,14 @@ def use_case(client):
return SearchMovieUseCase(client) return SearchMovieUseCase(client)
def _result(**kw) -> MediaResult: def _hit(**kw) -> TmdbMovieSearchResult:
defaults = dict( defaults = dict(
tmdb_id=1, tmdb_id=TmdbId(27205),
title="Inception", title=MovieTitle("Inception"),
media_type="movie", release_year=ReleaseYear(2010),
imdb_id="tt1375666",
overview="o",
release_date="2010-07-15",
poster_path="/x.jpg",
vote_average=8.4,
) )
defaults.update(kw) defaults.update(kw)
return MediaResult(**defaults) return TmdbMovieSearchResult(**defaults)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -61,36 +58,36 @@ def _result(**kw) -> MediaResult:
class TestSuccess: class TestSuccess:
def test_full_result_returns_ok_with_imdb_id(self, client, use_case): def test_single_hit_is_flattened(self, client, use_case):
client.search_media.return_value = _result() client.search_movies.return_value = [_hit()]
r = use_case.execute("Inception") r = use_case.execute("Inception")
assert r.status == "ok" assert r.status == "ok"
assert r.imdb_id == "tt1375666" assert len(r.hits) == 1
assert r.title == "Inception" h = r.hits[0]
assert r.media_type == "movie" assert h.tmdb_id == 27205
assert r.tmdb_id == 1 assert h.title == "Inception"
assert r.vote_average == 8.4 assert h.release_year == 2010
def test_multiple_hits_preserve_order(self, client, use_case):
client.search_movies.return_value = [
_hit(),
_hit(tmdb_id=TmdbId(42), title=MovieTitle("Inception 2")),
]
r = use_case.execute("Inception")
assert [h.tmdb_id for h in r.hits] == [27205, 42]
def test_hit_without_release_year(self, client, use_case):
client.search_movies.return_value = [_hit(release_year=None)]
r = use_case.execute("Inception")
assert r.hits[0].release_year is None
def test_empty_results_returns_ok_with_no_hits(self, client, use_case):
client.search_movies.return_value = []
r = use_case.execute("nothing")
assert r.status == "ok"
assert r.hits == []
assert r.error is None assert r.error is None
def test_tv_result(self, client, use_case):
client.search_media.return_value = _result(
media_type="tv", title="Breaking Bad", imdb_id="tt0903747"
)
r = use_case.execute("Breaking Bad")
assert r.status == "ok"
assert r.media_type == "tv"
assert r.imdb_id == "tt0903747"
def test_missing_imdb_id_returns_ok_with_no_imdb_id_error(self, client, use_case):
client.search_media.return_value = _result(imdb_id=None)
r = use_case.execute("Inception")
assert r.status == "ok"
assert r.error == "no_imdb_id"
assert r.message is not None
assert "Inception" in r.message
assert r.imdb_id is None
assert r.title == "Inception"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Error translation # # Error translation #
@@ -98,28 +95,21 @@ class TestSuccess:
class TestErrorTranslation: class TestErrorTranslation:
def test_not_found(self, client, use_case):
client.search_media.side_effect = TMDBNotFoundError("no match")
r = use_case.execute("ghost")
assert r.status == "error"
assert r.error == "not_found"
assert "no match" in r.message
def test_configuration_error(self, client, use_case): def test_configuration_error(self, client, use_case):
client.search_media.side_effect = TMDBConfigurationError("missing key") client.search_movies.side_effect = TMDBConfigurationError("missing key")
r = use_case.execute("x") r = use_case.execute("x")
assert r.status == "error" assert r.status == "error"
assert r.error == "configuration_error" assert r.error == "configuration_error"
def test_api_error(self, client, use_case): def test_api_error(self, client, use_case):
client.search_media.side_effect = TMDBAPIError("500 oops") client.search_movies.side_effect = TMDBAPIError("500 oops")
r = use_case.execute("x") r = use_case.execute("x")
assert r.status == "error" assert r.status == "error"
assert r.error == "api_error" assert r.error == "api_error"
assert "500" in r.message assert "500" in r.message
def test_validation_error(self, client, use_case): def test_validation_error(self, client, use_case):
client.search_media.side_effect = ValueError("query too long") client.search_movies.side_effect = ValueError("query too long")
r = use_case.execute("x") r = use_case.execute("x")
assert r.status == "error" assert r.status == "error"
assert r.error == "validation_failed" assert r.error == "validation_failed"
@@ -133,6 +123,6 @@ class TestErrorTranslation:
class TestPassThrough: class TestPassThrough:
def test_query_forwarded_verbatim(self, client, use_case): def test_query_forwarded_verbatim(self, client, use_case):
client.search_media.return_value = _result() client.search_movies.return_value = []
use_case.execute("Inception") use_case.execute("Inception")
client.search_media.assert_called_once_with("Inception") client.search_movies.assert_called_once_with("Inception")
+101
View File
@@ -0,0 +1,101 @@
"""Tests for ``alfred.application.tv_shows.search_show.SearchShowUseCase``.
Symmetric to ``test_search_movie.py``. The use case wraps
:meth:`TMDBClient.search_shows` and flattens each hit into a
:class:`ShowHit` wrapped in a :class:`SearchShowResponse` envelope.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from alfred.application.tv_shows.search_show import SearchShowUseCase
from alfred.domain.shared.value_objects import TmdbId
from alfred.infrastructure.api.tmdb.dto import TmdbShowSearchResult
from alfred.infrastructure.api.tmdb.exceptions import (
TMDBAPIError,
TMDBConfigurationError,
)
@pytest.fixture
def client():
return MagicMock()
@pytest.fixture
def use_case(client):
return SearchShowUseCase(client)
def _hit(**kw) -> TmdbShowSearchResult:
defaults = dict(
tmdb_id=TmdbId(84958),
name="Foundation",
first_air_year=2021,
)
defaults.update(kw)
return TmdbShowSearchResult(**defaults)
class TestSuccess:
def test_single_hit_is_flattened(self, client, use_case):
client.search_shows.return_value = [_hit()]
r = use_case.execute("Foundation")
assert r.status == "ok"
assert len(r.hits) == 1
h = r.hits[0]
assert h.tmdb_id == 84958
assert h.name == "Foundation"
assert h.first_air_year == 2021
def test_multiple_hits_preserve_order(self, client, use_case):
client.search_shows.return_value = [
_hit(),
_hit(tmdb_id=TmdbId(42), name="Fallout"),
]
r = use_case.execute("Foundation")
assert [h.tmdb_id for h in r.hits] == [84958, 42]
def test_hit_without_first_air_year(self, client, use_case):
client.search_shows.return_value = [_hit(first_air_year=None)]
r = use_case.execute("Foundation")
assert r.hits[0].first_air_year is None
def test_empty_results_returns_ok_with_no_hits(self, client, use_case):
client.search_shows.return_value = []
r = use_case.execute("nothing")
assert r.status == "ok"
assert r.hits == []
assert r.error is None
class TestErrorTranslation:
def test_configuration_error(self, client, use_case):
client.search_shows.side_effect = TMDBConfigurationError("missing key")
r = use_case.execute("x")
assert r.status == "error"
assert r.error == "configuration_error"
def test_api_error(self, client, use_case):
client.search_shows.side_effect = TMDBAPIError("500 oops")
r = use_case.execute("x")
assert r.status == "error"
assert r.error == "api_error"
assert "500" in r.message
def test_validation_error(self, client, use_case):
client.search_shows.side_effect = ValueError("query too long")
r = use_case.execute("x")
assert r.status == "error"
assert r.error == "validation_failed"
assert "too long" in r.message
class TestPassThrough:
def test_query_forwarded_verbatim(self, client, use_case):
client.search_shows.return_value = []
use_case.execute("Foundation")
client.search_shows.assert_called_once_with("Foundation")
+2 -2
View File
@@ -22,7 +22,7 @@ from unittest.mock import patch
import pytest import pytest
from alfred.domain.shared.ports import FileEntry from alfred.domain.shared import FileEntry
from alfred.domain.subtitles.entities import SubtitleScanResult from alfred.domain.subtitles.entities import SubtitleScanResult
from alfred.domain.subtitles.services.identifier import ( from alfred.domain.subtitles.services.identifier import (
SubtitleIdentifier, SubtitleIdentifier,
@@ -48,7 +48,7 @@ def _file_entry(path) -> FileEntry:
path=path, path=path,
is_file=path.is_file(), is_file=path.is_file(),
is_dir=path.is_dir(), is_dir=path.is_dir(),
size_kb=(path.stat().st_size / 1024) if path.is_file() else None, size=path.stat().st_size if path.is_file() else None,
) )
+103 -118
View File
@@ -6,13 +6,12 @@ Exercises the public surface without any real HTTP traffic:
enforcement of the ``api_key``/``base_url`` invariants. enforcement of the ``api_key``/``base_url`` invariants.
- ``TestMakeRequest`` — error translation for timeouts, HTTP 401/404/5xx, - ``TestMakeRequest`` — error translation for timeouts, HTTP 401/404/5xx,
and generic ``RequestException``. and generic ``RequestException``.
- ``TestSearchMulti`` — query validation, success path, empty-results → - ``TestSearchMovies`` / ``TestSearchShows`` — query validation, success
``TMDBNotFoundError``. path (VO-wrapped hits), empty results yield empty list (no exception).
- ``TestGetExternalIds`` — ``media_type`` whitelist enforcement. - ``TestGetExternalIds`` — ``media_type`` whitelist enforcement.
- ``TestSearchMedia`` — happy path (movie/tv), media_type fallthrough to
the next result, structural-validation error, and the case where
external-ID resolution fails but the search still succeeds.
- ``TestDetailsEndpoints`` — ``get_movie_details`` / ``get_tv_details``. - ``TestDetailsEndpoints`` — ``get_movie_details`` / ``get_tv_details``.
- ``TestGetTvShowInfo`` / ``TestGetMovieInfo`` — composite info getters
aggregating details + external_ids into VO-typed DTOs.
- ``TestIsConfigured`` — reports ``True`` only when both api_key & url set. - ``TestIsConfigured`` — reports ``True`` only when both api_key & url set.
All HTTP is mocked at ``alfred.infrastructure.api.tmdb.client.requests``. All HTTP is mocked at ``alfred.infrastructure.api.tmdb.client.requests``.
@@ -25,8 +24,10 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from requests.exceptions import HTTPError, RequestException, Timeout from requests.exceptions import HTTPError, RequestException, Timeout
from alfred.domain.movies.value_objects import MovieTitle, ReleaseYear
from alfred.domain.shared.value_objects import ImdbId, TmdbId
from alfred.domain.tv_shows.value_objects import ShowStatus
from alfred.infrastructure.api.tmdb.client import TMDBClient from alfred.infrastructure.api.tmdb.client import TMDBClient
from alfred.infrastructure.api.tmdb.dto import MediaResult
from alfred.infrastructure.api.tmdb.exceptions import ( from alfred.infrastructure.api.tmdb.exceptions import (
TMDBAPIError, TMDBAPIError,
TMDBConfigurationError, TMDBConfigurationError,
@@ -85,7 +86,6 @@ class TestInit:
TMDBClient(api_key="", config=cfg) TMDBClient(api_key="", config=cfg)
def test_missing_base_url_raises(self): def test_missing_base_url_raises(self):
# Pass api_key but force empty base_url. Need a config with empty URL too.
from alfred.settings import Settings from alfred.settings import Settings
cfg = Settings(tmdb_api_key="fake", tmdb_base_url="") cfg = Settings(tmdb_api_key="fake", tmdb_base_url="")
@@ -140,34 +140,109 @@ class TestMakeRequest:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# search_multi # # search_movies #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
class TestSearchMulti: class TestSearchMovies:
@pytest.mark.parametrize("bad", ["", None, 123]) @pytest.mark.parametrize("bad", ["", None, 123])
def test_invalid_query_raises_value_error(self, client, bad): def test_invalid_query_raises_value_error(self, client, bad):
with pytest.raises(ValueError): with pytest.raises(ValueError):
client.search_multi(bad) client.search_movies(bad)
def test_query_too_long(self, client): def test_query_too_long(self, client):
with pytest.raises(ValueError, match="too long"): with pytest.raises(ValueError, match="too long"):
client.search_multi("a" * 501) client.search_movies("a" * 501)
@patch("alfred.infrastructure.api.tmdb.client.requests.get") @patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_success(self, mock_get, client): def test_success_wraps_vos(self, mock_get, client):
mock_get.return_value = _ok_response( mock_get.return_value = _ok_response(
{"results": [{"id": 1, "media_type": "movie"}]} {
"results": [
{
"id": 27205,
"title": "Inception",
"release_date": "2010-07-15",
}
]
}
) )
results = client.search_multi("Inception") results = client.search_movies("Inception")
assert len(results) == 1 assert len(results) == 1
assert results[0]["id"] == 1 hit = results[0]
assert hit.tmdb_id == TmdbId(27205)
assert hit.title == MovieTitle("Inception")
assert hit.release_year == ReleaseYear(2010)
@patch("alfred.infrastructure.api.tmdb.client.requests.get") @patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_empty_results_raise_not_found(self, mock_get, client): def test_empty_results_returns_empty_list(self, mock_get, client):
mock_get.return_value = _ok_response({"results": []}) mock_get.return_value = _ok_response({"results": []})
with pytest.raises(TMDBNotFoundError): assert client.search_movies("nothing") == []
client.search_multi("nothing")
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_missing_release_date_yields_none_year(self, mock_get, client):
mock_get.return_value = _ok_response(
{"results": [{"id": 1, "title": "X"}]}
)
results = client.search_movies("X")
assert results[0].release_year is None
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_malformed_year_yields_none(self, mock_get, client):
mock_get.return_value = _ok_response(
{"results": [{"id": 1, "title": "X", "release_date": "soon"}]}
)
results = client.search_movies("X")
assert results[0].release_year is None
# --------------------------------------------------------------------------- #
# search_shows #
# --------------------------------------------------------------------------- #
class TestSearchShows:
@pytest.mark.parametrize("bad", ["", None, 123])
def test_invalid_query_raises_value_error(self, client, bad):
with pytest.raises(ValueError):
client.search_shows(bad)
def test_query_too_long(self, client):
with pytest.raises(ValueError, match="too long"):
client.search_shows("a" * 501)
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_success_wraps_vos(self, mock_get, client):
mock_get.return_value = _ok_response(
{
"results": [
{
"id": 84958,
"name": "Foundation",
"first_air_date": "2021-09-24",
}
]
}
)
results = client.search_shows("Foundation")
assert len(results) == 1
hit = results[0]
assert hit.tmdb_id == TmdbId(84958)
assert hit.name == "Foundation"
assert hit.first_air_year == 2021
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_empty_results_returns_empty_list(self, mock_get, client):
mock_get.return_value = _ok_response({"results": []})
assert client.search_shows("nothing") == []
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_missing_first_air_date_yields_none_year(self, mock_get, client):
mock_get.return_value = _ok_response(
{"results": [{"id": 1, "name": "X"}]}
)
results = client.search_shows("X")
assert results[0].first_air_year is None
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -193,97 +268,6 @@ class TestGetExternalIds:
assert result["imdb_id"] == "tt0903747" assert result["imdb_id"] == "tt0903747"
# --------------------------------------------------------------------------- #
# search_media (composite) #
# --------------------------------------------------------------------------- #
class TestSearchMedia:
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_happy_path_movie(self, mock_get, client):
# First call → /search/multi ; second → /movie/X/external_ids
mock_get.side_effect = [
_ok_response(
{
"results": [
{
"id": 27205,
"media_type": "movie",
"title": "Inception",
"overview": "...",
"release_date": "2010-07-15",
"poster_path": "/x.jpg",
"vote_average": 8.4,
}
]
}
),
_ok_response({"imdb_id": "tt1375666"}),
]
result = client.search_media("Inception")
assert isinstance(result, MediaResult)
assert result.title == "Inception"
assert result.imdb_id == "tt1375666"
assert result.media_type == "movie"
assert result.vote_average == 8.4
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_tv_uses_name_field(self, mock_get, client):
mock_get.side_effect = [
_ok_response(
{"results": [{"id": 1396, "media_type": "tv", "name": "Breaking Bad"}]}
),
_ok_response({"imdb_id": "tt0903747"}),
]
result = client.search_media("Breaking Bad")
assert result.title == "Breaking Bad"
assert result.media_type == "tv"
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_person_result_skipped_uses_next(self, mock_get, client):
# First result is a person → falls through to second result.
mock_get.side_effect = [
_ok_response(
{
"results": [
{"id": 1, "media_type": "person", "name": "X"},
{"id": 2, "media_type": "movie", "title": "Y"},
]
}
),
_ok_response({"imdb_id": "tt7654321"}),
]
result = client.search_media("Y")
assert result.title == "Y"
assert result.media_type == "movie"
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_only_person_result_raises_not_found(self, mock_get, client):
mock_get.return_value = _ok_response(
{"results": [{"id": 1, "media_type": "person", "name": "X"}]}
)
with pytest.raises(TMDBNotFoundError):
client.search_media("X")
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_malformed_top_result_raises(self, mock_get, client):
mock_get.return_value = _ok_response(
{"results": [{"title": "no id or media_type"}]}
)
with pytest.raises(TMDBAPIError, match="Invalid"):
client.search_media("X")
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_external_ids_failure_returns_result_without_imdb(self, mock_get, client):
# Second call (external IDs) fails — the search should still succeed.
mock_get.side_effect = [
_ok_response({"results": [{"id": 1, "media_type": "movie", "title": "X"}]}),
Timeout("slow"),
]
result = client.search_media("X")
assert result.imdb_id is None
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Details endpoints # # Details endpoints #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -333,10 +317,10 @@ class TestGetTvShowInfo:
info = client.get_tv_show_info(84958) info = client.get_tv_show_info(84958)
assert info.tmdb_id == 84958 assert info.tmdb_id == TmdbId(84958)
assert info.imdb_id == "tt0804484" assert info.imdb_id == ImdbId("tt0804484")
assert info.name == "Foundation" assert info.name == "Foundation"
assert info.status == "Returning Series" assert info.status == ShowStatus.RETURNING_SERIES
assert len(info.seasons) == 2 assert len(info.seasons) == 2
assert info.seasons[0].number == 1 assert info.seasons[0].number == 1
assert info.seasons[0].episode_count == 10 assert info.seasons[0].episode_count == 10
@@ -353,11 +337,12 @@ class TestGetTvShowInfo:
"seasons": [], "seasons": [],
} }
), ),
_ok_response({}), # external_ids without imdb_id _ok_response({}),
] ]
info = client.get_tv_show_info(1) info = client.get_tv_show_info(1)
assert info.imdb_id is None assert info.imdb_id is None
assert info.seasons == () assert info.seasons == ()
assert info.status == ShowStatus.ENDED
class TestGetMovieInfo: class TestGetMovieInfo:
@@ -378,10 +363,10 @@ class TestGetMovieInfo:
info = client.get_movie_info(27205) info = client.get_movie_info(27205)
assert info.tmdb_id == 27205 assert info.tmdb_id == TmdbId(27205)
assert info.imdb_id == "tt1375666" assert info.imdb_id == ImdbId("tt1375666")
assert info.title == "Inception" assert info.title == MovieTitle("Inception")
assert info.release_year == 2010 assert info.release_year == ReleaseYear(2010)
@patch("alfred.infrastructure.api.tmdb.client.requests.get") @patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_missing_imdb_id_becomes_none(self, mock_get, client): def test_missing_imdb_id_becomes_none(self, mock_get, client):
@@ -397,7 +382,7 @@ class TestGetMovieInfo:
] ]
info = client.get_movie_info(1) info = client.get_movie_info(1)
assert info.imdb_id is None assert info.imdb_id is None
assert info.release_year == 2024 assert info.release_year == ReleaseYear(2024)
class TestIsConfigured: class TestIsConfigured:
+11 -8
View File
@@ -12,6 +12,9 @@ from datetime import date
import pytest import pytest
from alfred.domain.movies.value_objects import MovieTitle, ReleaseYear
from alfred.domain.shared.value_objects import ImdbId, TmdbId
from alfred.domain.tv_shows.value_objects import ShowStatus
from alfred.infrastructure.api.tmdb.dto import ( from alfred.infrastructure.api.tmdb.dto import (
TmdbMovieInfo, TmdbMovieInfo,
TmdbSeasonInfo, TmdbSeasonInfo,
@@ -42,10 +45,10 @@ class TestParseTvShowInfoHappyPath:
today=REF_DATE, today=REF_DATE,
) )
assert info == TmdbShowInfo( assert info == TmdbShowInfo(
tmdb_id=84958, tmdb_id=TmdbId(84958),
imdb_id="tt0804484", imdb_id=ImdbId("tt0804484"),
name="Foundation", name="Foundation",
status="Returning Series", status=ShowStatus.RETURNING_SERIES,
seasons=(), seasons=(),
) )
@@ -192,10 +195,10 @@ class TestParseMovieInfoHappyPath:
{"imdb_id": "tt1375666"}, {"imdb_id": "tt1375666"},
) )
assert info == TmdbMovieInfo( assert info == TmdbMovieInfo(
tmdb_id=27205, tmdb_id=TmdbId(27205),
imdb_id="tt1375666", imdb_id=ImdbId("tt1375666"),
title="Inception", title=MovieTitle("Inception"),
release_year=2010, release_year=ReleaseYear(2010),
) )
def test_release_year_extracted_from_release_date(self): def test_release_year_extracted_from_release_date(self):
@@ -203,7 +206,7 @@ class TestParseMovieInfoHappyPath:
_movie_details(release_date="1999-03-31"), _movie_details(release_date="1999-03-31"),
{}, {},
) )
assert info.release_year == 1999 assert info.release_year == ReleaseYear(1999)
class TestParseMovieInfoImdb: class TestParseMovieInfoImdb:
@@ -24,7 +24,7 @@ from alfred.domain.releases.entities import (
from alfred.domain.releases.value_objects import EpisodeRange, ReleaseMode from alfred.domain.releases.value_objects import EpisodeRange, ReleaseMode
from alfred.domain.shared.media import AudioTrack, SubtitleTrack from alfred.domain.shared.media import AudioTrack, SubtitleTrack
from alfred.domain.shared.value_objects import FilePath, ImdbId, TmdbId from alfred.domain.shared.value_objects import FilePath, ImdbId, TmdbId
from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber, ShowStatus
from alfred.infrastructure.api.tmdb.dto import TmdbSeasonInfo, TmdbShowInfo from alfred.infrastructure.api.tmdb.dto import TmdbSeasonInfo, TmdbShowInfo
@@ -164,10 +164,10 @@ def inception_release() -> MovieRelease:
def foundation_tmdb_info() -> TmdbShowInfo: def foundation_tmdb_info() -> TmdbShowInfo:
"""Foundation TMDB cache snapshot — 3 seasons, S03 not yet aired.""" """Foundation TMDB cache snapshot — 3 seasons, S03 not yet aired."""
return TmdbShowInfo( return TmdbShowInfo(
tmdb_id=84958, tmdb_id=TmdbId(84958),
imdb_id="tt0804484", imdb_id=ImdbId("tt0804484"),
name="Foundation", name="Foundation",
status="Returning Series", status=ShowStatus.RETURNING_SERIES,
seasons=( seasons=(
TmdbSeasonInfo(number=1, episode_count=10, aired=True), TmdbSeasonInfo(number=1, episode_count=10, aired=True),
TmdbSeasonInfo(number=2, episode_count=10, aired=True), TmdbSeasonInfo(number=2, episode_count=10, aired=True),