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
45 changes: 45 additions & 0 deletions mcp_plex/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

import os
import json
from typing import Any, Annotated

from fastmcp.server import FastMCP
Expand Down Expand Up @@ -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[
Expand Down Expand Up @@ -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[
Expand Down
75 changes: 49 additions & 26 deletions tests/test_load_from_plex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
)

Expand All @@ -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)
Expand All @@ -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
44 changes: 25 additions & 19 deletions tests/test_loader_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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():
Expand Down
12 changes: 12 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pathlib import Path
import importlib
import types
import json
import pytest

from mcp_plex import loader
Expand Down Expand Up @@ -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"))

Expand Down