From 31985c17c92b649d2d5d3a801e6a1d78111a3399 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Tue, 7 Oct 2025 01:47:37 -0600 Subject: [PATCH] fix: derive tmdb episode ids when missing --- docker/pyproject.deps.toml | 2 +- mcp_plex/common/types.py | 68 +++++++++++++++++++++++++- mcp_plex/loader/pipeline/enrichment.py | 6 ++- pyproject.toml | 2 +- tests/test_enrichment_helpers.py | 49 +++++++++++++++++-- uv.lock | 2 +- 6 files changed, 119 insertions(+), 10 deletions(-) diff --git a/docker/pyproject.deps.toml b/docker/pyproject.deps.toml index 4263c0f..eab9102 100644 --- a/docker/pyproject.deps.toml +++ b/docker/pyproject.deps.toml @@ -1,6 +1,6 @@ [project] name = "mcp-plex" -version = "2.0.0" +version = "2.0.2" requires-python = ">=3.11,<3.13" dependencies = [ "fastmcp>=2.11.2", diff --git a/mcp_plex/common/types.py b/mcp_plex/common/types.py index baf0132..0e1a342 100644 --- a/mcp_plex/common/types.py +++ b/mcp_plex/common/types.py @@ -5,7 +5,7 @@ from datetime import datetime from typing import List, Literal, Mapping, MutableMapping, Optional, Sequence, TypeAlias -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator class IMDbRating(BaseModel): @@ -56,6 +56,34 @@ class TMDBSeason(BaseModel): air_date: Optional[str] = None +class TMDBCrewMember(BaseModel): + id: Optional[int] = None + name: Optional[str] = None + original_name: Optional[str] = None + known_for_department: Optional[str] = None + department: Optional[str] = None + job: Optional[str] = None + credit_id: Optional[str] = None + adult: Optional[bool] = None + gender: Optional[int] = None + popularity: Optional[float] = None + profile_path: Optional[str] = None + + +class TMDBGuestStar(BaseModel): + id: Optional[int] = None + name: Optional[str] = None + original_name: Optional[str] = None + known_for_department: Optional[str] = None + character: Optional[str] = None + credit_id: Optional[str] = None + order: Optional[int] = None + adult: Optional[bool] = None + gender: Optional[int] = None + popularity: Optional[float] = None + profile_path: Optional[str] = None + + class TMDBMovie(BaseModel): id: int title: str @@ -98,12 +126,46 @@ def model_validate(cls, data): # type: ignore[override] class TMDBEpisode(BaseModel): - id: int + id: str name: str overview: Optional[str] = None season_number: Optional[int] = None episode_number: Optional[int] = None air_date: Optional[str] = None + episode_type: Optional[str] = None + production_code: Optional[str] = None + runtime: Optional[int] = None + show_id: Optional[int] = None + still_path: Optional[str] = None + vote_average: Optional[float] = None + vote_count: Optional[int] = None + crew: List[TMDBCrewMember] = Field(default_factory=list) + guest_stars: List[TMDBGuestStar] = Field(default_factory=list) + + @model_validator(mode="before") + @classmethod + def _normalise_episode_id(cls, data): + """Ensure episode IDs are populated when missing from TMDb payloads.""" + + if isinstance(data, dict): + payload = data.copy() + episode_id = payload.get("id") + if episode_id is not None: + payload["id"] = str(episode_id) + else: + show_id = payload.get("show_id") + season_number = payload.get("season_number") + episode_number = payload.get("episode_number") + if ( + show_id is not None + and season_number is not None + and episode_number is not None + ): + payload["id"] = ( + f"{show_id}/season/{season_number}/episode/{episode_number}" + ) + return payload + return data TMDBItem = TMDBMovie | TMDBShow | TMDBEpisode @@ -169,6 +231,8 @@ class ExternalIDs: "TMDBSeason", "TMDBShow", "TMDBEpisode", + "TMDBCrewMember", + "TMDBGuestStar", "TMDBItem", "PlexGuid", "PlexPerson", diff --git a/mcp_plex/loader/pipeline/enrichment.py b/mcp_plex/loader/pipeline/enrichment.py index 17a90ac..90838d5 100644 --- a/mcp_plex/loader/pipeline/enrichment.py +++ b/mcp_plex/loader/pipeline/enrichment.py @@ -221,7 +221,10 @@ async def _fetch_tmdb_episode( ) return None if resp.is_success: - return TMDBEpisode.model_validate(resp.json()) + data = resp.json() + if isinstance(data, dict): + data.setdefault("show_id", show_id) + return TMDBEpisode.model_validate(data) return None @@ -254,6 +257,7 @@ async def _fetch_tmdb_episode_chunk( for path in append_paths: payload = data.get(path) if isinstance(payload, dict): + payload.setdefault("show_id", show_id) try: results[path] = TMDBEpisode.model_validate(payload) except ValidationError: diff --git a/pyproject.toml b/pyproject.toml index 0a990e6..605e78d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mcp-plex" -version = "2.0.0" +version = "2.0.2" description = "Plex-Oriented Model Context Protocol Server" requires-python = ">=3.11,<3.13" diff --git a/tests/test_enrichment_helpers.py b/tests/test_enrichment_helpers.py index 07c8f63..29071d8 100644 --- a/tests/test_enrichment_helpers.py +++ b/tests/test_enrichment_helpers.py @@ -123,7 +123,14 @@ async def tmdb_show_mock(request): async def tmdb_episode_mock(request): assert request.headers.get("Authorization") == "Bearer k" if "/tv/1/season/2/episode/3" in str(request.url): - return httpx.Response(200, json={"id": 1, "name": "E"}) + return httpx.Response( + 200, + json={ + "name": "E", + "season_number": 2, + "episode_number": 3, + }, + ) return httpx.Response(404) async def tmdb_episode_chunk_mock(request): @@ -133,8 +140,36 @@ async def tmdb_episode_chunk_mock(request): return httpx.Response( 200, json={ - "season/1/episode/1": {"id": 11, "name": "Episode 1"}, - "season/1/episode/2": {"id": 12, "name": "Episode 2"}, + "season/1/episode/1": { + "name": "Episode 1", + "runtime": 22, + "episode_type": "standard", + "season_number": 1, + "episode_number": 1, + "crew": [ + { + "id": 1, + "name": "Director A", + "job": "Director", + } + ], + "guest_stars": [ + { + "id": 2, + "name": "Guest Star A", + "character": "Self", + } + ], + }, + "season/1/episode/2": { + "name": "Episode 2", + "runtime": 24, + "episode_type": "finale", + "season_number": 1, + "episode_number": 2, + "crew": [], + "guest_stars": [], + }, }, ) return httpx.Response(404) @@ -154,7 +189,9 @@ async def main(): assert (await _fetch_tmdb_show(client, "bad", "k")) is None async with httpx.AsyncClient(transport=episode_transport) as client: - assert (await _fetch_tmdb_episode(client, 1, 2, 3, "k")) is not None + episode = await _fetch_tmdb_episode(client, 1, 2, 3, "k") + assert episode is not None + assert episode.id == "1/season/2/episode/3" assert (await _fetch_tmdb_episode(client, 1, 2, 4, "k")) is None async with httpx.AsyncClient(transport=episode_chunk_transport) as client: @@ -169,6 +206,10 @@ async def main(): "season/1/episode/2", } assert chunk["season/1/episode/1"].name == "Episode 1" + assert chunk["season/1/episode/1"].runtime == 22 + assert chunk["season/1/episode/1"].crew[0].job == "Director" + assert chunk["season/1/episode/1"].guest_stars[0].character == "Self" + assert chunk["season/1/episode/1"].id == "1/season/1/episode/1" assert ( await _fetch_tmdb_episode_chunk( client, 1, ["season/1/episode/3"], "k" diff --git a/uv.lock b/uv.lock index b2bdd45..ac3731a 100644 --- a/uv.lock +++ b/uv.lock @@ -730,7 +730,7 @@ wheels = [ [[package]] name = "mcp-plex" -version = "2.0.0" +version = "2.0.2" source = { editable = "." } dependencies = [ { name = "fastapi" },