From c3db93dd4806c0bad0608f6069ab72488976bd94 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Sun, 14 Sep 2025 00:31:50 -0600 Subject: [PATCH] fix: resolve TMDb seasons by air date --- AGENTS.md | 3 +++ mcp_plex/loader.py | 56 +++++++++++++++++++++++++++++++++++---- mcp_plex/types.py | 8 ++++++ pyproject.toml | 2 +- tests/test_loader_unit.py | 22 +++++++++++++++ uv.lock | 2 +- 6 files changed, 86 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index df937f8..efb7bf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,9 @@ avoid unnecessary model downloads when reranking is disabled or unavailable. - Media payload and artwork caching centralized in `MediaCache` attached to `PlexServer` to streamline cache management and testing. +- Plex episodes with year-based seasons are mapped to the correct TMDb season + numbers via a helper that matches season names or air-date years to ensure + accurate episode lookups. ## User Queries The project should handle natural-language searches and recommendations such as: diff --git a/mcp_plex/loader.py b/mcp_plex/loader.py index 2708b4d..e5b766b 100644 --- a/mcp_plex/loader.py +++ b/mcp_plex/loader.py @@ -164,6 +164,56 @@ async def _fetch_tmdb_episode( return None +def resolve_tmdb_season_number( + show_tmdb: Optional[TMDBShow], episode: PlexPartialObject +) -> Optional[int]: + """Map a Plex episode to the appropriate TMDb season number. + + This resolves cases where Plex uses year-based season indices that do not + match TMDb's sequential ``season_number`` values. + """ + + parent_index = getattr(episode, "parentIndex", None) + parent_title = getattr(episode, "parentTitle", None) + + seasons = getattr(show_tmdb, "seasons", []) if show_tmdb else [] + + # direct numeric match + if parent_index is not None: + for season in seasons: + if season.season_number == parent_index: + return season.season_number + + # match by season name (e.g. "Season 2018" -> "2018") + title_norm: Optional[str] = None + if isinstance(parent_title, str): + title_norm = parent_title.lower().lstrip("season ").strip() + for season in seasons: + name_norm = (season.name or "").lower().lstrip("season ").strip() + if name_norm == title_norm: + return season.season_number + + # match by air date year when Plex uses year-based seasons + year: Optional[int] = None + if isinstance(parent_index, int): + year = parent_index + elif title_norm and title_norm.isdigit(): + year = int(title_norm) + + if year is not None: + for season in seasons: + air = getattr(season, "air_date", None) + if isinstance(air, str) and len(air) >= 4 and air[:4].isdigit(): + if int(air[:4]) == year: + return season.season_number + + if parent_index is not None: + return int(parent_index) + if isinstance(parent_title, str) and parent_title.isdigit(): + return int(parent_title) + return None + + def _extract_external_ids(item: PlexPartialObject) -> ExternalIDs: """Extract IMDb and TMDb IDs from a Plex object.""" @@ -245,11 +295,7 @@ async def _augment_episode( imdb_task = ( _fetch_imdb(client, ids.imdb) if ids.imdb else asyncio.sleep(0, result=None) ) - season = getattr(episode, "parentIndex", None) - if season is None: - title = getattr(episode, "parentTitle", "") - if isinstance(title, str) and title.isdigit(): - season = int(title) + season = resolve_tmdb_season_number(show_tmdb, episode) ep_num = getattr(episode, "index", None) tmdb_task = ( _fetch_tmdb_episode(client, show_tmdb.id, season, ep_num, tmdb_api_key) diff --git a/mcp_plex/types.py b/mcp_plex/types.py index 2c23f05..a5cf4fd 100644 --- a/mcp_plex/types.py +++ b/mcp_plex/types.py @@ -50,6 +50,12 @@ class TMDBGenre(BaseModel): name: str +class TMDBSeason(BaseModel): + season_number: int + name: str + air_date: Optional[str] = None + + class TMDBMovie(BaseModel): id: int title: str @@ -77,6 +83,7 @@ class TMDBShow(BaseModel): first_air_date: Optional[str] = None last_air_date: Optional[str] = None genres: List[TMDBGenre] = Field(default_factory=list) + seasons: List[TMDBSeason] = Field(default_factory=list) poster_path: Optional[str] = None backdrop_path: Optional[str] = None tagline: Optional[str] = None @@ -153,6 +160,7 @@ class ExternalIDs: "IMDbName", "TMDBGenre", "TMDBMovie", + "TMDBSeason", "TMDBShow", "TMDBEpisode", "TMDBItem", diff --git a/pyproject.toml b/pyproject.toml index 603a9c8..ccfa34d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mcp-plex" -version = "0.26.15" +version = "0.26.17" description = "Plex-Oriented Model Context Protocol Server" requires-python = ">=3.11,<3.13" diff --git a/tests/test_loader_unit.py b/tests/test_loader_unit.py index 6f58c91..ab98203 100644 --- a/tests/test_loader_unit.py +++ b/tests/test_loader_unit.py @@ -18,7 +18,9 @@ _load_imdb_retry_queue, _persist_imdb_retry_queue, _process_imdb_retry_queue, + resolve_tmdb_season_number, ) +from mcp_plex.types import TMDBSeason, TMDBShow def test_extract_external_ids(): @@ -240,3 +242,23 @@ async def second_run(): asyncio.run(second_run()) assert json.loads(queue_path.read_text()) == [] assert loader._imdb_cache.get("tt1") is not None + + +def test_resolve_tmdb_season_number_matches_name(): + episode = types.SimpleNamespace(parentIndex=2018, parentTitle="2018") + show = TMDBShow( + id=1, + name="Show", + seasons=[TMDBSeason(season_number=14, name="2018")], + ) + assert resolve_tmdb_season_number(show, episode) == 14 + + +def test_resolve_tmdb_season_number_matches_air_date(): + episode = types.SimpleNamespace(parentIndex=2018, parentTitle="Season 2018") + show = TMDBShow( + id=1, + name="Show", + seasons=[TMDBSeason(season_number=16, name="Season 16", air_date="2018-01-03")], + ) + assert resolve_tmdb_season_number(show, episode) == 16 diff --git a/uv.lock b/uv.lock index 4b106d3..db307c1 100644 --- a/uv.lock +++ b/uv.lock @@ -690,7 +690,7 @@ wheels = [ [[package]] name = "mcp-plex" -version = "0.26.15" +version = "0.26.17" source = { editable = "." } dependencies = [ { name = "fastapi" },