Skip to content

Commit 7bb482b

Browse files
authored
Add Plex playback tool with configurable player aliases (#75)
* feat: add Plex playback tool * feat: control plex playback via plexapi
1 parent 1f7ec58 commit 7bb482b

File tree

7 files changed

+392
-4
lines changed

7 files changed

+392
-4
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 = "0.26.44"
3+
version = "0.26.46"
44
requires-python = ">=3.11,<3.13"
55
dependencies = [
66
"fastmcp>=2.11.2",

mcp_plex/config.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

3-
from pydantic import Field
3+
import json
4+
5+
from pydantic import AnyHttpUrl, Field, field_validator
46
from pydantic_settings import BaseSettings, SettingsConfigDict
57

68

@@ -35,5 +37,24 @@ class Settings(BaseSettings):
3537
)
3638
cache_size: int = Field(default=128, validation_alias="CACHE_SIZE")
3739
use_reranker: bool = Field(default=True, validation_alias="USE_RERANKER")
40+
plex_url: AnyHttpUrl | None = Field(default=None, validation_alias="PLEX_URL")
41+
plex_token: str | None = Field(default=None, validation_alias="PLEX_TOKEN")
42+
plex_player_aliases: dict[str, str] = Field(
43+
default_factory=dict, validation_alias="PLEX_PLAYER_ALIASES"
44+
)
45+
46+
@field_validator("plex_player_aliases", mode="before")
47+
@classmethod
48+
def _parse_aliases(cls, value: object) -> dict[str, str]:
49+
if value in (None, ""):
50+
return {}
51+
if isinstance(value, str):
52+
try:
53+
value = json.loads(value)
54+
except json.JSONDecodeError as exc:
55+
raise ValueError("PLEX_PLAYER_ALIASES must be valid JSON") from exc
56+
if isinstance(value, dict):
57+
return {str(k): str(v) for k, v in value.items()}
58+
raise TypeError("PLEX_PLAYER_ALIASES must be a mapping or JSON object")
3859

3960
model_config = SettingsConfigDict(case_sensitive=False)

mcp_plex/server.py

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33

44
import argparse
55
import asyncio
6+
import importlib.metadata
67
import inspect
78
import json
89
import os
10+
import uuid
911
from typing import Annotated, Any, Callable, Sequence
1012

1113
from fastapi import FastAPI
@@ -14,6 +16,8 @@
1416
from fastmcp.prompts import Message
1517
from fastmcp.server import FastMCP
1618
from fastmcp.server.context import Context as FastMCPContext
19+
from plexapi.exceptions import PlexApiException
20+
from plexapi.server import PlexServer as PlexServerClient
1721
from pydantic import BaseModel, Field, create_model
1822
from qdrant_client import models
1923
from qdrant_client.async_qdrant_client import AsyncQdrantClient
@@ -32,6 +36,12 @@
3236
settings = Settings()
3337

3438

39+
try:
40+
__version__ = importlib.metadata.version("mcp-plex")
41+
except importlib.metadata.PackageNotFoundError:
42+
__version__ = "0.0.0"
43+
44+
3545
class PlexServer(FastMCP):
3646
"""FastMCP server with an attached Qdrant client."""
3747

@@ -73,9 +83,15 @@ def _lifespan(app: FastMCP) -> _ServerLifespan: # noqa: ARG001
7383
self._reranker: CrossEncoder | None = None
7484
self._reranker_loaded = False
7585
self.cache = MediaCache(self.settings.cache_size)
86+
self.client_identifier = uuid.uuid4().hex
87+
self._plex_identity: dict[str, Any] | None = None
88+
self._plex_client: PlexServerClient | None = None
89+
self._plex_client_lock = asyncio.Lock()
7690

7791
async def close(self) -> None:
7892
await self.qdrant_client.close()
93+
self._plex_client = None
94+
self._plex_identity = None
7995

8096
@property
8197
def settings(self) -> Settings: # type: ignore[override]
@@ -95,6 +111,12 @@ def reranker(self) -> CrossEncoder | None:
95111
self._reranker_loaded = True
96112
return self._reranker
97113

114+
def clear_plex_identity_cache(self) -> None:
115+
"""Reset cached Plex identity metadata."""
116+
117+
self._plex_identity = None
118+
self._plex_client = None
119+
98120

99121
server = PlexServer(settings=settings)
100122

@@ -199,6 +221,219 @@ async def _get_media_data(identifier: str) -> dict[str, Any]:
199221
return payload
200222

201223

224+
def _ensure_plex_configuration() -> None:
225+
"""Ensure Plex playback settings are provided."""
226+
227+
if not server.settings.plex_url or not server.settings.plex_token:
228+
raise RuntimeError("PLEX_URL and PLEX_TOKEN must be configured for playback")
229+
230+
231+
async def _get_plex_client() -> PlexServerClient:
232+
"""Return a cached Plex API client instance."""
233+
234+
_ensure_plex_configuration()
235+
async with server._plex_client_lock:
236+
if server._plex_client is None:
237+
base_url = str(server.settings.plex_url)
238+
239+
def _connect() -> PlexServerClient:
240+
return PlexServerClient(base_url, server.settings.plex_token)
241+
242+
server._plex_client = await asyncio.to_thread(_connect)
243+
return server._plex_client
244+
245+
246+
async def _fetch_plex_identity() -> dict[str, Any]:
247+
"""Fetch and cache Plex server identity details."""
248+
249+
if server._plex_identity is not None:
250+
return server._plex_identity
251+
plex_client = await _get_plex_client()
252+
machine_identifier = getattr(plex_client, "machineIdentifier", None)
253+
if not machine_identifier:
254+
raise RuntimeError("Unable to determine Plex server machine identifier")
255+
server._plex_identity = {"machineIdentifier": machine_identifier}
256+
return server._plex_identity
257+
258+
259+
async def _get_plex_players() -> list[dict[str, Any]]:
260+
"""Return Plex players available for playback commands."""
261+
262+
plex_client = await _get_plex_client()
263+
264+
def _load_clients() -> list[Any]:
265+
return list(plex_client.clients())
266+
267+
raw_clients = await asyncio.to_thread(_load_clients)
268+
aliases = server.settings.plex_player_aliases
269+
players: list[dict[str, Any]] = []
270+
271+
for client in raw_clients:
272+
provides_raw = getattr(client, "provides", "")
273+
if isinstance(provides_raw, str):
274+
provides_iterable = provides_raw.split(",")
275+
elif isinstance(provides_raw, (list, tuple, set)):
276+
provides_iterable = provides_raw
277+
else:
278+
provides_iterable = []
279+
provides = {
280+
str(capability).strip().lower()
281+
for capability in provides_iterable
282+
if str(capability).strip()
283+
}
284+
machine_id = getattr(client, "machineIdentifier", None)
285+
client_id = getattr(client, "clientIdentifier", None)
286+
address = getattr(client, "address", None)
287+
port = getattr(client, "port", None)
288+
name = getattr(client, "title", None) or getattr(client, "name", None)
289+
product = getattr(client, "product", None) or getattr(client, "device", None)
290+
291+
friendly_names: list[str] = []
292+
293+
def _collect_alias(identifier: str | None) -> None:
294+
if not identifier:
295+
return
296+
alias = aliases.get(identifier)
297+
if alias and alias not in friendly_names:
298+
friendly_names.append(alias)
299+
300+
_collect_alias(machine_id)
301+
_collect_alias(client_id)
302+
if machine_id and client_id:
303+
_collect_alias(f"{machine_id}:{client_id}")
304+
305+
display_name = (
306+
friendly_names[0]
307+
if friendly_names
308+
else name
309+
or product
310+
or machine_id
311+
or client_id
312+
or "Unknown player"
313+
)
314+
315+
players.append(
316+
{
317+
"name": name,
318+
"product": product,
319+
"display_name": display_name,
320+
"friendly_names": friendly_names,
321+
"machine_identifier": machine_id,
322+
"client_identifier": client_id,
323+
"address": address,
324+
"port": port,
325+
"provides": provides,
326+
"client": client,
327+
}
328+
)
329+
330+
return players
331+
332+
333+
def _match_player(query: str, players: Sequence[dict[str, Any]]) -> dict[str, Any]:
334+
"""Locate a Plex player by friendly name or identifier."""
335+
336+
normalized = query.strip().lower()
337+
for player in players:
338+
candidates = {
339+
player.get("display_name"),
340+
player.get("name"),
341+
player.get("product"),
342+
player.get("machine_identifier"),
343+
player.get("client_identifier"),
344+
}
345+
candidates.update(player.get("friendly_names", []))
346+
machine_id = player.get("machine_identifier")
347+
client_id = player.get("client_identifier")
348+
if machine_id and client_id:
349+
candidates.add(f"{machine_id}:{client_id}")
350+
for candidate in candidates:
351+
if candidate and candidate.lower() == normalized:
352+
return player
353+
raise ValueError(f"Player '{query}' not found")
354+
355+
356+
async def _start_playback(
357+
rating_key: str, player: dict[str, Any], offset_seconds: int
358+
) -> None:
359+
"""Send a playback command to the selected player."""
360+
361+
if "player" not in player.get("provides", set()):
362+
raise ValueError(
363+
f"Player '{player.get('display_name')}' cannot be controlled for playback"
364+
)
365+
plex_client = player.get("client")
366+
if plex_client is None:
367+
raise ValueError(
368+
f"Player '{player.get('display_name')}' is missing a Plex client instance"
369+
)
370+
371+
plex_server = await _get_plex_client()
372+
identity = await _fetch_plex_identity()
373+
offset_ms = max(offset_seconds, 0) * 1000
374+
375+
def _play() -> None:
376+
media = plex_server.fetchItem(f"/library/metadata/{rating_key}")
377+
plex_client.playMedia(
378+
media,
379+
offset=offset_ms,
380+
machineIdentifier=identity["machineIdentifier"],
381+
)
382+
383+
try:
384+
await asyncio.to_thread(_play)
385+
except PlexApiException as exc:
386+
raise RuntimeError("Failed to start playback via plexapi") from exc
387+
388+
389+
@server.tool("play-media")
390+
async def play_media(
391+
identifier: Annotated[
392+
str,
393+
Field(
394+
description="Rating key, IMDb/TMDb ID, or media title",
395+
examples=["49915", "tt8367814", "The Gentlemen"],
396+
),
397+
],
398+
player: Annotated[
399+
str,
400+
Field(
401+
description=(
402+
"Friendly name, machine identifier, or client identifier of the"
403+
" Plex player"
404+
),
405+
examples=["Living Room", "machine-123"],
406+
),
407+
],
408+
offset_seconds: Annotated[
409+
int | None,
410+
Field(
411+
description="Start playback at the specified offset (seconds)",
412+
ge=0,
413+
examples=[0],
414+
),
415+
] = 0,
416+
) -> dict[str, Any]:
417+
"""Play a media item on a specific Plex player."""
418+
419+
media = await _get_media_data(identifier)
420+
plex_info = media.get("plex") or {}
421+
rating_key = plex_info.get("rating_key")
422+
if not rating_key:
423+
raise ValueError("Media item is missing a Plex rating key")
424+
425+
players = await _get_plex_players()
426+
target = _match_player(player, players)
427+
await _start_playback(str(rating_key), target, offset_seconds or 0)
428+
429+
return {
430+
"player": target.get("display_name"),
431+
"rating_key": str(rating_key),
432+
"title": plex_info.get("title") or media.get("title"),
433+
"offset_seconds": offset_seconds or 0,
434+
}
435+
436+
202437
@server.tool("get-media")
203438
async def get_media(
204439
identifier: Annotated[

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "mcp-plex"
7-
version = "0.26.44"
7+
version = "0.26.46"
88

99
description = "Plex-Oriented Model Context Protocol Server"
1010
requires-python = ">=3.11,<3.13"

tests/test_config.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22
from pydantic import ValidationError
3+
from pydantic_settings import SettingsError
34

45
from mcp_plex.config import Settings
56

@@ -14,3 +15,21 @@ def test_settings_invalid_cache_size(monkeypatch):
1415
monkeypatch.setenv("CACHE_SIZE", "notint")
1516
with pytest.raises(ValidationError):
1617
Settings()
18+
19+
20+
def test_settings_player_aliases(monkeypatch):
21+
monkeypatch.setenv(
22+
"PLEX_PLAYER_ALIASES",
23+
"{\"machine-1\": \"Living Room\", \"client-2\": \"Bedroom\"}",
24+
)
25+
settings = Settings()
26+
assert settings.plex_player_aliases == {
27+
"machine-1": "Living Room",
28+
"client-2": "Bedroom",
29+
}
30+
31+
32+
def test_settings_invalid_aliases(monkeypatch):
33+
monkeypatch.setenv("PLEX_PLAYER_ALIASES", "not-json")
34+
with pytest.raises(SettingsError):
35+
Settings()

0 commit comments

Comments
 (0)