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:
+24
-12
@@ -5,6 +5,7 @@ from typing import Any
|
||||
|
||||
from alfred.application.movies import SearchMovieUseCase
|
||||
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.qbittorrent import qbittorrent_client
|
||||
from alfred.infrastructure.api.tmdb import tmdb_client
|
||||
@@ -13,25 +14,36 @@ from alfred.infrastructure.persistence import get_memory
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def find_media_imdb_id(media_title: str) -> dict[str, Any]:
|
||||
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/find_media_imdb_id.yaml."""
|
||||
def search_movies(media_title: str) -> dict[str, Any]:
|
||||
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/search_movies.yaml."""
|
||||
use_case = SearchMovieUseCase(tmdb_client)
|
||||
response = use_case.execute(media_title)
|
||||
result = response.to_dict()
|
||||
|
||||
if result.get("status") == "ok":
|
||||
memory = get_memory()
|
||||
memory.stm.set_entity(
|
||||
"last_media_search",
|
||||
{
|
||||
"title": result.get("title"),
|
||||
"imdb_id": result.get("imdb_id"),
|
||||
"media_type": result.get("media_type"),
|
||||
"tmdb_id": result.get("tmdb_id"),
|
||||
},
|
||||
memory.stm.set_entity("last_movie_search", {"hits": result.get("hits", [])})
|
||||
memory.stm.set_topic("searching_movie")
|
||||
logger.debug(
|
||||
f"Stored movie search result in STM: {len(result.get('hits', []))} hits"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ from .dto import (
|
||||
ManageSubtitlesResponse,
|
||||
MoveMediaResponse,
|
||||
PlacedSubtitle,
|
||||
SetFolderPathResponse,
|
||||
)
|
||||
)
|
||||
from .list_folder import ListFolderUseCase
|
||||
from .manage_subtitles import ManageSubtitlesUseCase
|
||||
from .move_media import MoveMediaUseCase
|
||||
@@ -22,10 +21,8 @@ from .resolve_destination import (
|
||||
resolve_season_destination,
|
||||
resolve_series_destination,
|
||||
)
|
||||
from .set_folder_path import SetFolderPathUseCase
|
||||
|
||||
__all__ = [
|
||||
"SetFolderPathUseCase",
|
||||
"ListFolderUseCase",
|
||||
"CreateSeedLinksUseCase",
|
||||
"MoveMediaUseCase",
|
||||
@@ -38,7 +35,6 @@ __all__ = [
|
||||
"resolve_episode_destination",
|
||||
"resolve_movie_destination",
|
||||
"resolve_series_destination",
|
||||
"SetFolderPathResponse",
|
||||
"ListFolderResponse",
|
||||
"CreateSeedLinksResponse",
|
||||
"MoveMediaResponse",
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"""Movie use cases."""
|
||||
|
||||
from .dto import SearchMovieResponse
|
||||
from .rescan import MovieRescanFailed, rescan_movie
|
||||
from .dto import MovieHit, SearchMovieResponse
|
||||
from .search_movie import SearchMovieUseCase
|
||||
|
||||
__all__ = [
|
||||
"MovieRescanFailed",
|
||||
"MovieHit",
|
||||
"SearchMovieResponse",
|
||||
"SearchMovieUseCase",
|
||||
"rescan_movie",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
"""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
|
||||
@@ -8,31 +23,18 @@ class SearchMovieResponse:
|
||||
"""Response from searching for a movie."""
|
||||
|
||||
status: str
|
||||
imdb_id: str | None = None
|
||||
title: str | None = None
|
||||
media_type: str | None = None
|
||||
tmdb_id: int | None = None
|
||||
release_date: str | None = None
|
||||
hits: list[MovieHit] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dict for agent compatibility."""
|
||||
result = {"status": self.status}
|
||||
result: dict = {"status": self.status}
|
||||
|
||||
if self.error:
|
||||
result["error"] = self.error
|
||||
result["message"] = self.message
|
||||
else:
|
||||
if self.imdb_id:
|
||||
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
|
||||
result["hits"] = [h.to_dict() for h in self.hits]
|
||||
|
||||
return result
|
||||
|
||||
@@ -6,71 +6,40 @@ from alfred.infrastructure.api.tmdb import (
|
||||
TMDBAPIError,
|
||||
TMDBClient,
|
||||
TMDBConfigurationError,
|
||||
TMDBNotFoundError,
|
||||
)
|
||||
|
||||
from .dto import SearchMovieResponse
|
||||
from .dto import MovieHit, SearchMovieResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SearchMovieUseCase:
|
||||
"""
|
||||
Use case for searching a movie and retrieving its IMDb ID.
|
||||
"""List movies matching a free-text query via TMDB ``/search/movie``.
|
||||
|
||||
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):
|
||||
"""
|
||||
Initialize use case.
|
||||
|
||||
Args:
|
||||
tmdb_client: TMDB API client
|
||||
"""
|
||||
self.tmdb_client = tmdb_client
|
||||
|
||||
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:
|
||||
# Use the TMDB client to search for media
|
||||
result = self.tmdb_client.search_media(media_title)
|
||||
results = self.tmdb_client.search_movies(media_title)
|
||||
|
||||
# Check if IMDb ID was found # FUCK THIS
|
||||
if result.imdb_id:
|
||||
logger.info(f"IMDb ID found for '{media_title}': {result.imdb_id}")
|
||||
return SearchMovieResponse(
|
||||
status="ok",
|
||||
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)
|
||||
hits = [
|
||||
MovieHit(
|
||||
tmdb_id=r.tmdb_id.value,
|
||||
title=str(r.title),
|
||||
release_year=r.release_year.value if r.release_year else None,
|
||||
)
|
||||
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:
|
||||
logger.error(f"TMDB configuration error: {e}")
|
||||
|
||||
@@ -7,7 +7,15 @@ reusing the existing release pipeline (``inspect_release``) rather
|
||||
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
|
||||
|
||||
__all__ = ["SeasonFolder", "ShowTree", "rescan_show", "walk_show"]
|
||||
__all__ = [
|
||||
"SearchShowResponse",
|
||||
"SearchShowUseCase",
|
||||
"SeasonFolder",
|
||||
"ShowHit",
|
||||
"ShowTree",
|
||||
"walk_show",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -55,9 +55,9 @@ class MovieTitle:
|
||||
f"Movie title must be a string, got {type(self.value)}"
|
||||
)
|
||||
|
||||
if len(self.value) > 100:
|
||||
if len(self.value) > 150:
|
||||
raise ValidationError(
|
||||
f"Movie title too long: {len(self.value)} characters (max 100)"
|
||||
f"Movie title too long: {len(self.value)} characters (max 150)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Shared kernel - Common domain concepts used across subdomains."""
|
||||
|
||||
from .exceptions import DomainException, ValidationError
|
||||
from .file_entry import FileEntry
|
||||
from .value_objects import FilePath, FileSize, ImdbId, Language
|
||||
|
||||
__all__ = [
|
||||
"DomainException",
|
||||
"ValidationError",
|
||||
"FileEntry",
|
||||
"ImdbId",
|
||||
"FilePath",
|
||||
"FileSize",
|
||||
|
||||
@@ -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
|
||||
@@ -6,12 +6,11 @@ injection and calls it. Tests can pass in-memory fakes that satisfy the
|
||||
Protocol without going through real I/O.
|
||||
"""
|
||||
|
||||
from .filesystem_scanner import FileEntry, FilesystemScanner
|
||||
from .filesystem_scanner import FilesystemScanner
|
||||
from .language_repository import LanguageRepository
|
||||
from .media_prober import MediaProber, SubtitleStreamInfo
|
||||
|
||||
__all__ = [
|
||||
"FileEntry",
|
||||
"FilesystemScanner",
|
||||
"LanguageRepository",
|
||||
"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 dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@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
|
||||
from alfred.domain.shared.file_entry import FileEntry
|
||||
|
||||
|
||||
class FilesystemScanner(Protocol):
|
||||
|
||||
@@ -259,7 +259,7 @@ class SubtitleIdentifier:
|
||||
subtitle_type=subtitle_type,
|
||||
is_embedded=False,
|
||||
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,
|
||||
confidence=confidence,
|
||||
raw_tokens=tokens,
|
||||
|
||||
@@ -11,7 +11,7 @@ Typical usage during a TMDB hydration::
|
||||
tmdb_id=TmdbId(1396),
|
||||
title="Breaking Bad",
|
||||
imdb_id=ImdbId("tt0903747"),
|
||||
status="Ended",
|
||||
status=ShowStatus.ENDED,
|
||||
)
|
||||
builder.season_builder(1).set_episode_count(7).add_episode(Episode(
|
||||
season_number=SeasonNumber(1),
|
||||
@@ -45,7 +45,7 @@ from __future__ import annotations
|
||||
|
||||
from ..shared.value_objects import ImdbId, TmdbId
|
||||
from .entities import Episode, Season, TVShow
|
||||
from .value_objects import EpisodeNumber, SeasonNumber
|
||||
from .value_objects import EpisodeNumber, SeasonNumber, ShowStatus
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# SeasonBuilder
|
||||
@@ -139,7 +139,7 @@ class TVShowBuilder:
|
||||
tmdb_id: TmdbId,
|
||||
title: str,
|
||||
imdb_id: ImdbId | None = None,
|
||||
status: str = "unknown",
|
||||
status: ShowStatus = ShowStatus.UNKNOWN,
|
||||
) -> None:
|
||||
if not isinstance(tmdb_id, TmdbId):
|
||||
raise ValueError(
|
||||
@@ -152,7 +152,7 @@ class TVShowBuilder:
|
||||
self._tmdb_id: TmdbId = tmdb_id
|
||||
self._title: str = title
|
||||
self._imdb_id: ImdbId | None = imdb_id
|
||||
self._status: str = status
|
||||
self._status: ShowStatus = status
|
||||
self._season_builders: dict[SeasonNumber, SeasonBuilder] = {}
|
||||
|
||||
@classmethod
|
||||
@@ -184,7 +184,7 @@ class TVShowBuilder:
|
||||
self._imdb_id = imdb_id
|
||||
return self
|
||||
|
||||
def set_status(self, status: str) -> TVShowBuilder:
|
||||
def set_status(self, status: ShowStatus) -> TVShowBuilder:
|
||||
self._status = status
|
||||
return self
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ from ..shared.value_objects import (
|
||||
TmdbId,
|
||||
to_dot_folder_name,
|
||||
)
|
||||
from .value_objects import EpisodeNumber, SeasonNumber
|
||||
from .value_objects import EpisodeNumber, SeasonNumber, ShowStatus
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# Episode
|
||||
@@ -208,17 +208,15 @@ class TVShow:
|
||||
Identity is carried by ``tmdb_id`` (primary key, required) and
|
||||
``imdb_id`` (optional secondary anchor — some TMDB items lack one).
|
||||
|
||||
:attr:`status` mirrors the raw TMDB status string verbatim
|
||||
(``"Returning Series"`` / ``"Ended"`` / ``"Canceled"`` …). No
|
||||
taxonomy of our own — callers that need a normalized view can map
|
||||
it themselves. The ``"unknown"`` default matches the auto-heal
|
||||
placeholder used by the v2 library index.
|
||||
:attr:`status` is a :class:`ShowStatus` — the TMDB-native lifecycle
|
||||
enum. :attr:`ShowStatus.UNKNOWN` is the default placeholder used by
|
||||
the v2 library index when no status has been synced yet.
|
||||
"""
|
||||
|
||||
tmdb_id: TmdbId
|
||||
title: str
|
||||
imdb_id: ImdbId | None = None
|
||||
status: str = "unknown"
|
||||
status: ShowStatus = ShowStatus.UNKNOWN
|
||||
seasons: tuple[Season, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
|
||||
@@ -2,11 +2,53 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"""TMDB API client."""
|
||||
|
||||
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 (
|
||||
TMDBAPIError,
|
||||
TMDBConfigurationError,
|
||||
@@ -13,12 +21,17 @@ from .exceptions import (
|
||||
tmdb_client = TMDBClient()
|
||||
|
||||
__all__ = [
|
||||
"TMDBClient",
|
||||
"MediaResult",
|
||||
"ExternalIds",
|
||||
"TMDBError",
|
||||
"TMDBConfigurationError",
|
||||
"TMDBAPIError",
|
||||
"TMDBClient",
|
||||
"TMDBConfigurationError",
|
||||
"TMDBError",
|
||||
"TMDBNotFoundError",
|
||||
"TmdbMovieInfo",
|
||||
"TmdbMovieSearchResult",
|
||||
"TmdbSeasonInfo",
|
||||
"TmdbShowInfo",
|
||||
"TmdbShowSearchResult",
|
||||
"parse_movie_info",
|
||||
"parse_tv_show_info",
|
||||
"tmdb_client",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""TMDB (The Movie Database) API client."""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
@@ -8,10 +9,14 @@ from requests.exceptions import HTTPError, RequestException, Timeout
|
||||
|
||||
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 (
|
||||
MediaResult,
|
||||
TmdbMovieInfo,
|
||||
TmdbMovieSearchResult,
|
||||
TmdbShowInfo,
|
||||
TmdbShowSearchResult,
|
||||
parse_movie_info,
|
||||
parse_tv_show_info,
|
||||
)
|
||||
@@ -24,6 +29,22 @@ from .exceptions import (
|
||||
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:
|
||||
"""
|
||||
Client for interacting with The Movie Database (TMDB) API.
|
||||
@@ -33,9 +54,9 @@ class TMDBClient:
|
||||
|
||||
Example:
|
||||
>>> client = TMDBClient()
|
||||
>>> result = client.search_media("Inception")
|
||||
>>> print(result.imdb_id)
|
||||
'tt1375666'
|
||||
>>> results = client.search_movies("Inception")
|
||||
>>> print(results[0].title)
|
||||
MovieTitle('Inception')
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -123,34 +144,79 @@ class TMDBClient:
|
||||
logger.error(f"TMDB API request failed: {e}")
|
||||
raise TMDBAPIError(f"Failed to connect to TMDB API: {e}") from e
|
||||
|
||||
def search_multi(self, query: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Search for movies and TV shows.
|
||||
def search_movies(self, query: str) -> list[TmdbMovieSearchResult]:
|
||||
"""Search the TMDB ``/search/movie`` endpoint.
|
||||
|
||||
Args:
|
||||
query: Search query (movie or TV show title)
|
||||
query: free-text title query.
|
||||
|
||||
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:
|
||||
TMDBAPIError: If request fails
|
||||
TMDBNotFoundError: If no results found
|
||||
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/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", [])
|
||||
if not results:
|
||||
raise TMDBNotFoundError(f"No results found for '{query}'")
|
||||
def search_shows(self, query: str) -> list[TmdbShowSearchResult]:
|
||||
"""Search the TMDB ``/search/tv`` endpoint. Symmetric to
|
||||
:meth:`search_movies`.
|
||||
|
||||
logger.info(f"Found {len(results)} results for '{query}'")
|
||||
return results
|
||||
Args:
|
||||
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]:
|
||||
"""
|
||||
@@ -174,87 +240,6 @@ class TMDBClient:
|
||||
endpoint = f"/{media_type}/{tmdb_id}/external_ids"
|
||||
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]:
|
||||
"""
|
||||
Get detailed information about a movie.
|
||||
|
||||
@@ -6,30 +6,39 @@ from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaResult:
|
||||
"""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
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalIds:
|
||||
"""External IDs for a media item."""
|
||||
@dataclass(frozen=True)
|
||||
class TmdbMovieSearchResult:
|
||||
"""One movie hit from the TMDB ``/search/movie`` endpoint.
|
||||
|
||||
imdb_id: str | None = None
|
||||
tvdb_id: int | None = None
|
||||
facebook_id: str | None = None
|
||||
instagram_id: str | None = None
|
||||
twitter_id: str | None = None
|
||||
Carries the minimal identity + display facts needed by the search
|
||||
use case. No external-ids enrichment — that requires a separate
|
||||
HTTP call and is out of scope for search results. Callers that
|
||||
need ``imdb_id`` for a specific hit should subsequently call
|
||||
: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
|
||||
fields the v2 library index needs to cache; richer details remain
|
||||
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
|
||||
imdb_id: str | None
|
||||
tmdb_id: TmdbId
|
||||
imdb_id: ImdbId | None
|
||||
name: str
|
||||
status: str
|
||||
status: ShowStatus
|
||||
seasons: tuple[TmdbSeasonInfo, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@@ -93,33 +109,37 @@ def parse_tv_show_info(
|
||||
"""
|
||||
ref = today or date.today()
|
||||
|
||||
tmdb_id = details.get("id")
|
||||
if not isinstance(tmdb_id, int):
|
||||
tmdb_id_raw = details.get("id")
|
||||
if not isinstance(tmdb_id_raw, int):
|
||||
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")
|
||||
if not isinstance(name, str) or not name:
|
||||
raise ValueError(
|
||||
f"TMDB show payload missing/invalid 'name': {name!r}"
|
||||
)
|
||||
status = details.get("status")
|
||||
if not isinstance(status, str) or not status:
|
||||
status_raw = details.get("status")
|
||||
if not isinstance(status_raw, str) or not status_raw:
|
||||
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 = 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 = tuple(_parse_season(s, ref) for s in seasons_raw)
|
||||
|
||||
return TmdbShowInfo(
|
||||
tmdb_id=tmdb_id,
|
||||
tmdb_id=TmdbId(tmdb_id_raw),
|
||||
imdb_id=imdb_id,
|
||||
name=name,
|
||||
status=status,
|
||||
status=ShowStatus.from_tmdb(status_raw),
|
||||
seasons=seasons,
|
||||
)
|
||||
|
||||
@@ -172,15 +192,22 @@ class TmdbMovieInfo:
|
||||
remain on-demand via the raw client methods. Symmetric to
|
||||
: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
|
||||
first 4 chars). It is ``None`` when TMDB has no release date
|
||||
(very old or future-dated titles).
|
||||
"""
|
||||
|
||||
tmdb_id: int
|
||||
imdb_id: str | None
|
||||
title: str
|
||||
release_year: int | None
|
||||
tmdb_id: TmdbId
|
||||
imdb_id: ImdbId | None
|
||||
title: MovieTitle
|
||||
release_year: ReleaseYear | None
|
||||
|
||||
|
||||
def parse_movie_info(
|
||||
@@ -203,27 +230,31 @@ def parse_movie_info(
|
||||
ValueError: if a required field (``id``, ``title``) is missing
|
||||
from ``details``.
|
||||
"""
|
||||
tmdb_id = details.get("id")
|
||||
if not isinstance(tmdb_id, int):
|
||||
tmdb_id_raw = details.get("id")
|
||||
if not isinstance(tmdb_id_raw, int):
|
||||
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")
|
||||
if not isinstance(title, str) or not title:
|
||||
title_raw = details.get("title")
|
||||
if not isinstance(title_raw, str) or not title_raw:
|
||||
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 = 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(
|
||||
tmdb_id=tmdb_id,
|
||||
tmdb_id=TmdbId(tmdb_id_raw),
|
||||
imdb_id=imdb_id,
|
||||
title=title,
|
||||
release_year=release_year,
|
||||
title=MovieTitle(title_raw),
|
||||
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.
|
||||
"""
|
||||
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
|
||||
try:
|
||||
return int(release_date_raw[:4])
|
||||
return date.fromisoformat(release_date_raw).year
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from alfred.domain.shared.ports import FileEntry
|
||||
from alfred.domain.shared import FileEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -56,11 +56,11 @@ class PathlibFilesystemScanner:
|
||||
if not (is_file or is_dir):
|
||||
return None
|
||||
|
||||
size_kb: float | None = None
|
||||
size: int | None = None
|
||||
if is_file:
|
||||
try:
|
||||
size_kb = path.stat().st_size / 1024
|
||||
size = path.stat().st_size
|
||||
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
|
||||
)
|
||||
return ShowIndexEntry(
|
||||
tmdb_id=info.tmdb_id,
|
||||
imdb_id=info.imdb_id,
|
||||
tmdb_id=info.tmdb_id.value,
|
||||
imdb_id=str(info.imdb_id) if info.imdb_id is not None else None,
|
||||
name=info.name,
|
||||
status=info.status,
|
||||
status=info.status.value,
|
||||
metadata=ShowIndexMetadata(path=path, fetched_at=fetched_at),
|
||||
seasons=seasons,
|
||||
)
|
||||
@@ -236,11 +236,10 @@ def movie_index_entry_from(
|
||||
) -> MovieIndexEntry:
|
||||
"""Project a movie release + identity facts into one library-index entry.
|
||||
|
||||
Movies don't have a ``TmdbMovieInfo`` DTO yet (no per-movie TMDB
|
||||
cache surface defined in Phase 2), so identity facts are passed
|
||||
explicitly by the caller. The release supplies ``tmdb_id`` /
|
||||
``imdb_id``; ``name`` and ``release_year`` come from the caller's
|
||||
TMDB lookup (or a future ``TmdbMovieInfo`` DTO when one ships).
|
||||
Identity facts are passed explicitly by the caller (the release
|
||||
supplies ``tmdb_id`` / ``imdb_id``; ``name`` and ``release_year``
|
||||
typically come from a ``TmdbMovieInfo`` lookup that the caller
|
||||
unwraps to primitives before passing in).
|
||||
"""
|
||||
return MovieIndexEntry(
|
||||
tmdb_id=release.tmdb_id.value,
|
||||
|
||||
@@ -40,6 +40,7 @@ from pydantic import ValidationError
|
||||
from ....api.tmdb.dto import TmdbShowInfo
|
||||
from .....domain.releases.entities import MovieRelease, SeriesRelease
|
||||
from .....domain.shared.value_objects import ImdbId, TmdbId
|
||||
from .....domain.tv_shows.value_objects import ShowStatus
|
||||
from .bridge import (
|
||||
movie_index_entry_from,
|
||||
movie_release_from_sidecar,
|
||||
@@ -455,7 +456,7 @@ class DotAlfredTVShowLibraryIndex:
|
||||
tmdb_id=release.tmdb_id.value,
|
||||
imdb_id=str(release.imdb_id) if release.imdb_id else None,
|
||||
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={
|
||||
"path": folder,
|
||||
"fetched_at": now,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"""Tests for ``alfred.application.movies.search_movie.SearchMovieUseCase``.
|
||||
|
||||
The use case wraps ``TMDBClient.search_media`` and converts results / errors
|
||||
into a ``SearchMovieResponse`` envelope (status="ok"|"error").
|
||||
The use case wraps :meth:`TMDBClient.search_movies` and flattens each
|
||||
hit's domain VOs into agent-friendly primitives wrapped in a
|
||||
:class:`SearchMovieResponse` envelope (status="ok"|"error").
|
||||
|
||||
Coverage:
|
||||
|
||||
- ``TestSuccess`` — full MediaResult with imdb_id → ok+imdb_id; missing
|
||||
imdb_id → ok+no_imdb_id; TV media_type preserved.
|
||||
- ``TestErrorTranslation`` — ``TMDBNotFoundError`` → not_found,
|
||||
``TMDBConfigurationError`` → configuration_error,
|
||||
``TMDBAPIError`` → api_error, ``ValueError`` → validation_failed.
|
||||
- ``TestSuccess`` — list of hits flattened, year present/absent,
|
||||
empty list still ``status="ok"``.
|
||||
- ``TestErrorTranslation`` — ``TMDBConfigurationError`` →
|
||||
configuration_error, ``TMDBAPIError`` → api_error, ``ValueError``
|
||||
→ validation_failed.
|
||||
- ``TestPassThrough`` — query is forwarded to the client unchanged.
|
||||
|
||||
TMDBClient is fully mocked — no real HTTP.
|
||||
@@ -22,11 +23,12 @@ from unittest.mock import MagicMock
|
||||
import pytest
|
||||
|
||||
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 (
|
||||
TMDBAPIError,
|
||||
TMDBConfigurationError,
|
||||
TMDBNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
@@ -40,19 +42,14 @@ def use_case(client):
|
||||
return SearchMovieUseCase(client)
|
||||
|
||||
|
||||
def _result(**kw) -> MediaResult:
|
||||
def _hit(**kw) -> TmdbMovieSearchResult:
|
||||
defaults = dict(
|
||||
tmdb_id=1,
|
||||
title="Inception",
|
||||
media_type="movie",
|
||||
imdb_id="tt1375666",
|
||||
overview="o",
|
||||
release_date="2010-07-15",
|
||||
poster_path="/x.jpg",
|
||||
vote_average=8.4,
|
||||
tmdb_id=TmdbId(27205),
|
||||
title=MovieTitle("Inception"),
|
||||
release_year=ReleaseYear(2010),
|
||||
)
|
||||
defaults.update(kw)
|
||||
return MediaResult(**defaults)
|
||||
return TmdbMovieSearchResult(**defaults)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -61,36 +58,36 @@ def _result(**kw) -> MediaResult:
|
||||
|
||||
|
||||
class TestSuccess:
|
||||
def test_full_result_returns_ok_with_imdb_id(self, client, use_case):
|
||||
client.search_media.return_value = _result()
|
||||
def test_single_hit_is_flattened(self, client, use_case):
|
||||
client.search_movies.return_value = [_hit()]
|
||||
r = use_case.execute("Inception")
|
||||
assert r.status == "ok"
|
||||
assert r.imdb_id == "tt1375666"
|
||||
assert r.title == "Inception"
|
||||
assert r.media_type == "movie"
|
||||
assert r.tmdb_id == 1
|
||||
assert r.vote_average == 8.4
|
||||
assert len(r.hits) == 1
|
||||
h = r.hits[0]
|
||||
assert h.tmdb_id == 27205
|
||||
assert h.title == "Inception"
|
||||
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
|
||||
|
||||
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 #
|
||||
@@ -98,28 +95,21 @@ class TestSuccess:
|
||||
|
||||
|
||||
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):
|
||||
client.search_media.side_effect = TMDBConfigurationError("missing key")
|
||||
client.search_movies.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_media.side_effect = TMDBAPIError("500 oops")
|
||||
client.search_movies.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_media.side_effect = ValueError("query too long")
|
||||
client.search_movies.side_effect = ValueError("query too long")
|
||||
r = use_case.execute("x")
|
||||
assert r.status == "error"
|
||||
assert r.error == "validation_failed"
|
||||
@@ -133,6 +123,6 @@ class TestErrorTranslation:
|
||||
|
||||
class TestPassThrough:
|
||||
def test_query_forwarded_verbatim(self, client, use_case):
|
||||
client.search_media.return_value = _result()
|
||||
client.search_movies.return_value = []
|
||||
use_case.execute("Inception")
|
||||
client.search_media.assert_called_once_with("Inception")
|
||||
client.search_movies.assert_called_once_with("Inception")
|
||||
|
||||
@@ -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")
|
||||
@@ -22,7 +22,7 @@ from unittest.mock import patch
|
||||
|
||||
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.services.identifier import (
|
||||
SubtitleIdentifier,
|
||||
@@ -48,7 +48,7 @@ def _file_entry(path) -> FileEntry:
|
||||
path=path,
|
||||
is_file=path.is_file(),
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -6,13 +6,12 @@ Exercises the public surface without any real HTTP traffic:
|
||||
enforcement of the ``api_key``/``base_url`` invariants.
|
||||
- ``TestMakeRequest`` — error translation for timeouts, HTTP 401/404/5xx,
|
||||
and generic ``RequestException``.
|
||||
- ``TestSearchMulti`` — query validation, success path, empty-results →
|
||||
``TMDBNotFoundError``.
|
||||
- ``TestSearchMovies`` / ``TestSearchShows`` — query validation, success
|
||||
path (VO-wrapped hits), empty results yield empty list (no exception).
|
||||
- ``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``.
|
||||
- ``TestGetTvShowInfo`` / ``TestGetMovieInfo`` — composite info getters
|
||||
aggregating details + external_ids into VO-typed DTOs.
|
||||
- ``TestIsConfigured`` — reports ``True`` only when both api_key & url set.
|
||||
|
||||
All HTTP is mocked at ``alfred.infrastructure.api.tmdb.client.requests``.
|
||||
@@ -25,8 +24,10 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
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.dto import MediaResult
|
||||
from alfred.infrastructure.api.tmdb.exceptions import (
|
||||
TMDBAPIError,
|
||||
TMDBConfigurationError,
|
||||
@@ -85,7 +86,6 @@ class TestInit:
|
||||
TMDBClient(api_key="", config=cfg)
|
||||
|
||||
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
|
||||
|
||||
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])
|
||||
def test_invalid_query_raises_value_error(self, client, bad):
|
||||
with pytest.raises(ValueError):
|
||||
client.search_multi(bad)
|
||||
client.search_movies(bad)
|
||||
|
||||
def test_query_too_long(self, client):
|
||||
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")
|
||||
def test_success(self, mock_get, client):
|
||||
def test_success_wraps_vos(self, mock_get, client):
|
||||
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 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")
|
||||
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": []})
|
||||
with pytest.raises(TMDBNotFoundError):
|
||||
client.search_multi("nothing")
|
||||
assert client.search_movies("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"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -333,10 +317,10 @@ class TestGetTvShowInfo:
|
||||
|
||||
info = client.get_tv_show_info(84958)
|
||||
|
||||
assert info.tmdb_id == 84958
|
||||
assert info.imdb_id == "tt0804484"
|
||||
assert info.tmdb_id == TmdbId(84958)
|
||||
assert info.imdb_id == ImdbId("tt0804484")
|
||||
assert info.name == "Foundation"
|
||||
assert info.status == "Returning Series"
|
||||
assert info.status == ShowStatus.RETURNING_SERIES
|
||||
assert len(info.seasons) == 2
|
||||
assert info.seasons[0].number == 1
|
||||
assert info.seasons[0].episode_count == 10
|
||||
@@ -353,11 +337,12 @@ class TestGetTvShowInfo:
|
||||
"seasons": [],
|
||||
}
|
||||
),
|
||||
_ok_response({}), # external_ids without imdb_id
|
||||
_ok_response({}),
|
||||
]
|
||||
info = client.get_tv_show_info(1)
|
||||
assert info.imdb_id is None
|
||||
assert info.seasons == ()
|
||||
assert info.status == ShowStatus.ENDED
|
||||
|
||||
|
||||
class TestGetMovieInfo:
|
||||
@@ -378,10 +363,10 @@ class TestGetMovieInfo:
|
||||
|
||||
info = client.get_movie_info(27205)
|
||||
|
||||
assert info.tmdb_id == 27205
|
||||
assert info.imdb_id == "tt1375666"
|
||||
assert info.title == "Inception"
|
||||
assert info.release_year == 2010
|
||||
assert info.tmdb_id == TmdbId(27205)
|
||||
assert info.imdb_id == ImdbId("tt1375666")
|
||||
assert info.title == MovieTitle("Inception")
|
||||
assert info.release_year == ReleaseYear(2010)
|
||||
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_missing_imdb_id_becomes_none(self, mock_get, client):
|
||||
@@ -397,7 +382,7 @@ class TestGetMovieInfo:
|
||||
]
|
||||
info = client.get_movie_info(1)
|
||||
assert info.imdb_id is None
|
||||
assert info.release_year == 2024
|
||||
assert info.release_year == ReleaseYear(2024)
|
||||
|
||||
|
||||
class TestIsConfigured:
|
||||
|
||||
@@ -12,6 +12,9 @@ from datetime import date
|
||||
|
||||
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 (
|
||||
TmdbMovieInfo,
|
||||
TmdbSeasonInfo,
|
||||
@@ -42,10 +45,10 @@ class TestParseTvShowInfoHappyPath:
|
||||
today=REF_DATE,
|
||||
)
|
||||
assert info == TmdbShowInfo(
|
||||
tmdb_id=84958,
|
||||
imdb_id="tt0804484",
|
||||
tmdb_id=TmdbId(84958),
|
||||
imdb_id=ImdbId("tt0804484"),
|
||||
name="Foundation",
|
||||
status="Returning Series",
|
||||
status=ShowStatus.RETURNING_SERIES,
|
||||
seasons=(),
|
||||
)
|
||||
|
||||
@@ -192,10 +195,10 @@ class TestParseMovieInfoHappyPath:
|
||||
{"imdb_id": "tt1375666"},
|
||||
)
|
||||
assert info == TmdbMovieInfo(
|
||||
tmdb_id=27205,
|
||||
imdb_id="tt1375666",
|
||||
title="Inception",
|
||||
release_year=2010,
|
||||
tmdb_id=TmdbId(27205),
|
||||
imdb_id=ImdbId("tt1375666"),
|
||||
title=MovieTitle("Inception"),
|
||||
release_year=ReleaseYear(2010),
|
||||
)
|
||||
|
||||
def test_release_year_extracted_from_release_date(self):
|
||||
@@ -203,7 +206,7 @@ class TestParseMovieInfoHappyPath:
|
||||
_movie_details(release_date="1999-03-31"),
|
||||
{},
|
||||
)
|
||||
assert info.release_year == 1999
|
||||
assert info.release_year == ReleaseYear(1999)
|
||||
|
||||
|
||||
class TestParseMovieInfoImdb:
|
||||
|
||||
@@ -24,7 +24,7 @@ from alfred.domain.releases.entities import (
|
||||
from alfred.domain.releases.value_objects import EpisodeRange, ReleaseMode
|
||||
from alfred.domain.shared.media import AudioTrack, SubtitleTrack
|
||||
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
|
||||
|
||||
|
||||
@@ -164,10 +164,10 @@ def inception_release() -> MovieRelease:
|
||||
def foundation_tmdb_info() -> TmdbShowInfo:
|
||||
"""Foundation TMDB cache snapshot — 3 seasons, S03 not yet aired."""
|
||||
return TmdbShowInfo(
|
||||
tmdb_id=84958,
|
||||
imdb_id="tt0804484",
|
||||
tmdb_id=TmdbId(84958),
|
||||
imdb_id=ImdbId("tt0804484"),
|
||||
name="Foundation",
|
||||
status="Returning Series",
|
||||
status=ShowStatus.RETURNING_SERIES,
|
||||
seasons=(
|
||||
TmdbSeasonInfo(number=1, episode_count=10, aired=True),
|
||||
TmdbSeasonInfo(number=2, episode_count=10, aired=True),
|
||||
|
||||
Reference in New Issue
Block a user