From 93e648fec7aeeacffaafe448210f289cca92e571 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Mon, 6 Oct 2025 18:02:52 -0600 Subject: [PATCH 1/2] fix(loader): normalise Plex person identifiers --- docker/pyproject.deps.toml | 2 +- mcp_plex/loader/pipeline/enrichment.py | 25 ++++++++++++++++++++++--- pyproject.toml | 2 +- tests/test_enrichment_helpers.py | 20 ++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/docker/pyproject.deps.toml b/docker/pyproject.deps.toml index aee68f8..fb38977 100644 --- a/docker/pyproject.deps.toml +++ b/docker/pyproject.deps.toml @@ -1,6 +1,6 @@ [project] name = "mcp-plex" -version = "1.0.8" +version = "1.0.9" requires-python = ">=3.11,<3.13" dependencies = [ "fastmcp>=2.11.2", diff --git a/mcp_plex/loader/pipeline/enrichment.py b/mcp_plex/loader/pipeline/enrichment.py index b471aa4..f96cffb 100644 --- a/mcp_plex/loader/pipeline/enrichment.py +++ b/mcp_plex/loader/pipeline/enrichment.py @@ -66,13 +66,32 @@ def _extract_external_ids(item: PlexPartialObject) -> ExternalIDs: return ExternalIDs(imdb=imdb_id, tmdb=tmdb_id) +def _coerce_person_id(raw_id: Any) -> int: + """Coerce a Plex person identifier into an integer.""" + + if isinstance(raw_id, int): + return raw_id + if isinstance(raw_id, str): + raw_id = raw_id.strip() + if not raw_id: + return 0 + try: + return int(raw_id) + except ValueError: + return 0 + try: + return int(raw_id) + except (TypeError, ValueError): + return 0 + + def _build_plex_item(item: PlexPartialObject) -> PlexItem: """Convert a Plex object into the internal :class:`PlexItem`.""" guids = [PlexGuid(id=g.id) for g in getattr(item, "guids", [])] directors = [ PlexPerson( - id=getattr(d, "id", 0), + id=_coerce_person_id(getattr(d, "id", 0)), tag=str(getattr(d, "tag", "")), thumb=getattr(d, "thumb", None), ) @@ -80,7 +99,7 @@ def _build_plex_item(item: PlexPartialObject) -> PlexItem: ] writers = [ PlexPerson( - id=getattr(w, "id", 0), + id=_coerce_person_id(getattr(w, "id", 0)), tag=str(getattr(w, "tag", "")), thumb=getattr(w, "thumb", None), ) @@ -88,7 +107,7 @@ def _build_plex_item(item: PlexPartialObject) -> PlexItem: ] actors = [ PlexPerson( - id=getattr(a, "id", 0), + id=_coerce_person_id(getattr(a, "id", 0)), tag=str(getattr(a, "tag", "")), thumb=getattr(a, "thumb", None), role=getattr(a, "role", None), diff --git a/pyproject.toml b/pyproject.toml index c539dd5..6f45a1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mcp-plex" -version = "1.0.8" +version = "1.0.9" 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 98830b5..0d8a8d2 100644 --- a/tests/test_enrichment_helpers.py +++ b/tests/test_enrichment_helpers.py @@ -86,6 +86,26 @@ def test_build_plex_item_converts_string_indices(): assert item.episode_number == 3 +def test_build_plex_item_normalises_person_ids(): + raw = types.SimpleNamespace( + ratingKey="1", + guid="g", + type="movie", + title="T", + directors=[types.SimpleNamespace(id=None, tag="Director")], + writers=[types.SimpleNamespace(id="5", tag="Writer")], + actors=[ + types.SimpleNamespace(id="", tag="Actor"), + types.SimpleNamespace(id="7", tag="Lead"), + ], + ) + + item = _build_plex_item(raw) + assert [p.id for p in item.directors] == [0] + assert [p.id for p in item.writers] == [5] + assert [p.id for p in item.actors] == [0, 7] + + def test_fetch_functions_success_and_failure(): async def tmdb_movie_mock(request): assert request.headers.get("Authorization") == "Bearer k" diff --git a/uv.lock b/uv.lock index 5151b52..70dce72 100644 --- a/uv.lock +++ b/uv.lock @@ -730,7 +730,7 @@ wheels = [ [[package]] name = "mcp-plex" -version = "1.0.8" +version = "1.0.9" source = { editable = "." } dependencies = [ { name = "fastapi" }, From 6ba0f12984637f6c98f6ef7e439a65de58526488 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Mon, 6 Oct 2025 18:13:30 -0600 Subject: [PATCH 2/2] fix(loader): normalise Plex tag identifiers --- docker/pyproject.deps.toml | 2 +- mcp_plex/common/validation.py | 23 +++++++++++++++++++- mcp_plex/loader/__init__.py | 30 ++++++++++++++++++++------ mcp_plex/loader/pipeline/enrichment.py | 27 ++++------------------- pyproject.toml | 2 +- tests/test_common_validation.py | 23 ++++++++++++++++---- uv.lock | 2 +- 7 files changed, 71 insertions(+), 38 deletions(-) diff --git a/docker/pyproject.deps.toml b/docker/pyproject.deps.toml index fb38977..0dccc92 100644 --- a/docker/pyproject.deps.toml +++ b/docker/pyproject.deps.toml @@ -1,6 +1,6 @@ [project] name = "mcp-plex" -version = "1.0.9" +version = "1.0.10" requires-python = ">=3.11,<3.13" dependencies = [ "fastmcp>=2.11.2", diff --git a/mcp_plex/common/validation.py b/mcp_plex/common/validation.py index 0e65e5a..3d17921 100644 --- a/mcp_plex/common/validation.py +++ b/mcp_plex/common/validation.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + def require_positive(value: int, *, name: str) -> int: """Return *value* if it is a positive integer, otherwise raise an error.""" @@ -13,4 +15,23 @@ def require_positive(value: int, *, name: str) -> int: return value -__all__ = ["require_positive"] +def coerce_plex_tag_id(raw_id: Any) -> int: + """Best-effort conversion of Plex media tag identifiers to integers.""" + + if isinstance(raw_id, int): + return raw_id + if isinstance(raw_id, str): + raw_id = raw_id.strip() + if not raw_id: + return 0 + try: + return int(raw_id) + except ValueError: + return 0 + try: + return int(raw_id) + except (TypeError, ValueError): + return 0 + + +__all__ = ["require_positive", "coerce_plex_tag_id"] diff --git a/mcp_plex/loader/__init__.py b/mcp_plex/loader/__init__.py index 0b780ad..c9fd4b2 100644 --- a/mcp_plex/loader/__init__.py +++ b/mcp_plex/loader/__init__.py @@ -27,7 +27,7 @@ PersistenceQueue, chunk_sequence, ) -from ..common.validation import require_positive +from ..common.validation import coerce_plex_tag_id, require_positive from .pipeline.orchestrator import LoaderOrchestrator from .pipeline.persistence import PersistenceStage as _PersistenceStage from ..common.types import ( @@ -494,16 +494,24 @@ def _load_from_sample(sample_dir: Path) -> List[AggregatedItem]: tagline=movie_data.get("tagline"), content_rating=movie_data.get("contentRating"), directors=[ - PlexPerson(id=d.get("id", 0), tag=d.get("tag", ""), thumb=d.get("thumb")) + PlexPerson( + id=coerce_plex_tag_id(d.get("id", 0)), + tag=d.get("tag", ""), + thumb=d.get("thumb"), + ) for d in movie_data.get("Director", []) ], writers=[ - PlexPerson(id=w.get("id", 0), tag=w.get("tag", ""), thumb=w.get("thumb")) + PlexPerson( + id=coerce_plex_tag_id(w.get("id", 0)), + tag=w.get("tag", ""), + thumb=w.get("thumb"), + ) for w in movie_data.get("Writer", []) ], actors=[ PlexPerson( - id=a.get("id", 0), + id=coerce_plex_tag_id(a.get("id", 0)), tag=a.get("tag", ""), role=a.get("role"), thumb=a.get("thumb"), @@ -545,16 +553,24 @@ def _load_from_sample(sample_dir: Path) -> List[AggregatedItem]: tagline=episode_data.get("tagline"), content_rating=episode_data.get("contentRating"), directors=[ - PlexPerson(id=d.get("id", 0), tag=d.get("tag", ""), thumb=d.get("thumb")) + PlexPerson( + id=coerce_plex_tag_id(d.get("id", 0)), + tag=d.get("tag", ""), + thumb=d.get("thumb"), + ) for d in episode_data.get("Director", []) ], writers=[ - PlexPerson(id=w.get("id", 0), tag=w.get("tag", ""), thumb=w.get("thumb")) + PlexPerson( + id=coerce_plex_tag_id(w.get("id", 0)), + tag=w.get("tag", ""), + thumb=w.get("thumb"), + ) for w in episode_data.get("Writer", []) ], actors=[ PlexPerson( - id=a.get("id", 0), + id=coerce_plex_tag_id(a.get("id", 0)), tag=a.get("tag", ""), role=a.get("role"), thumb=a.get("thumb"), diff --git a/mcp_plex/loader/pipeline/enrichment.py b/mcp_plex/loader/pipeline/enrichment.py index f96cffb..666436e 100644 --- a/mcp_plex/loader/pipeline/enrichment.py +++ b/mcp_plex/loader/pipeline/enrichment.py @@ -30,7 +30,7 @@ SampleBatch, chunk_sequence, ) -from ...common.validation import require_positive +from ...common.validation import coerce_plex_tag_id, require_positive from ...common.types import ( AggregatedItem, @@ -66,32 +66,13 @@ def _extract_external_ids(item: PlexPartialObject) -> ExternalIDs: return ExternalIDs(imdb=imdb_id, tmdb=tmdb_id) -def _coerce_person_id(raw_id: Any) -> int: - """Coerce a Plex person identifier into an integer.""" - - if isinstance(raw_id, int): - return raw_id - if isinstance(raw_id, str): - raw_id = raw_id.strip() - if not raw_id: - return 0 - try: - return int(raw_id) - except ValueError: - return 0 - try: - return int(raw_id) - except (TypeError, ValueError): - return 0 - - def _build_plex_item(item: PlexPartialObject) -> PlexItem: """Convert a Plex object into the internal :class:`PlexItem`.""" guids = [PlexGuid(id=g.id) for g in getattr(item, "guids", [])] directors = [ PlexPerson( - id=_coerce_person_id(getattr(d, "id", 0)), + id=coerce_plex_tag_id(getattr(d, "id", 0)), tag=str(getattr(d, "tag", "")), thumb=getattr(d, "thumb", None), ) @@ -99,7 +80,7 @@ def _build_plex_item(item: PlexPartialObject) -> PlexItem: ] writers = [ PlexPerson( - id=_coerce_person_id(getattr(w, "id", 0)), + id=coerce_plex_tag_id(getattr(w, "id", 0)), tag=str(getattr(w, "tag", "")), thumb=getattr(w, "thumb", None), ) @@ -107,7 +88,7 @@ def _build_plex_item(item: PlexPartialObject) -> PlexItem: ] actors = [ PlexPerson( - id=_coerce_person_id(getattr(a, "id", 0)), + id=coerce_plex_tag_id(getattr(a, "id", 0)), tag=str(getattr(a, "tag", "")), thumb=getattr(a, "thumb", None), role=getattr(a, "role", None), diff --git a/pyproject.toml b/pyproject.toml index 6f45a1d..021ec8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mcp-plex" -version = "1.0.9" +version = "1.0.10" description = "Plex-Oriented Model Context Protocol Server" requires-python = ">=3.11,<3.13" diff --git a/tests/test_common_validation.py b/tests/test_common_validation.py index 45eac69..ed16068 100644 --- a/tests/test_common_validation.py +++ b/tests/test_common_validation.py @@ -1,19 +1,34 @@ +"""Tests for shared validation helpers.""" + import pytest -from mcp_plex.common.validation import require_positive +from mcp_plex.common.validation import coerce_plex_tag_id, require_positive -def test_require_positive_accepts_positive_int(): +def test_require_positive_accepts_positive_int() -> None: assert require_positive(5, name="value") == 5 @pytest.mark.parametrize("bad", [0, -1, -100]) -def test_require_positive_rejects_non_positive_int(bad): +def test_require_positive_rejects_non_positive_int(bad: int) -> None: with pytest.raises(ValueError, match="value must be positive"): require_positive(bad, name="value") @pytest.mark.parametrize("bad_type", [1.5, "1", None, object(), True]) -def test_require_positive_enforces_int_type(bad_type): +def test_require_positive_enforces_int_type(bad_type: object) -> None: with pytest.raises(TypeError, match="value must be an int"): require_positive(bad_type, name="value") # type: ignore[arg-type] + + +def test_coerce_plex_tag_id_accepts_ints() -> None: + assert coerce_plex_tag_id(7) == 7 + + +def test_coerce_plex_tag_id_coerces_strings() -> None: + assert coerce_plex_tag_id(" 42 ") == 42 + + +def test_coerce_plex_tag_id_handles_invalid_values() -> None: + assert coerce_plex_tag_id(None) == 0 + assert coerce_plex_tag_id("not-a-number") == 0 diff --git a/uv.lock b/uv.lock index 70dce72..f406974 100644 --- a/uv.lock +++ b/uv.lock @@ -730,7 +730,7 @@ wheels = [ [[package]] name = "mcp-plex" -version = "1.0.9" +version = "1.0.10" source = { editable = "." } dependencies = [ { name = "fastapi" },