diff --git a/dev/test_sonarr_release_grab.py b/dev/test_sonarr_release_grab.py index 7cf5c03..aec38f9 100644 --- a/dev/test_sonarr_release_grab.py +++ b/dev/test_sonarr_release_grab.py @@ -74,6 +74,35 @@ class ReleaseGrabTests(unittest.IsolatedAsyncioTestCase): {"series_id": 42, "season_number": 7, "guid": "different-guid"}, ) + async def test_search_can_limit_results_to_group_and_season_pack(self) -> None: + client = FakeSonarrClient() + client.get_release = lambda **_kwargs: [ + dict(client.release), + { + **client.release, + "guid": "episode-guid", + "title": "Mord.ist.ihr.Hobby.S07E02.German.1080p-FuN", + "fullSeason": False, + }, + { + **client.release, + "guid": "other-group", + "title": "Mord.ist.ihr.Hobby.S07.German.1080p-HQC", + "releaseGroup": "HQC", + }, + ] + result = await mcp_sonarr._search_releases( + client, + { + "series_id": 42, + "season_number": 7, + "release_group": "FuN", + "season_pack_only": True, + }, + ) + self.assertEqual(result["results"]["total"], 1) + self.assertEqual(result["results"]["items"][0]["guid"], "exact-guid") + if __name__ == "__main__": unittest.main() diff --git a/platform/mcp/README.md b/platform/mcp/README.md index ffe1ff4..b4d87e2 100644 --- a/platform/mcp/README.md +++ b/platform/mcp/README.md @@ -165,6 +165,11 @@ Der erlaubte Schreibablauf ist eng auf fehlende Episoden begrenzt: das passende Ticket. Erst dann startet Sonarr eine `EpisodeSearch` über die dort konfigurierten Indexer. +`EpisodeSearch` ist keine bloße Ergebnisvorschau: Sonarr kann dabei sofort das +beste akzeptierte Release pro Episode an den Download-Client übergeben. Das +gilt auch für explizit ausgewählte, momentan nicht überwachte Episoden; +Monitoring steuert vor allem die spätere automatische/RSS-Verarbeitung. + Der Ablauf ändert weder Serien- noch Staffel-Monitoring und erlaubt weder beliebige Commands noch direkte URL-Downloads. Tickets gelten zehn Minuten, sind einmalig und an genau die angezeigte Auswahl gebunden. @@ -176,6 +181,9 @@ bestimmten Releases. Wenn ein Benutzer etwa ausdrücklich ein FuN-Staffelpaket verlangt, gilt stattdessen dieser Ablauf: 1. `search_releases` sucht ausschließlich über Sonarrs konfigurierte Indexer. + Für Gruppen- oder Staffelpaketfragen werden `release_group` und + `season_pack_only=true` direkt gesetzt; vorhandene Bibliotheksdateien sind + kein Beleg dafür, was aktuell auf den Indexern verfügbar ist. 2. `preview_release_grab` bekommt Serien-ID, Staffel und die **exakte GUID** des ausgewählten Suchergebnisses. Sonarr wird erneut abgefragt; Titel, Größe, Indexer, Ablehnungsgründe und vorhandene Episodendateien werden angezeigt. diff --git a/platform/mcp/patches/mcp_sonarr.py b/platform/mcp/patches/mcp_sonarr.py index 206daf1..433dada 100644 --- a/platform/mcp/patches/mcp_sonarr.py +++ b/platform/mcp/patches/mcp_sonarr.py @@ -183,7 +183,7 @@ async def _find_series(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: str(item.get(field, "")) for field in ("title", "sortTitle", "originalTitle", "alternateTitles") ).casefold() if all(word in haystack for word in words): - matches.append(_compact_series(item, include_seasons=True)) + matches.append(_compact_series(item, include_seasons=False)) return { "query": query, "matches": matches[:limit], @@ -245,6 +245,7 @@ async def _search_releases(client: Any, kwargs: dict[str, Any]) -> dict[str, Any episode_id = kwargs.get("episode_id") season_number = kwargs.get("season_number") release_group = str(kwargs.get("release_group", "")).strip() + season_pack_only = kwargs.get("season_pack_only") is True if series_id is None and episode_id is None: raise ValueError("search_releases requires series_id or episode_id") query: dict[str, Any] = {} @@ -265,6 +266,22 @@ async def _search_releases(client: Any, kwargs: dict[str, Any]) -> dict[str, Any str(item.get("releaseGroup", "")) + " " + str(item.get("title", "")) ).casefold() ] + if season_pack_only: + if season_number is None: + raise ValueError("season_pack_only=true requires season_number") + season_token = rf"(?:^|[. _-])S0*{int(season_number)}(?:[. _-]|$)" + episode_token = rf"S0*{int(season_number)}E\d+" + raw = [ + item for item in raw + if isinstance(item, dict) + and ( + item.get("fullSeason") is True + or ( + re.search(season_token, str(item.get("title", "")), re.IGNORECASE) + and not re.search(episode_token, str(item.get("title", "")), re.IGNORECASE) + ) + ) + ] if isinstance(raw, list) else raw compact = _compact_result("get_release", raw) return { "task_complete": True, @@ -273,6 +290,7 @@ async def _search_releases(client: Any, kwargs: dict[str, Any]) -> dict[str, Any "episode_id": episode_id, "season_number": season_number, "release_group_filter": release_group or None, + "season_pack_only": season_pack_only, }, "monitoring_changed": False, "download_started": False, @@ -410,7 +428,13 @@ async def _start_episode_search(client: Any, kwargs: dict[str, Any]) -> dict[str "sonarr_command": _pick(command, ("id", "name", "status", "queued", "startedOn")) if isinstance(command, dict) else command, "scope": resolved["scope"], "monitoring_changed": False, - "instruction": "Sonarr is now searching its configured indexers for the approved missing episodes.", + "download_may_start_immediately": True, + "instruction": ( + "Sonarr is now searching its configured indexers and may immediately grab/download " + "the best acceptable release for every approved episode. EpisodeSearch is NOT a " + "read-only manual-search preview and is NOT limited by the monitored flag. Check the " + "queue before claiming that nothing was downloaded." + ), } @@ -590,11 +614,11 @@ def register_sonarr_tools(mcp: FastMCP) -> None: @mcp.tool(tags={"sonarr"}) async def sonarr_action( action: str = Field( - description="Choose one Sonarr operation. Use find_series to resolve a TV-series name; get_season_summary for present/missing episodes; search_releases for Sonarr indexer results. If the user requests a SPECIFIC release, group, or complete season pack, NEVER use automatic episode search: use preview_release_grab with its exact GUID, wait for approval, then grab_release. Use preview_episode_search/start_episode_search only when any acceptable release for missing episodes is wanted. Use list_actions only for unusual reads." + description="Choose one Sonarr operation. Use find_series only to resolve a TV-series name. For questions whether a release/group/season pack is AVAILABLE, immediately use search_releases with release_group and season_pack_only as requested; do not infer availability from existing library files or call get_season_summary first. If the user requests a SPECIFIC release, group, or complete season pack, NEVER use automatic episode search: use preview_release_grab with its exact GUID, wait for approval, then grab_release. preview_episode_search/start_episode_search automatically chooses releases and may immediately download them; use it only when the user accepts any suitable release." ), params_json: str = Field( default="{}", - description="JSON object encoded as a string. Common forms: find_series {\"query\":\"Title\"}; get_season_summary/search_releases {\"series_id\":123,\"season_number\":2}; preview_release_grab {\"series_id\":123,\"season_number\":2,\"guid\":\"exact-guid-from-search\"}; preview_episode_search {\"series_id\":123,\"season_number\":2,\"episode_numbers\":[2,3]}. For grab_release or start_episode_search reuse the exact preview scope and add confirm:true plus approval_ticket.", + description="JSON object encoded as a string. Common forms: find_series {\"query\":\"Title\"}; get_season_summary {\"series_id\":123,\"season_number\":2}; search_releases {\"series_id\":123,\"season_number\":2,\"release_group\":\"FuN\",\"season_pack_only\":true}; preview_release_grab {\"series_id\":123,\"season_number\":2,\"guid\":\"exact-guid-from-search\"}; preview_episode_search {\"series_id\":123,\"season_number\":2,\"episode_numbers\":[2,3]}. For grab_release or start_episode_search reuse the exact preview scope and add confirm:true plus approval_ticket.", ), ) -> Any: """USE ONLY for TV-series tasks managed by Sonarr: identify a series, inspect missing episodes, search configured indexers, grab one explicitly selected and approved release, or start an approved automatic missing-episode search. A specific season pack/release must use preview_release_grab then grab_release; automatic episode search is not equivalent. DO NOT use for movies (use Radarr), public-web research, media playback, filesystem copying, or direct URL downloads. Read-only by default; monitoring is never changed."""