33
44import argparse
55import asyncio
6+ import importlib .metadata
67import inspect
78import json
89import os
10+ import uuid
911from typing import Annotated , Any , Callable , Sequence
1012
1113from fastapi import FastAPI
1416from fastmcp .prompts import Message
1517from fastmcp .server import FastMCP
1618from fastmcp .server .context import Context as FastMCPContext
19+ from plexapi .exceptions import PlexApiException
20+ from plexapi .server import PlexServer as PlexServerClient
1721from pydantic import BaseModel , Field , create_model
1822from qdrant_client import models
1923from qdrant_client .async_qdrant_client import AsyncQdrantClient
3236settings = Settings ()
3337
3438
39+ try :
40+ __version__ = importlib .metadata .version ("mcp-plex" )
41+ except importlib .metadata .PackageNotFoundError :
42+ __version__ = "0.0.0"
43+
44+
3545class 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
99121server = 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" )
203438async def get_media (
204439 identifier : Annotated [
0 commit comments