diff --git a/mcp_plex/server.py b/mcp_plex/server.py index f63de76..038ef2e 100644 --- a/mcp_plex/server.py +++ b/mcp_plex/server.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +import json from typing import Any, Annotated from fastmcp.server import FastMCP @@ -57,6 +58,14 @@ async def _find_records(identifier: str, limit: int = 5) -> list[models.Record]: return points +async def _get_media_data(identifier: str) -> dict[str, Any]: + """Return the first matching media record's payload.""" + records = await _find_records(identifier, limit=1) + if not records: + raise ValueError("Media item not found") + return records[0].payload["data"] + + @server.tool("get-media") async def get_media( identifier: Annotated[ @@ -144,6 +153,42 @@ async def recommend_media( return [r.payload["data"] for r in recs] +@server.resource("resource://media-item/{identifier}") +async def media_item( + identifier: Annotated[ + str, + Field( + description="Rating key, IMDb/TMDb ID, or media title", + examples=["49915", "tt8367814", "The Gentlemen"], + ), + ], +) -> str: + """Return full metadata for the given media identifier.""" + data = await _get_media_data(identifier) + return json.dumps(data) + + +@server.resource("resource://media-ids/{identifier}") +async def media_ids( + identifier: Annotated[ + str, + Field( + description="Rating key, IMDb/TMDb ID, or media title", + examples=["49915", "tt8367814", "The Gentlemen"], + ), + ], +) -> str: + """Return external identifiers for the given media item.""" + data = await _get_media_data(identifier) + ids = { + "rating_key": data.get("plex", {}).get("rating_key"), + "imdb": data.get("imdb", {}).get("id"), + "tmdb": data.get("tmdb", {}).get("id"), + "title": data.get("plex", {}).get("title"), + } + return json.dumps(ids) + + @server.resource("resource://media-poster/{identifier}") async def media_poster( identifier: Annotated[ diff --git a/tests/test_load_from_plex.py b/tests/test_load_from_plex.py index 136073d..44c4c81 100644 --- a/tests/test_load_from_plex.py +++ b/tests/test_load_from_plex.py @@ -8,36 +8,36 @@ def test_load_from_plex(monkeypatch): movie = types.SimpleNamespace( - ratingKey="1", - guid="g1", + ratingKey="101", + guid="plex://movie/101", type="movie", - title="Movie", + title="Inception", guids=[ - types.SimpleNamespace(id="imdb://ttm"), - types.SimpleNamespace(id="tmdb://1"), + types.SimpleNamespace(id="imdb://tt1375666"), + types.SimpleNamespace(id="tmdb://27205"), ], ) ep1 = types.SimpleNamespace( - ratingKey="2", - guid="g2", + ratingKey="102", + guid="plex://episode/102", type="episode", - title="Ep1", + title="Pilot", guids=[ - types.SimpleNamespace(id="imdb://tt1"), - types.SimpleNamespace(id="tmdb://2"), + types.SimpleNamespace(id="imdb://tt0959621"), + types.SimpleNamespace(id="tmdb://62085"), ], ) ep2 = types.SimpleNamespace( - ratingKey="3", - guid="g3", + ratingKey="103", + guid="plex://episode/103", type="episode", - title="Ep2", - guids=[types.SimpleNamespace(id="imdb://tt2")], + title="Cat's in the Bag...", + guids=[types.SimpleNamespace(id="imdb://tt0959622")], ) show = types.SimpleNamespace( - guids=[types.SimpleNamespace(id="tmdb://3")], + guids=[types.SimpleNamespace(id="tmdb://1396")], episodes=lambda: [ep1, ep2], ) @@ -50,16 +50,39 @@ def test_load_from_plex(monkeypatch): async def handler(request): url = str(request.url) - if "imdbapi" in url: + if "tt1375666" in url: return httpx.Response( - 200, json={"id": "tt", "type": "movie", "primaryTitle": "IMDb"} + 200, + json={ + "id": "tt1375666", + "type": "movie", + "primaryTitle": "Inception", + }, ) - if "/movie/1" in url: - return httpx.Response(200, json={"id": 1, "title": "TMDB Movie"}) - if "/tv/3" in url: - return httpx.Response(200, json={"id": 3, "name": "TMDB Show"}) - if "/episode/2" in url: - return httpx.Response(200, json={"id": 2, "name": "TMDB Ep"}) + if "tt0959621" in url: + return httpx.Response( + 200, + json={ + "id": "tt0959621", + "type": "episode", + "primaryTitle": "Pilot", + }, + ) + if "tt0959622" in url: + return httpx.Response( + 200, + json={ + "id": "tt0959622", + "type": "episode", + "primaryTitle": "Cat's in the Bag...", + }, + ) + if "/movie/27205" in url: + return httpx.Response(200, json={"id": 27205, "title": "Inception"}) + if "/tv/1396" in url: + return httpx.Response(200, json={"id": 1396, "name": "Breaking Bad"}) + if "/episode/62085" in url: + return httpx.Response(200, json={"id": 62085, "name": "Pilot"}) return httpx.Response(404) transport = httpx.MockTransport(handler) @@ -72,7 +95,7 @@ async def handler(request): items = asyncio.run(loader._load_from_plex(server, "key")) assert len(items) == 3 - assert items[0].tmdb and items[0].tmdb.id == 1 - assert items[1].tmdb and items[1].tmdb.id == 2 + assert items[0].tmdb and items[0].tmdb.id == 27205 + assert items[1].tmdb and items[1].tmdb.id == 62085 assert isinstance(items[2].tmdb, TMDBShow) - assert items[2].tmdb.id == 3 + assert items[2].tmdb.id == 1396 diff --git a/tests/test_loader_unit.py b/tests/test_loader_unit.py index 6ba2114..eeb3a03 100644 --- a/tests/test_loader_unit.py +++ b/tests/test_loader_unit.py @@ -16,11 +16,14 @@ def test_extract_external_ids(): - guid_objs = [types.SimpleNamespace(id="imdb://tt123"), types.SimpleNamespace(id="tmdb://456")] + guid_objs = [ + types.SimpleNamespace(id="imdb://tt0133093"), + types.SimpleNamespace(id="tmdb://603"), + ] item = types.SimpleNamespace(guids=guid_objs) ids = _extract_external_ids(item) - assert ids.imdb == "tt123" - assert ids.tmdb == "456" + assert ids.imdb == "tt0133093" + assert ids.tmdb == "603" def test_load_from_sample_returns_items(): @@ -31,28 +34,31 @@ def test_load_from_sample_returns_items(): def test_build_plex_item_handles_full_metadata(): - guid_objs = [types.SimpleNamespace(id="imdb://tt123"), types.SimpleNamespace(id="tmdb://456")] + guid_objs = [ + types.SimpleNamespace(id="imdb://tt0133093"), + types.SimpleNamespace(id="tmdb://603"), + ] raw = types.SimpleNamespace( - ratingKey="1", - guid="guid", + ratingKey="603", + guid="plex://movie/603", type="movie", - title="Title", - summary="Summary", - year=2024, + title="The Matrix", + summary="A hacker discovers the nature of his reality.", + year=1999, guids=guid_objs, - thumb="thumb.jpg", - art="art.jpg", - tagline="Tagline", - contentRating="PG", - directors=[types.SimpleNamespace(id=1, tag="Director", thumb="d.jpg")], - writers=[types.SimpleNamespace(id=2, tag="Writer", thumb="w.jpg")], - actors=[types.SimpleNamespace(id=3, tag="Actor", thumb="a.jpg", role="Role")], + thumb="matrix.jpg", + art="matrix_art.jpg", + tagline="Welcome to the Real World", + contentRating="R", + directors=[types.SimpleNamespace(id=1, tag="Lana Wachowski", thumb="lana.jpg")], + writers=[types.SimpleNamespace(id=2, tag="Lilly Wachowski", thumb="lilly.jpg")], + actors=[types.SimpleNamespace(id=3, tag="Keanu Reeves", thumb="neo.jpg", role="Neo")], ) item = _build_plex_item(raw) - assert item.rating_key == "1" - assert item.directors[0].tag == "Director" - assert item.actors[0].role == "Role" + assert item.rating_key == "603" + assert item.directors[0].tag == "Lana Wachowski" + assert item.actors[0].role == "Neo" def test_fetch_functions_success_and_failure(): diff --git a/tests/test_server.py b/tests/test_server.py index ba9fc5f..4dd64ef 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -3,6 +3,7 @@ from pathlib import Path import importlib import types +import json import pytest from mcp_plex import loader @@ -159,6 +160,17 @@ def test_server_tools(tmp_path, monkeypatch): art = asyncio.run(server.media_background.fn(identifier=movie_id)) assert isinstance(art, str) and "art" in art + item = json.loads(asyncio.run(server.media_item.fn(identifier=movie_id))) + assert item["plex"]["rating_key"] == movie_id + + ids = json.loads(asyncio.run(server.media_ids.fn(identifier=movie_id))) + assert ids["imdb"] == "tt8367814" + + with pytest.raises(ValueError): + asyncio.run(server.media_item.fn(identifier="0")) + with pytest.raises(ValueError): + asyncio.run(server.media_ids.fn(identifier="0")) + with pytest.raises(ValueError): asyncio.run(server.media_poster.fn(identifier="0"))