Add approved Sonarr release grabs
This commit is contained in:
+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