diff --git a/docker/pyproject.deps.toml b/docker/pyproject.deps.toml index 29c15d2..d922712 100644 --- a/docker/pyproject.deps.toml +++ b/docker/pyproject.deps.toml @@ -1,6 +1,6 @@ [project] name = "mcp-plex" -version = "0.26.41" +version = "0.26.43" requires-python = ">=3.11,<3.13" dependencies = [ "fastmcp>=2.11.2", diff --git a/mcp_plex/__init__.py b/mcp_plex/__init__.py index ad1ab0c..32b9324 100644 --- a/mcp_plex/__init__.py +++ b/mcp_plex/__init__.py @@ -1,3 +1,13 @@ """mcp-plex package.""" +from __future__ import annotations + +import warnings + +warnings.filterwarnings( + "ignore", + message=".*'mcp_plex\\.loader' found in sys.modules after import of package 'mcp_plex'.*", + category=RuntimeWarning, +) + __all__ = [] diff --git a/mcp_plex/config.py b/mcp_plex/config.py index b1b50ce..4201be8 100644 --- a/mcp_plex/config.py +++ b/mcp_plex/config.py @@ -7,20 +7,33 @@ class Settings(BaseSettings): """Application configuration settings.""" - qdrant_url: str | None = Field(default=None, env="QDRANT_URL") - qdrant_api_key: str | None = Field(default=None, env="QDRANT_API_KEY") - qdrant_host: str | None = Field(default=None, env="QDRANT_HOST") - qdrant_port: int = Field(default=6333, env="QDRANT_PORT") - qdrant_grpc_port: int = Field(default=6334, env="QDRANT_GRPC_PORT") - qdrant_prefer_grpc: bool = Field(default=False, env="QDRANT_PREFER_GRPC") - qdrant_https: bool | None = Field(default=None, env="QDRANT_HTTPS") + qdrant_url: str | None = Field( + default=None, validation_alias="QDRANT_URL" + ) + qdrant_api_key: str | None = Field( + default=None, validation_alias="QDRANT_API_KEY" + ) + qdrant_host: str | None = Field( + default=None, validation_alias="QDRANT_HOST" + ) + qdrant_port: int = Field(default=6333, validation_alias="QDRANT_PORT") + qdrant_grpc_port: int = Field( + default=6334, validation_alias="QDRANT_GRPC_PORT" + ) + qdrant_prefer_grpc: bool = Field( + default=False, validation_alias="QDRANT_PREFER_GRPC" + ) + qdrant_https: bool | None = Field( + default=None, validation_alias="QDRANT_HTTPS" + ) dense_model: str = Field( - default="BAAI/bge-small-en-v1.5", env="DENSE_MODEL" + default="BAAI/bge-small-en-v1.5", validation_alias="DENSE_MODEL" ) sparse_model: str = Field( - default="Qdrant/bm42-all-minilm-l6-v2-attentions", env="SPARSE_MODEL" + default="Qdrant/bm42-all-minilm-l6-v2-attentions", + validation_alias="SPARSE_MODEL", ) - cache_size: int = Field(default=128, env="CACHE_SIZE") - use_reranker: bool = Field(default=True, env="USE_RERANKER") + cache_size: int = Field(default=128, validation_alias="CACHE_SIZE") + use_reranker: bool = Field(default=True, validation_alias="USE_RERANKER") model_config = SettingsConfigDict(case_sensitive=False) diff --git a/mcp_plex/loader.py b/mcp_plex/loader.py index 02964a4..832fb57 100644 --- a/mcp_plex/loader.py +++ b/mcp_plex/loader.py @@ -2,9 +2,11 @@ from __future__ import annotations import asyncio +import inspect import json import logging import sys +import warnings from collections import deque from pathlib import Path from typing import AsyncIterator, Awaitable, Iterable, List, Optional, Sequence, TypeVar @@ -39,6 +41,12 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +warnings.filterwarnings( + "ignore", + message=".*'mcp_plex\\.loader' found in sys.modules after import of package 'mcp_plex'.*", + category=RuntimeWarning, +) + T = TypeVar("T") _imdb_cache: IMDbCache | None = None @@ -59,6 +67,15 @@ def _require_positive(value: int, *, name: str) -> int: return value +def _is_local_qdrant(client: AsyncQdrantClient) -> bool: + """Return ``True`` if *client* targets an in-process Qdrant instance.""" + + inner = getattr(client, "_client", None) + return bool(inner) and inner.__class__.__module__.startswith( + "qdrant_client.local" + ) + + class _IMDbRetryQueue(asyncio.Queue[str]): """Queue that tracks items in a deque for safe serialization.""" @@ -102,12 +119,24 @@ def snapshot(self) -> list[str]: } +def _close_coroutines(tasks: Sequence[Awaitable[object]]) -> None: + """Close coroutine objects to avoid unawaited warnings.""" + + for task in tasks: + if inspect.iscoroutine(task): + task.close() + + async def _iter_gather_in_batches( tasks: Sequence[Awaitable[T]], batch_size: int ) -> AsyncIterator[T]: """Yield results from awaitable tasks in fixed-size batches.""" - _require_positive(batch_size, name="batch_size") + try: + _require_positive(batch_size, name="batch_size") + except ValueError: + _close_coroutines(tasks) + raise total = len(tasks) for i in range(0, total, batch_size): @@ -314,112 +343,57 @@ async def _ensure_collection( if not created_collection: return + suppress_payload_warning = _is_local_qdrant(client) + + async def _create_index( + field_name: str, + field_schema: models.PayloadSchemaType | models.TextIndexParams, + ) -> None: + if suppress_payload_warning: + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="Payload indexes have no effect in the local Qdrant.*", + category=UserWarning, + ) + await client.create_payload_index( + collection_name=collection_name, + field_name=field_name, + field_schema=field_schema, + ) + else: + await client.create_payload_index( + collection_name=collection_name, + field_name=field_name, + field_schema=field_schema, + ) + text_index = models.TextIndexParams( type=models.PayloadSchemaType.TEXT, tokenizer=models.TokenizerType.WORD, min_token_len=2, lowercase=True, ) - await client.create_payload_index( - collection_name=collection_name, - field_name="title", - field_schema=text_index, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="type", - field_schema=models.PayloadSchemaType.KEYWORD, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="year", - field_schema=models.PayloadSchemaType.INTEGER, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="added_at", - field_schema=models.PayloadSchemaType.INTEGER, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="actors", - field_schema=models.PayloadSchemaType.KEYWORD, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="directors", - field_schema=models.PayloadSchemaType.KEYWORD, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="writers", - field_schema=models.PayloadSchemaType.KEYWORD, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="genres", - field_schema=models.PayloadSchemaType.KEYWORD, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="show_title", - field_schema=models.PayloadSchemaType.KEYWORD, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="season_number", - field_schema=models.PayloadSchemaType.INTEGER, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="episode_number", - field_schema=models.PayloadSchemaType.INTEGER, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="collections", - field_schema=models.PayloadSchemaType.KEYWORD, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="summary", - field_schema=text_index, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="overview", - field_schema=text_index, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="plot", - field_schema=text_index, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="tagline", - field_schema=text_index, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="reviews", - field_schema=text_index, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="data.plex.rating_key", - field_schema=models.PayloadSchemaType.KEYWORD, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="data.imdb.id", - field_schema=models.PayloadSchemaType.KEYWORD, - ) - await client.create_payload_index( - collection_name=collection_name, - field_name="data.tmdb.id", - field_schema=models.PayloadSchemaType.INTEGER, - ) + await _create_index("title", text_index) + await _create_index("type", models.PayloadSchemaType.KEYWORD) + await _create_index("year", models.PayloadSchemaType.INTEGER) + await _create_index("added_at", models.PayloadSchemaType.INTEGER) + await _create_index("actors", models.PayloadSchemaType.KEYWORD) + await _create_index("directors", models.PayloadSchemaType.KEYWORD) + await _create_index("writers", models.PayloadSchemaType.KEYWORD) + await _create_index("genres", models.PayloadSchemaType.KEYWORD) + await _create_index("show_title", models.PayloadSchemaType.KEYWORD) + await _create_index("season_number", models.PayloadSchemaType.INTEGER) + await _create_index("episode_number", models.PayloadSchemaType.INTEGER) + await _create_index("collections", models.PayloadSchemaType.KEYWORD) + await _create_index("summary", text_index) + await _create_index("overview", text_index) + await _create_index("plot", text_index) + await _create_index("tagline", text_index) + await _create_index("reviews", text_index) + await _create_index("data.plex.rating_key", models.PayloadSchemaType.KEYWORD) + await _create_index("data.imdb.id", models.PayloadSchemaType.KEYWORD) + await _create_index("data.tmdb.id", models.PayloadSchemaType.INTEGER) async def _fetch_tmdb_movie( diff --git a/mcp_plex/server.py b/mcp_plex/server.py index 46df48a..d2a0632 100644 --- a/mcp_plex/server.py +++ b/mcp_plex/server.py @@ -651,14 +651,17 @@ async def recommend_media( record = records[0] if record is None: return [] - recs = await server.qdrant_client.recommend( + rec_query = models.RecommendQuery( + recommend=models.RecommendInput(positive=[record.id]) + ) + response = await server.qdrant_client.query_points( collection_name="media-items", - positive=[record.id], + query=rec_query, limit=limit, with_payload=True, using="dense", ) - return [_flatten_payload(r.payload) for r in recs] + return [_flatten_payload(r.payload) for r in response.points] @server.tool("new-movies") diff --git a/pyproject.toml b/pyproject.toml index f18df23..ccb6c2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mcp-plex" -version = "0.26.41" +version = "0.26.43" description = "Plex-Oriented Model Context Protocol Server" requires-python = ">=3.11,<3.13" diff --git a/tests/test_loader_cli.py b/tests/test_loader_cli.py index 7236eea..4c2e01e 100644 --- a/tests/test_loader_cli.py +++ b/tests/test_loader_cli.py @@ -140,6 +140,11 @@ async def fake_run(*args, **kwargs): def test_loader_script_entrypoint(monkeypatch): monkeypatch.setattr(sys, "argv", ["loader", "--help"]) - with pytest.raises(SystemExit) as exc: - runpy.run_module("mcp_plex.loader", run_name="__main__") + module = sys.modules.pop("mcp_plex.loader", None) + try: + with pytest.raises(SystemExit) as exc: + runpy.run_module("mcp_plex.loader", run_name="__main__") + finally: + if module is not None: + sys.modules["mcp_plex.loader"] = module assert exc.value.code == 0 diff --git a/uv.lock b/uv.lock index fafe510..02d56da 100644 --- a/uv.lock +++ b/uv.lock @@ -690,7 +690,7 @@ wheels = [ [[package]] name = "mcp-plex" -version = "0.26.41" +version = "0.26.43" source = { editable = "." } dependencies = [ { name = "fastapi" },