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 = "1.0.23"
version = "1.0.24"
requires-python = ">=3.11,<3.13"
dependencies = [
"fastmcp>=2.11.2",
Expand Down
4 changes: 4 additions & 0 deletions mcp_plex/server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@
- The cross-encoder reranker is loaded lazily via a `PlexServer` property so models are only downloaded when reranking is enabled and available.
- Media payload and artwork caching are centralized in a `MediaCache` attached to `PlexServer` for consistent cache management across endpoints.

## API Guidelines
- New FastMCP/FastAPI endpoints must declare typed request payload and response models instead of returning or accepting untyped dictionaries.
- Introducing new `Any` or `object` annotations requires an inline comment that justifies the loosened typing and references the relevant design constraint.

4 changes: 2 additions & 2 deletions mcp_plex/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

from ..common.cache import MediaCache
from ..common.types import JSONValue
from .config import Settings
from .config import PlexPlayerAliasMap, Settings


class PlexTag(TypedDict, total=False):
Expand Down Expand Up @@ -456,7 +456,7 @@ def _load_clients() -> list[Any]:
return list(plex_client.clients())

raw_clients = await asyncio.to_thread(_load_clients)
aliases = server.settings.plex_player_aliases
aliases: PlexPlayerAliasMap = server.settings.plex_player_aliases
players: list[PlexPlayerMetadata] = []

for client in raw_clients:
Expand Down
97 changes: 74 additions & 23 deletions mcp_plex/server/config.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
from __future__ import annotations

import json
from collections.abc import Mapping, Sequence
from typing import Any

from pydantic import AnyHttpUrl, Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

PlexPlayerAliasMap = dict[str, tuple[str, ...]]
RawAliasValue = str | Sequence[Any]
RawAliasItems = list[tuple[Any, RawAliasValue]]
RawAliasMapping = Mapping[str, RawAliasValue]
RawAliases = str | RawAliasMapping | RawAliasItems | None


class Settings(BaseSettings):
"""Application configuration settings."""
Expand Down Expand Up @@ -39,40 +47,83 @@ 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, list[str]] = Field(
plex_player_aliases: PlexPlayerAliasMap = 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, list[str]]:
def _parse_aliases(cls, value: RawAliases) -> PlexPlayerAliasMap:
if value in (None, ""):
return {}

if isinstance(value, str):
try:
value = json.loads(value)
loaded = json.loads(value)
except json.JSONDecodeError as exc:
raise ValueError("PLEX_PLAYER_ALIASES must be valid JSON") from exc
if isinstance(value, dict):
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"
if not isinstance(loaded, (Mapping, Sequence)):
raise ValueError(
"PLEX_PLAYER_ALIASES JSON must decode to a mapping or sequence"
)
value = loaded

if isinstance(value, Mapping):
items = list(value.items())
elif isinstance(value, Sequence):
items = cls._items_from_sequence(value)
else:
raise ValueError("PLEX_PLAYER_ALIASES must be a mapping or sequence")

parsed: PlexPlayerAliasMap = {}
for raw_key, raw_aliases in items:
key = str(raw_key).strip()
if not key:
continue
normalized = cls._normalize_alias_values(raw_aliases)
if normalized:
parsed[key] = tuple(normalized)
return parsed

@staticmethod
def _items_from_sequence(value: Sequence[Any]) -> RawAliasItems:
items: RawAliasItems = []
for entry in value:
if isinstance(entry, Mapping):
items.extend(entry.items())
elif isinstance(entry, Sequence) and not isinstance(entry, (str, bytes, bytearray)):
entry_list = list(entry)
if len(entry_list) != 2:
raise ValueError(
"PLEX_PLAYER_ALIASES sequence entries must contain exactly two items"
)
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")
items.append((entry_list[0], entry_list[1]))
else:
raise ValueError(
"PLEX_PLAYER_ALIASES sequence entries must be mappings or 2-tuples"
)
return items

@staticmethod
def _normalize_alias_values(raw_aliases: RawAliasValue) -> list[str]:
if isinstance(raw_aliases, str):
values: Sequence[Any] = [raw_aliases]
elif isinstance(raw_aliases, Sequence) and not isinstance(
raw_aliases, (str, bytes, bytearray)
):
values = raw_aliases
else:
raise ValueError(
"PLEX_PLAYER_ALIASES values must be strings or iterables of strings"
)

normalized: list[str] = []
for alias in values:
if alias is None:
continue
alias_str = str(alias).strip()
if alias_str and alias_str not in normalized:
normalized.append(alias_str)
return normalized

model_config = SettingsConfigDict(case_sensitive=False)
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 = "1.0.23"
version = "1.0.24"

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


def test_settings_invalid_aliases(monkeypatch):
monkeypatch.setenv("PLEX_PLAYER_ALIASES", "not-json")
with pytest.raises(SettingsError):
Settings()


def test_settings_aliases_from_mapping():
settings = Settings.model_validate(
{
"PLEX_PLAYER_ALIASES": {
"machine-1": [" Living Room ", "Living Room"],
"client-2": "Bedroom",
}
}
)
assert settings.plex_player_aliases == {
"machine-1": ("Living Room",),
"client-2": ("Bedroom",),
}


def test_settings_aliases_from_sequence():
settings = Settings.model_validate(
{
"PLEX_PLAYER_ALIASES": [
("machine-1", ("Living Room", "Living Room TV")),
{"client-2": ["Bedroom", None]},
]
}
)
assert settings.plex_player_aliases == {
"machine-1": ("Living Room", "Living Room TV"),
"client-2": ("Bedroom",),
}


def test_settings_invalid_alias_sequence():
with pytest.raises(ValidationError):
Settings.model_validate(
{
"PLEX_PLAYER_ALIASES": [("machine-1", "Living Room", "Extra")]
}
)
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.