Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker/pyproject.deps.toml
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
68 changes: 66 additions & 2 deletions mcp_plex/common/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -98,12 +126,46 @@ def model_validate(cls, data): # type: ignore[override]


class TMDBEpisode(BaseModel):
id: int
id: str
Comment on lines 128 to +129

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update TMDb ID payload schema for string episode IDs

Normalising TMDBEpisode.id to a string means episode payloads can now contain values such as "1/season/2/episode/3", but the loader still creates the data.tmdb.id Qdrant index as an integer and the server casts TMDb identifiers to int before querying. Ingesting an episode after this change will either fail when the integer index receives a non‑integer string or make lookups by TMDb ID impossible because the stored payload no longer matches the integer filter. The index and query logic should be adjusted to accept string IDs (or the model should remain numeric) so episode data stays accessible.

Useful? React with 👍 / 👎.

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
Expand Down Expand Up @@ -169,6 +231,8 @@ class ExternalIDs:
"TMDBSeason",
"TMDBShow",
"TMDBEpisode",
"TMDBCrewMember",
"TMDBGuestStar",
"TMDBItem",
"PlexGuid",
"PlexPerson",
Expand Down
6 changes: 5 additions & 1 deletion mcp_plex/loader/pipeline/enrichment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
49 changes: 45 additions & 4 deletions tests/test_enrichment_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.