Add approved Sonarr release grabs
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user