Clarify Sonarr search download behavior

This commit is contained in:
Mikei386
2026-08-23 14:50:08 +02:00
parent 09634a27f9
commit fea4a83582
3 changed files with 65 additions and 4 deletions
+29
View File
@@ -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()
+8
View File
@@ -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.
+28 -4
View File
@@ -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."""