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
2 changes: 1 addition & 1 deletion docker/pyproject.deps.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "mcp-plex"
version = "0.26.47"
version = "0.26.49"
requires-python = ">=3.11,<3.13"
dependencies = [
"fastmcp>=2.11.2",
Expand Down
24 changes: 21 additions & 3 deletions mcp_plex/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,13 @@ class Settings(BaseSettings):
use_reranker: bool = Field(default=True, validation_alias="USE_RERANKER")
plex_url: AnyHttpUrl | None = Field(default=None, validation_alias="PLEX_URL")
plex_token: str | None = Field(default=None, validation_alias="PLEX_TOKEN")
plex_player_aliases: dict[str, str] = Field(
plex_player_aliases: dict[str, list[str]] = Field(
default_factory=dict, validation_alias="PLEX_PLAYER_ALIASES"
)

@field_validator("plex_player_aliases", mode="before")
@classmethod
def _parse_aliases(cls, value: object) -> dict[str, str]:
def _parse_aliases(cls, value: object) -> dict[str, list[str]]:
if value in (None, ""):
return {}
if isinstance(value, str):
Expand All @@ -54,7 +54,25 @@ def _parse_aliases(cls, value: object) -> dict[str, str]:
except json.JSONDecodeError as exc:
raise ValueError("PLEX_PLAYER_ALIASES must be valid JSON") from exc
if isinstance(value, dict):
return {str(k): str(v) for k, v in value.items()}
parsed: dict[str, list[str]] = {}
for raw_key, raw_aliases in value.items():
key = str(raw_key)
if isinstance(raw_aliases, str):
aliases = [raw_aliases]
elif isinstance(raw_aliases, (list, tuple, set)):
aliases = [str(alias) for alias in raw_aliases]
else:
raise TypeError(
"PLEX_PLAYER_ALIASES values must be strings or iterables of strings"
)
normalized = []
for alias in aliases:
alias_str = str(alias).strip()
if alias_str and alias_str not in normalized:
normalized.append(alias_str)
if normalized:
parsed[key] = normalized
return parsed
raise TypeError("PLEX_PLAYER_ALIASES must be a mapping or JSON object")

model_config = SettingsConfigDict(case_sensitive=False)
53 changes: 44 additions & 9 deletions mcp_plex/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
from starlette.requests import Request
from starlette.responses import JSONResponse, PlainTextResponse, Response

from rapidfuzz import fuzz, process

from .cache import MediaCache
from .config import Settings

Expand Down Expand Up @@ -293,9 +295,9 @@ def _load_clients() -> list[Any]:
def _collect_alias(identifier: str | None) -> None:
if not identifier:
return
alias = aliases.get(identifier)
if alias and alias not in friendly_names:
friendly_names.append(alias)
for alias in aliases.get(identifier, []):
if alias and alias not in friendly_names:
friendly_names.append(alias)

_collect_alias(machine_id)
_collect_alias(client_id)
Expand Down Expand Up @@ -330,26 +332,59 @@ def _collect_alias(identifier: str | None) -> None:
return players


_FUZZY_MATCH_THRESHOLD = 70


def _match_player(query: str, players: Sequence[dict[str, Any]]) -> dict[str, Any]:
"""Locate a Plex player by friendly name or identifier."""

normalized = query.strip().lower()
normalized_query = query.strip()
normalized = normalized_query.lower()
if not normalized_query:
raise ValueError(f"Player '{query}' not found")

candidate_entries: list[tuple[str, str, dict[str, Any]]] = []
for player in players:
candidates = {
candidate_strings = {
player.get("display_name"),
player.get("name"),
player.get("product"),
player.get("machine_identifier"),
player.get("client_identifier"),
}
candidates.update(player.get("friendly_names", []))
candidate_strings.update(player.get("friendly_names", []))
machine_id = player.get("machine_identifier")
client_id = player.get("client_identifier")
if machine_id and client_id:
candidates.add(f"{machine_id}:{client_id}")
for candidate in candidates:
if candidate and candidate.lower() == normalized:
candidate_strings.add(f"{machine_id}:{client_id}")
for candidate in candidate_strings:
if not candidate:
continue
candidate_str = str(candidate).strip()
if not candidate_str:
continue
candidate_lower = candidate_str.lower()
candidate_entries.append((candidate_str, candidate_lower, player))
if candidate_lower == normalized:
return player
def _process_choice(
choice: str | tuple[str, str, dict[str, Any]]
) -> str:
if isinstance(choice, tuple):
return choice[1]
return str(choice).strip().lower()

match = process.extractOne(
normalized_query,
candidate_entries,
scorer=fuzz.WRatio,
processor=_process_choice,
score_cutoff=_FUZZY_MATCH_THRESHOLD,
)
if match:
choice, _, _ = match
if choice is not None:
return choice[2]
raise ValueError(f"Player '{query}' not found")


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "mcp-plex"
version = "0.26.47"
version = "0.26.49"

description = "Plex-Oriented Model Context Protocol Server"
requires-python = ">=3.11,<3.13"
Expand Down
9 changes: 6 additions & 3 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@ def test_settings_invalid_cache_size(monkeypatch):
def test_settings_player_aliases(monkeypatch):
monkeypatch.setenv(
"PLEX_PLAYER_ALIASES",
"{\"machine-1\": \"Living Room\", \"client-2\": \"Bedroom\"}",
(
"{\"machine-1\": [\"Living Room TV\", \"Living Room\"],"
" \"client-2\": \"Bedroom\"}"
),
)
settings = Settings()
assert settings.plex_player_aliases == {
"machine-1": "Living Room",
"client-2": "Bedroom",
"machine-1": ["Living Room TV", "Living Room"],
"client-2": ["Bedroom"],
}


Expand Down
57 changes: 54 additions & 3 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@

import builtins
from typing import Any
from qdrant_client import models

import pytest
from qdrant_client import models
from starlette.testclient import TestClient

from mcp_plex import loader
from mcp_plex import server as server_module


@contextmanager
Expand Down Expand Up @@ -195,9 +197,9 @@ def test_play_media_with_alias(monkeypatch):
"PLEX_PLAYER_ALIASES",
json.dumps(
{
"machine-123": "Living Room",
"machine-123": ["Living Room", "Movie Room"],
"client-abc": "Living Room",
"machine-123:client-abc": "Living Room",
"machine-123:client-abc": ["Living Room"],
}
),
)
Expand Down Expand Up @@ -292,6 +294,55 @@ def fetchItem(self, key: str) -> Any:
)


def test_match_player_fuzzy_alias_resolution():
players = [
{
"display_name": "Movie Room TV",
"name": "Plex for Apple TV",
"product": "Apple TV",
"machine_identifier": "machine-1",
"client_identifier": "client-1",
"friendly_names": ["Movie Room", "Movie Room TV"],
},
{
"display_name": "Bedroom TV",
"name": "Plex for Roku",
"product": "Roku",
"machine_identifier": "machine-2",
"client_identifier": "client-2",
"friendly_names": ["Bedroom"],
},
]

matched = server_module._match_player("movie rm", players)
assert matched is players[0]


def test_match_player_unknown_raises():
players = [
{
"display_name": "Bedroom TV",
"name": "Plex for Roku",
"product": "Roku",
"machine_identifier": "machine-2",
"client_identifier": "client-2",
"friendly_names": ["Bedroom"],
}
]

with pytest.raises(ValueError):
server_module._match_player("Kitchen", players)


def test_match_player_whitespace_query_preserves_original_input():
query = " "

with pytest.raises(ValueError) as exc:
server_module._match_player(query, [])

assert str(exc.value) == "Player ' ' not found"


def test_reranker_import_failure(monkeypatch):
monkeypatch.setenv("USE_RERANKER", "1")
orig_import = builtins.__import__
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.