Skip to content

Commit 604800d

Browse files
authored
feat(server): add LLM summaries for media tools (#149)
1 parent 5c4873a commit 604800d

File tree

8 files changed

+467
-35
lines changed

8 files changed

+467
-35
lines changed

docker/pyproject.deps.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mcp-plex"
3-
version = "2.0.10"
3+
version = "2.0.11"
44
requires-python = ">=3.11,<3.13"
55
dependencies = [
66
"fastmcp>=2.11.2",

mcp_plex/server/media.py

Lines changed: 202 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@
22

33
from __future__ import annotations
44

5-
from typing import Any, Mapping, TYPE_CHECKING, cast
5+
from typing import Any, Mapping, Sequence, TYPE_CHECKING, cast
66

77
from qdrant_client import models
88

99
from ..common.types import JSONValue
10-
from .models import AggregatedMediaItem, PlexMediaMetadata, QdrantMediaPayload
10+
from .models import (
11+
AggregatedMediaItem,
12+
MediaSummaryIdentifiers,
13+
MediaSummaryResponse,
14+
PlexMediaMetadata,
15+
QdrantMediaPayload,
16+
SummarizedMediaItem,
17+
)
1118

1219
if TYPE_CHECKING: # pragma: no cover - imported for type checking only
1320
from . import PlexServer
@@ -140,3 +147,196 @@ async def _get_media_data(server: "PlexServer", identifier: str) -> AggregatedMe
140147
if isinstance(art, str) and art:
141148
server.cache.set_background(rating_key, art)
142149
return payload
150+
151+
152+
def _coerce_text(value: Any) -> str | None:
153+
"""Convert raw values into a cleaned string when possible."""
154+
155+
if isinstance(value, str):
156+
text = value.strip()
157+
return text or None
158+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
159+
parts = [
160+
str(item).strip()
161+
for item in value
162+
if isinstance(item, str) and item.strip()
163+
]
164+
if parts:
165+
return "; ".join(parts)
166+
return None
167+
168+
169+
def _coerce_string_list(value: Any) -> list[str]:
170+
"""Extract a list of readable strings from mixed payload values."""
171+
172+
items: list[str] = []
173+
if isinstance(value, str):
174+
text = value.strip()
175+
if text:
176+
items.append(text)
177+
return items
178+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
179+
for entry in value:
180+
if isinstance(entry, str):
181+
text = entry.strip()
182+
if text:
183+
items.append(text)
184+
elif isinstance(entry, Mapping):
185+
for key in ("tag", "name", "title", "role"):
186+
maybe = entry.get(key)
187+
if isinstance(maybe, str):
188+
text = maybe.strip()
189+
if text:
190+
items.append(text)
191+
break
192+
return items
193+
194+
195+
def _first_text(*values: Any) -> str | None:
196+
"""Return the first non-empty string from the provided values."""
197+
198+
for value in values:
199+
text = _coerce_text(value)
200+
if text:
201+
return text
202+
return None
203+
204+
205+
def _extract_review_snippet(value: Any) -> str | None:
206+
"""Return a representative review snippet if one exists."""
207+
208+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)):
209+
return None
210+
for entry in value:
211+
if isinstance(entry, str):
212+
text = entry.strip()
213+
if text:
214+
return text
215+
elif isinstance(entry, Mapping):
216+
for key in ("quote", "summary", "text", "content"):
217+
maybe = entry.get(key)
218+
if isinstance(maybe, str):
219+
text = maybe.strip()
220+
if text:
221+
return text
222+
return None
223+
224+
225+
def summarize_media_items_for_llm(
226+
items: Sequence[AggregatedMediaItem],
227+
) -> MediaSummaryResponse:
228+
"""Create a concise summary that can be read by a voice assistant or LLM."""
229+
230+
summaries: list[SummarizedMediaItem] = []
231+
for media in items:
232+
plex_info = _extract_plex_metadata(media)
233+
summary_item: SummarizedMediaItem = {}
234+
235+
title = _first_text(media.get("title"), plex_info.get("title"))
236+
if title:
237+
summary_item["title"] = title
238+
239+
media_type = _first_text(media.get("type"), plex_info.get("type"))
240+
if media_type:
241+
summary_item["type"] = media_type
242+
243+
year = media.get("year")
244+
if year is None:
245+
year = plex_info.get("year")
246+
if isinstance(year, int):
247+
summary_item["year"] = year
248+
249+
show = _first_text(
250+
media.get("show_title"),
251+
plex_info.get("grandparent_title"),
252+
plex_info.get("parent_title"),
253+
)
254+
if show:
255+
summary_item["show"] = show
256+
257+
season = media.get("season_number")
258+
if season is None:
259+
season = plex_info.get("parent_index")
260+
if isinstance(season, int):
261+
summary_item["season"] = season
262+
263+
episode = media.get("episode_number")
264+
if episode is None:
265+
episode = plex_info.get("index")
266+
if isinstance(episode, int):
267+
summary_item["episode"] = episode
268+
269+
genres = _coerce_string_list(media.get("genres"))
270+
if genres:
271+
summary_item["genres"] = genres
272+
273+
collections = _coerce_string_list(media.get("collections"))
274+
if collections:
275+
summary_item["collections"] = collections
276+
277+
actors = _coerce_string_list(media.get("actors"))
278+
if not actors:
279+
actors = _coerce_string_list(plex_info.get("actors"))
280+
if actors:
281+
summary_item["actors"] = actors
282+
283+
directors = _coerce_string_list(media.get("directors"))
284+
if not directors:
285+
directors = _coerce_string_list(plex_info.get("directors"))
286+
if directors:
287+
summary_item["directors"] = directors
288+
289+
writers = _coerce_string_list(media.get("writers"))
290+
if not writers:
291+
writers = _coerce_string_list(plex_info.get("writers"))
292+
if writers:
293+
summary_item["writers"] = writers
294+
295+
tagline = _first_text(media.get("tagline"), plex_info.get("tagline"))
296+
main_summary = _first_text(
297+
media.get("summary"),
298+
plex_info.get("summary"),
299+
media.get("overview"),
300+
media.get("plot"),
301+
)
302+
review_snippet = _extract_review_snippet(media.get("reviews"))
303+
304+
description_parts: list[str] = []
305+
if tagline:
306+
description_parts.append(tagline)
307+
if main_summary:
308+
description_parts.append(main_summary)
309+
elif review_snippet:
310+
description_parts.append(review_snippet)
311+
if actors:
312+
description_parts.append(f"Starring {', '.join(actors[:5])}")
313+
if show and summary_item.get("type") == "episode":
314+
description_parts.append(f"Episode of {show}")
315+
316+
description = " ".join(description_parts)
317+
if description:
318+
summary_item["description"] = description
319+
320+
identifiers: MediaSummaryIdentifiers = {}
321+
rating_key = _normalize_identifier(plex_info.get("rating_key"))
322+
if rating_key:
323+
identifiers["rating_key"] = rating_key
324+
imdb_value = media.get("imdb")
325+
if isinstance(imdb_value, Mapping):
326+
imdb_id = _normalize_identifier(imdb_value.get("id"))
327+
if imdb_id:
328+
identifiers["imdb"] = imdb_id
329+
tmdb_value = media.get("tmdb")
330+
if isinstance(tmdb_value, Mapping):
331+
tmdb_id = _normalize_identifier(tmdb_value.get("id"))
332+
if tmdb_id:
333+
identifiers["tmdb"] = tmdb_id
334+
if identifiers:
335+
summary_item["identifiers"] = identifiers
336+
337+
summaries.append(summary_item)
338+
339+
return {
340+
"total_results": len(items),
341+
"results": summaries,
342+
}

mcp_plex/server/models.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,39 @@ class AggregatedMediaItem(TypedDict, total=False):
7373
plex: NotRequired[PlexMediaMetadata]
7474

7575

76+
class MediaSummaryIdentifiers(TypedDict, total=False):
77+
"""Identifiers that help reference a summarized media item."""
78+
79+
rating_key: NotRequired[str]
80+
imdb: NotRequired[str]
81+
tmdb: NotRequired[str]
82+
83+
84+
class SummarizedMediaItem(TypedDict, total=False):
85+
"""Concise description of a media item for LLM consumption."""
86+
87+
title: NotRequired[str]
88+
type: NotRequired[str]
89+
year: NotRequired[int]
90+
description: NotRequired[str]
91+
genres: NotRequired[list[str]]
92+
collections: NotRequired[list[str]]
93+
actors: NotRequired[list[str]]
94+
directors: NotRequired[list[str]]
95+
writers: NotRequired[list[str]]
96+
show: NotRequired[str]
97+
season: NotRequired[int]
98+
episode: NotRequired[int]
99+
identifiers: NotRequired[MediaSummaryIdentifiers]
100+
101+
102+
class MediaSummaryResponse(TypedDict):
103+
"""Container for summarized media items."""
104+
105+
total_results: int
106+
results: list[SummarizedMediaItem]
107+
108+
76109
class QdrantMediaPayload(TypedDict, total=False):
77110
"""Raw payload stored within Qdrant records."""
78111

@@ -121,6 +154,9 @@ class PlexPlayerMetadata(TypedDict, total=False):
121154
"ExternalIds",
122155
"PlexMediaMetadata",
123156
"AggregatedMediaItem",
157+
"MediaSummaryIdentifiers",
158+
"SummarizedMediaItem",
159+
"MediaSummaryResponse",
124160
"QdrantMediaPayload",
125161
"PlexPlayerMetadata",
126162
]

0 commit comments

Comments
 (0)