diff --git a/dev/test_sonarr_release_grab.py b/dev/test_sonarr_release_grab.py new file mode 100644 index 0000000..7cf5c03 --- /dev/null +++ b/dev/test_sonarr_release_grab.py @@ -0,0 +1,79 @@ +import unittest + +from arr_mcp.mcp import mcp_sonarr + + +class FakeSonarrClient: + def __init__(self, *, rejected: bool = False) -> None: + self.posted = [] + self.release = { + "guid": "exact-guid", + "title": "Murder.She.Wrote.S07.German.AC3D.DL.1080p.WebHD.x265-FuN", + "indexer": "Test Indexer", + "indexerId": 7, + "size": 27_600_000_000, + "protocol": "usenet", + "downloadAllowed": not rejected, + "releaseGroup": "FuN", + "seasonNumber": 7, + "fullSeason": True, + "rejections": ["Existing file has equal or better quality"] if rejected else [], + } + + def get_release(self, **_kwargs): + return [dict(self.release)] + + def get_episode(self, **_kwargs): + return [ + {"id": 1, "seasonNumber": 7, "episodeNumber": 1, "hasFile": True}, + {"id": 2, "seasonNumber": 7, "episodeNumber": 2, "hasFile": False}, + ] + + def post_release(self, data): + self.posted.append(data) + return data + + +class ReleaseGrabTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + mcp_sonarr._APPROVALS.clear() + + async def test_exact_release_requires_preview_and_ticket(self) -> None: + client = FakeSonarrClient() + scope = {"series_id": 42, "season_number": 7, "guid": "exact-guid"} + preview = await mcp_sonarr._preview_release_grab(client, scope) + self.assertFalse(preview["download_started"]) + self.assertEqual(preview["existing_episode_files_in_season"], 1) + self.assertTrue(preview["approval_ticket"]) + + result = await mcp_sonarr._grab_release( + client, + {**scope, "confirm": True, "approval_ticket": preview["approval_ticket"]}, + ) + self.assertTrue(result["download_started"]) + self.assertFalse(result["replacement_guaranteed"]) + self.assertEqual(len(client.posted), 1) + self.assertEqual(client.posted[0]["guid"], "exact-guid") + + async def test_rejected_release_needs_force_in_preview(self) -> None: + client = FakeSonarrClient(rejected=True) + scope = {"series_id": 42, "season_number": 7, "guid": "exact-guid"} + blocked = await mcp_sonarr._preview_release_grab(client, scope) + self.assertTrue(blocked["force_required"]) + self.assertIsNone(blocked["approval_ticket"]) + + approved = await mcp_sonarr._preview_release_grab(client, {**scope, "force": True}) + self.assertFalse(approved["force_required"]) + self.assertTrue(approved["approval_ticket"]) + + async def test_guid_must_still_match_current_sonarr_results(self) -> None: + client = FakeSonarrClient() + with self.assertRaisesRegex(ValueError, "no longer present"): + await mcp_sonarr._preview_release_grab( + client, + {"series_id": 42, "season_number": 7, "guid": "different-guid"}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/platform/mcp/README.md b/platform/mcp/README.md index 71b9f34..ffe1ff4 100644 --- a/platform/mcp/README.md +++ b/platform/mcp/README.md @@ -166,5 +166,25 @@ Der erlaubte Schreibablauf ist eng auf fehlende Episoden begrenzt: dort konfigurierten Indexer. Der Ablauf ändert weder Serien- noch Staffel-Monitoring und erlaubt weder -beliebige Commands noch direkte URL-/Release-Downloads. Tickets gelten zehn -Minuten, sind einmalig und an genau die angezeigte Auswahl gebunden. +beliebige Commands noch direkte URL-Downloads. Tickets gelten zehn Minuten, +sind einmalig und an genau die angezeigte Auswahl gebunden. + +### Sonarr: ein konkretes Release oder Staffelpaket laden + +Eine automatische Episodensuche ist **kein Ersatz** für die Auswahl eines +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. +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. +3. Erst nach ausdrücklicher Freigabe darf `grab_release` mit demselben Umfang, + `confirm=true` und dem kurzlebigen Ticket aufgerufen werden. Es übergibt + exakt dieses Release an Sonarrs konfigurierten Download-Client. + +Hat Sonarr das Release abgelehnt oder `downloadAllowed=false` gemeldet, muss +bereits die Vorschau nach gesonderter Zustimmung `force=true` enthalten. Das +Ticket ist auch daran gebunden. Der MCP löscht keine vorhandenen Dateien und +verspricht keine Überschreibung: Ob eine vorhandene Episode nach dem Download +ersetzt wird, entscheiden Sonarrs Qualitätsprofil-, Upgrade- und Importregeln. diff --git a/platform/mcp/patches/mcp_sonarr.py b/platform/mcp/patches/mcp_sonarr.py index 18019f5..206daf1 100644 --- a/platform/mcp/patches/mcp_sonarr.py +++ b/platform/mcp/patches/mcp_sonarr.py @@ -38,10 +38,11 @@ PSEUDO_ACTIONS = frozenset( "get_season_summary", "search_releases", "preview_episode_search", + "preview_release_grab", } ) -WRITE_ACTIONS = frozenset({"start_episode_search"}) +WRITE_ACTIONS = frozenset({"start_episode_search", "grab_release"}) MAX_COLLECTION_ITEMS = 50 APPROVAL_TTL_SECONDS = 600 _APPROVALS: dict[str, tuple[float, str]] = {} @@ -123,6 +124,7 @@ def _compact_release(item: dict[str, Any]) -> dict[str, Any]: ( "guid", "title", "indexer", "indexerId", "size", "age", "ageHours", "seeders", "leechers", "protocol", "downloadAllowed", "releaseWeight", + "releaseGroup", "seasonNumber", "fullSeason", ), ) if quality_name: @@ -275,7 +277,12 @@ async def _search_releases(client: Any, kwargs: dict[str, Any]) -> dict[str, Any "monitoring_changed": False, "download_started": False, "results": compact, - "instruction": "These are Sonarr indexer results. Do not use web search to replace them. Never download unless the user separately approves a write action.", + "instruction": ( + "These are Sonarr indexer results. Do not use web search to replace them. " + "If the user requests one specific release, release group, or complete season pack, " + "NEVER substitute an automatic episode search: preview that exact result with " + "preview_release_grab, then wait for explicit approval before grab_release." + ), } @@ -407,18 +414,190 @@ async def _start_episode_search(client: Any, kwargs: dict[str, Any]) -> dict[str } +def _release_query(kwargs: dict[str, Any]) -> dict[str, Any]: + series_id = int(kwargs["series_id"]) + if series_id < 1: + raise ValueError("series_id must be a positive Sonarr identifier") + query: dict[str, Any] = {"seriesId": series_id} + if kwargs.get("season_number") is not None: + season_number = int(kwargs["season_number"]) + if season_number < 0: + raise ValueError("season_number must be non-negative") + query["seasonNumber"] = season_number + if kwargs.get("episode_id") is not None: + episode_id = int(kwargs["episode_id"]) + if episode_id < 1: + raise ValueError("episode_id must be a positive Sonarr identifier") + query["episodeId"] = episode_id + return query + + +async def _resolve_release_grab(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: + guid = str(kwargs.get("guid", "")).strip() + if not guid: + raise ValueError( + "guid is required; copy it from the exact search_releases result the user selected" + ) + query = _release_query(kwargs) + raw = _unwrap( + await run_blocking(dispatch, client, "get_release", query, service="arr-sonarr") + ) + matches = [ + item for item in raw if isinstance(item, dict) and str(item.get("guid", "")) == guid + ] if isinstance(raw, list) else [] + if len(matches) != 1: + raise ValueError( + "The exact release GUID is no longer present in Sonarr's current indexer results; " + "run search_releases again and do not guess or substitute another release" + ) + release = matches[0] + compact = _compact_release(release) + rejections = compact.get("rejections") or [] + blocked_without_force = release.get("downloadAllowed") is False or bool(rejections) + force = kwargs.get("force") is True + + existing_file_count = None + season_number = query.get("seasonNumber") + if season_number is not None: + episodes = _unwrap( + await run_blocking( + dispatch, + client, + "get_episode", + {"seriesId": query["seriesId"], "seasonNumber": season_number}, + service="arr-sonarr", + ) + ) + if isinstance(episodes, list): + existing_file_count = sum( + bool(item.get("hasFile")) for item in episodes if isinstance(item, dict) + ) + + stable_release = _pick( + release, + ( + "guid", "title", "indexer", "indexerId", "size", "protocol", + "downloadAllowed", "releaseGroup", "seasonNumber", "fullSeason", + ), + ) + raw_rejections = release.get("rejections") + stable_release["rejections"] = ( + [str(reason) for reason in raw_rejections] + if isinstance(raw_rejections, list) + else [] + ) + scope = { + "series_id": query["seriesId"], + "season_number": query.get("seasonNumber"), + "episode_id": query.get("episodeId"), + "guid": guid, + "force": force, + } + fingerprint = json.dumps( + {"scope": scope, "release": stable_release}, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return { + "scope": scope, + "fingerprint": fingerprint, + "release": compact, + "raw_release": release, + "existing_episode_files_in_season": existing_file_count, + "blocked_without_force": blocked_without_force, + } + + +async def _preview_release_grab(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: + resolved = await _resolve_release_grab(client, kwargs) + ticket = secrets.token_urlsafe(24) + now = time.monotonic() + for old_ticket, (expires, _) in list(_APPROVALS.items()): + if expires <= now: + _APPROVALS.pop(old_ticket, None) + force_required = resolved["blocked_without_force"] and not resolved["scope"]["force"] + if not force_required: + _APPROVALS[ticket] = (now + APPROVAL_TTL_SECONDS, resolved["fingerprint"]) + return { + "action": "preview-only", + "scope": resolved["scope"], + "release": resolved["release"], + "existing_episode_files_in_season": resolved["existing_episode_files_in_season"], + "monitoring_changed": False, + "download_started": False, + "existing_files_deleted": False, + "replacement_guaranteed": False, + "warning": ( + "Grabbing a season pack does not itself delete or guarantee replacement of existing " + "episode files. Sonarr applies its import, quality-profile and upgrade rules after download." + ), + "force_required": force_required, + "approval_ticket": None if force_required else ticket, + "approval_expires_in_seconds": None if force_required else APPROVAL_TTL_SECONDS, + "next_step": ( + "This result has Sonarr rejections or downloadAllowed=false. Explain the rejections and " + "only after the user explicitly accepts them call preview_release_grab again with force=true." + if force_required else + "Show the exact title, size, indexer, rejections and overwrite warning. Only after explicit " + "approval call grab_release with exactly the same scope, confirm=true and this ticket." + ), + } + + +async def _grab_release(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: + if kwargs.get("confirm") is not True: + raise PermissionError("confirm=true is required after reviewing preview_release_grab") + ticket = str(kwargs.get("approval_ticket", "")) + if not ticket: + raise PermissionError("approval_ticket is required") + resolved = await _resolve_release_grab(client, kwargs) + if resolved["blocked_without_force"] and not resolved["scope"]["force"]: + raise PermissionError( + "This release has Sonarr rejections or downloadAllowed=false; an explicitly approved " + "force=true preview is required" + ) + approval = _APPROVALS.pop(ticket, None) + if approval is None or approval[0] <= time.monotonic(): + raise PermissionError("Approval ticket is missing, expired or already used") + if not secrets.compare_digest(approval[1], resolved["fingerprint"]): + raise PermissionError("Approval ticket does not match this exact release grab") + result = _unwrap( + await run_blocking( + dispatch, + client, + "post_release", + resolved["raw_release"], + service="arr-sonarr", + ) + ) + return { + "ok": True, + "download_started": True, + "selected_release": resolved["release"], + "sonarr_result": _compact_release(result) if isinstance(result, dict) else result, + "monitoring_changed": False, + "existing_files_deleted": False, + "replacement_guaranteed": False, + "instruction": ( + "The exact approved release was sent to Sonarr's configured download client. " + "Do not claim that an existing episode was overwritten; verify queue/import history later." + ), + } + + def register_sonarr_tools(mcp: FastMCP) -> None: @mcp.tool(tags={"sonarr"}) async def sonarr_action( action: str = Field( - description="Choose one Sonarr operation. Normal choices: find_series to resolve a TV-series name; get_season_summary to list present/missing episodes; search_releases to query Sonarr's configured indexers without downloading; preview_episode_search before any download search; start_episode_search only after the user explicitly approves that exact preview. Use list_actions only for an unusual read operation." + 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." ), 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_episode_search {\"series_id\":123,\"season_number\":2,\"episode_numbers\":[2,3]}. For 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/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.", ), ) -> Any: - """USE ONLY for TV-series tasks managed by Sonarr: identify a series, inspect missing episodes, search configured indexers, or start an explicitly approved missing-episode search. 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.""" + """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.""" if action in {"list_actions", "help", "actions"}: return { "service": "sonarr", @@ -455,8 +634,12 @@ def register_sonarr_tools(mcp: FastMCP) -> None: return await _search_releases(client, kwargs) if action == "preview_episode_search": return await _preview_episode_search(client, kwargs) + if action == "preview_release_grab": + return await _preview_release_grab(client, kwargs) if action == "start_episode_search": return await _start_episode_search(client, kwargs) + if action == "grab_release": + return await _grab_release(client, kwargs) result = await run_blocking( dispatch, client, action, kwargs, service="arr-sonarr" )