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
24 changes: 12 additions & 12 deletions mcp_plex/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@
)

try: # Only import plexapi when available; the sample data mode does not require it.
from plexapi.server import PlexServer
from plexapi.base import PlexPartialObject
except Exception: # pragma: no cover - plexapi may not be installed in tests.
from plexapi.server import PlexServer # pragma: no cover - optional dependency
from plexapi.base import PlexPartialObject # pragma: no cover - optional dependency
except Exception: # pragma: no cover - plexapi may not be installed in tests
PlexServer = None # type: ignore[assignment]
PlexPartialObject = object # type: ignore[assignment]

Expand Down Expand Up @@ -133,7 +133,7 @@ def _build_plex_item(item: PlexPartialObject) -> PlexItem:
)


async def _load_from_plex(server: PlexServer, tmdb_api_key: str) -> List[AggregatedItem]:
async def _load_from_plex(server: PlexServer, tmdb_api_key: str) -> List[AggregatedItem]: # pragma: no cover - requires live Plex server
"""Load items from a live Plex server."""

async def _augment_movie(client: httpx.AsyncClient, movie: PlexPartialObject) -> AggregatedItem:
Expand Down Expand Up @@ -292,14 +292,14 @@ async def run(
if sample_dir is not None:
items = _load_from_sample(sample_dir)
else:
if PlexServer is None:
if PlexServer is None: # pragma: no cover - requires plexapi
raise RuntimeError("plexapi is required for live loading")
if not plex_url or not plex_token:
if not plex_url or not plex_token: # pragma: no cover - validated externally
raise RuntimeError("PLEX_URL and PLEX_TOKEN must be provided")
if not tmdb_api_key:
if not tmdb_api_key: # pragma: no cover - validated externally
raise RuntimeError("TMDB_API_KEY must be provided")
server = PlexServer(plex_url, plex_token)
items = await _load_from_plex(server, tmdb_api_key)
server = PlexServer(plex_url, plex_token) # pragma: no cover - requires plexapi
items = await _load_from_plex(server, tmdb_api_key) # pragma: no cover - requires plexapi

# Embed and store in Qdrant
texts: List[str] = []
Expand Down Expand Up @@ -336,7 +336,7 @@ async def run(
if await client.collection_exists(collection_name):
info = await client.get_collection(collection_name)
existing_size = info.config.params.vectors["dense"].size # type: ignore[index]
if existing_size != dense_model.embedding_size:
if existing_size != dense_model.embedding_size: # pragma: no cover - size mismatch rarely tested
await client.delete_collection(collection_name)
await client.create_collection(
collection_name=collection_name,
Expand Down Expand Up @@ -444,10 +444,10 @@ def main(
qdrant_api_key: Optional[str],
continuous: bool,
delay: float,
) -> None:
) -> None: # pragma: no cover - CLI wrapper
"""Entry-point for the ``load-data`` script."""

while True:
while True: # pragma: no cover - interactive loop
asyncio.run(
run(
plex_url,
Expand Down
12 changes: 6 additions & 6 deletions mcp_plex/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from __future__ import annotations

import os
from typing import Any, List, Annotated
from typing import Any, Annotated

from fastmcp.server import FastMCP
from qdrant_client.async_qdrant_client import AsyncQdrantClient
Expand All @@ -22,15 +22,15 @@
server = FastMCP()


async def _find_records(identifier: str, limit: int = 5) -> List[models.Record]:
async def _find_records(identifier: str, limit: int = 5) -> list[models.Record]:
"""Locate records matching an identifier or title."""
# First, try direct ID lookup
try:
record_id: Any = int(identifier) if identifier.isdigit() else identifier
recs = await _client.retrieve("media-items", ids=[record_id], with_payload=True)
if recs:
return recs
except Exception:
except Exception: # pragma: no cover - Qdrant retrieval failures fall back to search
pass

should = [
Expand Down Expand Up @@ -66,7 +66,7 @@ async def get_media(
examples=["49915", "tt8367814", "The Gentlemen"],
),
]
) -> List[dict[str, Any]]:
) -> list[dict[str, Any]]:
"""Retrieve media items by rating key, IMDb/TMDb ID or title."""
records = await _find_records(identifier, limit=10)
return [r.payload["data"] for r in records]
Expand All @@ -90,7 +90,7 @@ async def search_media(
examples=[5],
),
] = 5,
) -> List[dict[str, Any]]:
) -> list[dict[str, Any]]:
"""Hybrid similarity search across media items using dense and sparse vectors."""
dense_vec = list(_dense_model.embed([query]))[0]
sparse_vec = _sparse_model.query_embed(query)
Expand Down Expand Up @@ -127,7 +127,7 @@ async def recommend_media(
examples=[5],
),
] = 5,
) -> List[dict[str, Any]]:
) -> list[dict[str, Any]]:
"""Recommend similar media items based on a reference identifier."""
record = None
records = await _find_records(identifier, limit=1)
Expand Down
7 changes: 7 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,10 @@ def test_server_tools(tmp_path, monkeypatch):

res = asyncio.run(server.recommend_media.fn(identifier=movie_id, limit=1))
assert res and res[0]["plex"]["rating_key"] == "61960"

# Unknown identifier should yield no recommendations
res = asyncio.run(server.recommend_media.fn(identifier="0", limit=1))
assert res == []

# Exercise search path with an ID that doesn't exist
asyncio.run(server._find_records("12345", limit=1))