Add approved Sonarr release grabs

This commit is contained in:
Mikei386
2026-08-23 14:38:42 +02:00
parent 76bfc65978
commit 09634a27f9
3 changed files with 289 additions and 7 deletions
+79
View File
@@ -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()
+22 -2
View File
@@ -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.
+188 -5
View File
@@ -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"
)