diff --git a/docker/pyproject.deps.toml b/docker/pyproject.deps.toml index ac30729..ac22583 100644 --- a/docker/pyproject.deps.toml +++ b/docker/pyproject.deps.toml @@ -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", diff --git a/mcp_plex/config.py b/mcp_plex/config.py index 5d31205..c055081 100644 --- a/mcp_plex/config.py +++ b/mcp_plex/config.py @@ -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): @@ -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) diff --git a/mcp_plex/server.py b/mcp_plex/server.py index 222091b..d4a88b1 100644 --- a/mcp_plex/server.py +++ b/mcp_plex/server.py @@ -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 @@ -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) @@ -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") diff --git a/pyproject.toml b/pyproject.toml index 74ccba1..0fd7093 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_config.py b/tests/test_config.py index ae7c9e9..3b60b4a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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"], } diff --git a/tests/test_server.py b/tests/test_server.py index 41818ac..6714628 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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 @@ -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"], } ), ) @@ -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__ diff --git a/uv.lock b/uv.lock index 8898194..4b5e5b0 100644 --- a/uv.lock +++ b/uv.lock @@ -730,7 +730,7 @@ wheels = [ [[package]] name = "mcp-plex" -version = "0.26.47" +version = "0.26.49" source = { editable = "." } dependencies = [ { name = "fastapi" },