Clarify Sonarr search download behavior
This commit is contained in:
@@ -74,6 +74,35 @@ class ReleaseGrabTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
{"series_id": 42, "season_number": 7, "guid": "different-guid"},
|
{"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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -165,6 +165,11 @@ Der erlaubte Schreibablauf ist eng auf fehlende Episoden begrenzt:
|
|||||||
das passende Ticket. Erst dann startet Sonarr eine `EpisodeSearch` über die
|
das passende Ticket. Erst dann startet Sonarr eine `EpisodeSearch` über die
|
||||||
dort konfigurierten Indexer.
|
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
|
Der Ablauf ändert weder Serien- noch Staffel-Monitoring und erlaubt weder
|
||||||
beliebige Commands noch direkte URL-Downloads. Tickets gelten zehn Minuten,
|
beliebige Commands noch direkte URL-Downloads. Tickets gelten zehn Minuten,
|
||||||
sind einmalig und an genau die angezeigte Auswahl gebunden.
|
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:
|
verlangt, gilt stattdessen dieser Ablauf:
|
||||||
|
|
||||||
1. `search_releases` sucht ausschließlich über Sonarrs konfigurierte Indexer.
|
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
|
2. `preview_release_grab` bekommt Serien-ID, Staffel und die **exakte GUID** des
|
||||||
ausgewählten Suchergebnisses. Sonarr wird erneut abgefragt; Titel, Größe,
|
ausgewählten Suchergebnisses. Sonarr wird erneut abgefragt; Titel, Größe,
|
||||||
Indexer, Ablehnungsgründe und vorhandene Episodendateien werden angezeigt.
|
Indexer, Ablehnungsgründe und vorhandene Episodendateien werden angezeigt.
|
||||||
|
|||||||
@@ -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")
|
str(item.get(field, "")) for field in ("title", "sortTitle", "originalTitle", "alternateTitles")
|
||||||
).casefold()
|
).casefold()
|
||||||
if all(word in haystack for word in words):
|
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 {
|
return {
|
||||||
"query": query,
|
"query": query,
|
||||||
"matches": matches[:limit],
|
"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")
|
episode_id = kwargs.get("episode_id")
|
||||||
season_number = kwargs.get("season_number")
|
season_number = kwargs.get("season_number")
|
||||||
release_group = str(kwargs.get("release_group", "")).strip()
|
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:
|
if series_id is None and episode_id is None:
|
||||||
raise ValueError("search_releases requires series_id or episode_id")
|
raise ValueError("search_releases requires series_id or episode_id")
|
||||||
query: dict[str, Any] = {}
|
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", ""))
|
str(item.get("releaseGroup", "")) + " " + str(item.get("title", ""))
|
||||||
).casefold()
|
).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)
|
compact = _compact_result("get_release", raw)
|
||||||
return {
|
return {
|
||||||
"task_complete": True,
|
"task_complete": True,
|
||||||
@@ -273,6 +290,7 @@ async def _search_releases(client: Any, kwargs: dict[str, Any]) -> dict[str, Any
|
|||||||
"episode_id": episode_id,
|
"episode_id": episode_id,
|
||||||
"season_number": season_number,
|
"season_number": season_number,
|
||||||
"release_group_filter": release_group or None,
|
"release_group_filter": release_group or None,
|
||||||
|
"season_pack_only": season_pack_only,
|
||||||
},
|
},
|
||||||
"monitoring_changed": False,
|
"monitoring_changed": False,
|
||||||
"download_started": 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,
|
"sonarr_command": _pick(command, ("id", "name", "status", "queued", "startedOn")) if isinstance(command, dict) else command,
|
||||||
"scope": resolved["scope"],
|
"scope": resolved["scope"],
|
||||||
"monitoring_changed": False,
|
"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"})
|
@mcp.tool(tags={"sonarr"})
|
||||||
async def sonarr_action(
|
async def sonarr_action(
|
||||||
action: str = Field(
|
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(
|
params_json: str = Field(
|
||||||
default="{}",
|
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:
|
) -> 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."""
|
"""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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user