Skip to content

Commit c377a54

Browse files
authored
Add fuzzy alias matching for Plex players (#77)
* feat(server): add fuzzy player alias matching * fix(server): improve player matching diagnostics
1 parent afb6ccc commit c377a54

File tree

7 files changed

+128
-21
lines changed

7 files changed

+128
-21
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.47"
3+
version = "0.26.49"
44
requires-python = ">=3.11,<3.13"
55
dependencies = [
66
"fastmcp>=2.11.2",

mcp_plex/config.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,13 @@ class Settings(BaseSettings):
3939
use_reranker: bool = Field(default=True, validation_alias="USE_RERANKER")
4040
plex_url: AnyHttpUrl | None = Field(default=None, validation_alias="PLEX_URL")
4141
plex_token: str | None = Field(default=None, validation_alias="PLEX_TOKEN")
42-
plex_player_aliases: dict[str, str] = Field(
42+
plex_player_aliases: dict[str, list[str]] = Field(
4343
default_factory=dict, validation_alias="PLEX_PLAYER_ALIASES"
4444
)
4545

4646
@field_validator("plex_player_aliases", mode="before")
4747
@classmethod
48-
def _parse_aliases(cls, value: object) -> dict[str, str]:
48+
def _parse_aliases(cls, value: object) -> dict[str, list[str]]:
4949
if value in (None, ""):
5050
return {}
5151
if isinstance(value, str):
@@ -54,7 +54,25 @@ def _parse_aliases(cls, value: object) -> dict[str, str]:
5454
except json.JSONDecodeError as exc:
5555
raise ValueError("PLEX_PLAYER_ALIASES must be valid JSON") from exc
5656
if isinstance(value, dict):
57-
return {str(k): str(v) for k, v in value.items()}
57+
parsed: dict[str, list[str]] = {}
58+
for raw_key, raw_aliases in value.items():
59+
key = str(raw_key)
60+
if isinstance(raw_aliases, str):
61+
aliases = [raw_aliases]
62+
elif isinstance(raw_aliases, (list, tuple, set)):
63+
aliases = [str(alias) for alias in raw_aliases]
64+
else:
65+
raise TypeError(
66+
"PLEX_PLAYER_ALIASES values must be strings or iterables of strings"
67+
)
68+
normalized = []
69+
for alias in aliases:
70+
alias_str = str(alias).strip()
71+
if alias_str and alias_str not in normalized:
72+
normalized.append(alias_str)
73+
if normalized:
74+
parsed[key] = normalized
75+
return parsed
5876
raise TypeError("PLEX_PLAYER_ALIASES must be a mapping or JSON object")
5977

6078
model_config = SettingsConfigDict(case_sensitive=False)

mcp_plex/server.py

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
from starlette.requests import Request
2525
from starlette.responses import JSONResponse, PlainTextResponse, Response
2626

27+
from rapidfuzz import fuzz, process
28+
2729
from .cache import MediaCache
2830
from .config import Settings
2931

@@ -293,9 +295,9 @@ def _load_clients() -> list[Any]:
293295
def _collect_alias(identifier: str | None) -> None:
294296
if not identifier:
295297
return
296-
alias = aliases.get(identifier)
297-
if alias and alias not in friendly_names:
298-
friendly_names.append(alias)
298+
for alias in aliases.get(identifier, []):
299+
if alias and alias not in friendly_names:
300+
friendly_names.append(alias)
299301

300302
_collect_alias(machine_id)
301303
_collect_alias(client_id)
@@ -330,26 +332,59 @@ def _collect_alias(identifier: str | None) -> None:
330332
return players
331333

332334

335+
_FUZZY_MATCH_THRESHOLD = 70
336+
337+
333338
def _match_player(query: str, players: Sequence[dict[str, Any]]) -> dict[str, Any]:
334339
"""Locate a Plex player by friendly name or identifier."""
335340

336-
normalized = query.strip().lower()
341+
normalized_query = query.strip()
342+
normalized = normalized_query.lower()
343+
if not normalized_query:
344+
raise ValueError(f"Player '{query}' not found")
345+
346+
candidate_entries: list[tuple[str, str, dict[str, Any]]] = []
337347
for player in players:
338-
candidates = {
348+
candidate_strings = {
339349
player.get("display_name"),
340350
player.get("name"),
341351
player.get("product"),
342352
player.get("machine_identifier"),
343353
player.get("client_identifier"),
344354
}
345-
candidates.update(player.get("friendly_names", []))
355+
candidate_strings.update(player.get("friendly_names", []))
346356
machine_id = player.get("machine_identifier")
347357
client_id = player.get("client_identifier")
348358
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:
359+
candidate_strings.add(f"{machine_id}:{client_id}")
360+
for candidate in candidate_strings:
361+
if not candidate:
362+
continue
363+
candidate_str = str(candidate).strip()
364+
if not candidate_str:
365+
continue
366+
candidate_lower = candidate_str.lower()
367+
candidate_entries.append((candidate_str, candidate_lower, player))
368+
if candidate_lower == normalized:
352369
return player
370+
def _process_choice(
371+
choice: str | tuple[str, str, dict[str, Any]]
372+
) -> str:
373+
if isinstance(choice, tuple):
374+
return choice[1]
375+
return str(choice).strip().lower()
376+
377+
match = process.extractOne(
378+
normalized_query,
379+
candidate_entries,
380+
scorer=fuzz.WRatio,
381+
processor=_process_choice,
382+
score_cutoff=_FUZZY_MATCH_THRESHOLD,
383+
)
384+
if match:
385+
choice, _, _ = match
386+
if choice is not None:
387+
return choice[2]
353388
raise ValueError(f"Player '{query}' not found")
354389

355390

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.47"
7+
version = "0.26.49"
88

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

tests/test_config.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@ def test_settings_invalid_cache_size(monkeypatch):
2020
def test_settings_player_aliases(monkeypatch):
2121
monkeypatch.setenv(
2222
"PLEX_PLAYER_ALIASES",
23-
"{\"machine-1\": \"Living Room\", \"client-2\": \"Bedroom\"}",
23+
(
24+
"{\"machine-1\": [\"Living Room TV\", \"Living Room\"],"
25+
" \"client-2\": \"Bedroom\"}"
26+
),
2427
)
2528
settings = Settings()
2629
assert settings.plex_player_aliases == {
27-
"machine-1": "Living Room",
28-
"client-2": "Bedroom",
30+
"machine-1": ["Living Room TV", "Living Room"],
31+
"client-2": ["Bedroom"],
2932
}
3033

3134

tests/test_server.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010

1111
import builtins
1212
from typing import Any
13-
from qdrant_client import models
13+
1414
import pytest
15+
from qdrant_client import models
1516
from starlette.testclient import TestClient
1617

1718
from mcp_plex import loader
19+
from mcp_plex import server as server_module
1820

1921

2022
@contextmanager
@@ -195,9 +197,9 @@ def test_play_media_with_alias(monkeypatch):
195197
"PLEX_PLAYER_ALIASES",
196198
json.dumps(
197199
{
198-
"machine-123": "Living Room",
200+
"machine-123": ["Living Room", "Movie Room"],
199201
"client-abc": "Living Room",
200-
"machine-123:client-abc": "Living Room",
202+
"machine-123:client-abc": ["Living Room"],
201203
}
202204
),
203205
)
@@ -292,6 +294,55 @@ def fetchItem(self, key: str) -> Any:
292294
)
293295

294296

297+
def test_match_player_fuzzy_alias_resolution():
298+
players = [
299+
{
300+
"display_name": "Movie Room TV",
301+
"name": "Plex for Apple TV",
302+
"product": "Apple TV",
303+
"machine_identifier": "machine-1",
304+
"client_identifier": "client-1",
305+
"friendly_names": ["Movie Room", "Movie Room TV"],
306+
},
307+
{
308+
"display_name": "Bedroom TV",
309+
"name": "Plex for Roku",
310+
"product": "Roku",
311+
"machine_identifier": "machine-2",
312+
"client_identifier": "client-2",
313+
"friendly_names": ["Bedroom"],
314+
},
315+
]
316+
317+
matched = server_module._match_player("movie rm", players)
318+
assert matched is players[0]
319+
320+
321+
def test_match_player_unknown_raises():
322+
players = [
323+
{
324+
"display_name": "Bedroom TV",
325+
"name": "Plex for Roku",
326+
"product": "Roku",
327+
"machine_identifier": "machine-2",
328+
"client_identifier": "client-2",
329+
"friendly_names": ["Bedroom"],
330+
}
331+
]
332+
333+
with pytest.raises(ValueError):
334+
server_module._match_player("Kitchen", players)
335+
336+
337+
def test_match_player_whitespace_query_preserves_original_input():
338+
query = " "
339+
340+
with pytest.raises(ValueError) as exc:
341+
server_module._match_player(query, [])
342+
343+
assert str(exc.value) == "Player ' ' not found"
344+
345+
295346
def test_reranker_import_failure(monkeypatch):
296347
monkeypatch.setenv("USE_RERANKER", "1")
297348
orig_import = builtins.__import__

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)