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 = "1.0.9"
version = "1.0.10"
requires-python = ">=3.11,<3.13"
dependencies = [
"fastmcp>=2.11.2",
Expand Down
23 changes: 22 additions & 1 deletion mcp_plex/common/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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"]
30 changes: 23 additions & 7 deletions mcp_plex/loader/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down
8 changes: 4 additions & 4 deletions mcp_plex/loader/pipeline/enrichment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -72,23 +72,23 @@ def _build_plex_item(item: PlexPartialObject) -> PlexItem:
guids = [PlexGuid(id=g.id) for g in getattr(item, "guids", [])]
directors = [
PlexPerson(
id=getattr(d, "id", 0),
id=coerce_plex_tag_id(getattr(d, "id", 0)),
tag=str(getattr(d, "tag", "")),
thumb=getattr(d, "thumb", None),
)
for d in getattr(item, "directors", []) or []
]
writers = [
PlexPerson(
id=getattr(w, "id", 0),
id=coerce_plex_tag_id(getattr(w, "id", 0)),
tag=str(getattr(w, "tag", "")),
thumb=getattr(w, "thumb", None),
)
for w in getattr(item, "writers", []) or []
]
actors = [
PlexPerson(
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),
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 = "1.0.9"
version = "1.0.10"

description = "Plex-Oriented Model Context Protocol Server"
requires-python = ">=3.11,<3.13"
Expand Down
23 changes: 19 additions & 4 deletions tests/test_common_validation.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions tests/test_enrichment_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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.