Route manual downloads through Arr collectors
Magent CI/CD / verify (push) Canceled after 3m43s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s

This commit is contained in:
2026-08-29 22:13:46 +12:00
parent 96fc43365f
commit 391cd41d71
6 changed files with 379 additions and 189 deletions
+11 -3
View File
@@ -36,6 +36,7 @@ class ApiClient:
*, *,
params: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None,
payload: Optional[Dict[str, Any]] = None, payload: Optional[Dict[str, Any]] = None,
timeout_seconds: float = 10.0,
) -> Optional[Any]: ) -> Optional[Any]:
if not self.base_url: if not self.base_url:
self.logger.warning("client request skipped method=%s path=%s reason=not-configured", method, path) self.logger.warning("client request skipped method=%s path=%s reason=not-configured", method, path)
@@ -51,7 +52,7 @@ class ApiClient:
sanitize_headers(self.headers()), sanitize_headers(self.headers()),
) )
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=timeout_seconds) as client:
response = await client.request( response = await client.request(
method, method,
url, url,
@@ -95,8 +96,15 @@ class ApiClient:
) )
raise raise
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]: async def get(
return await self._request("GET", path, params=params) self,
path: str,
params: Optional[Dict[str, Any]] = None,
timeout_seconds: float = 10.0,
) -> Optional[Any]:
return await self._request(
"GET", path, params=params, timeout_seconds=timeout_seconds
)
async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]: async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
return await self._request("POST", path, payload=payload) return await self._request("POST", path, payload=payload)
+5
View File
@@ -24,6 +24,11 @@ class RadarrClient(ApiClient):
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]: async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/queue", params={"movieId": movie_id}) return await self.get("/api/v3/queue", params={"movieId": movie_id})
async def search_releases(self, movie_id: int) -> Optional[Any]:
return await self.get(
"/api/v3/release", params={"movieId": movie_id}, timeout_seconds=90.0
)
async def get_indexers(self) -> Optional[Dict[str, Any]]: async def get_indexers(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/indexer") return await self.get("/api/v3/indexer")
+7
View File
@@ -27,6 +27,13 @@ class SonarrClient(ApiClient):
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]: async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/episode", params={"seriesId": series_id}) return await self.get("/api/v3/episode", params={"seriesId": series_id})
async def search_releases(self, series_id: int, season_number: int) -> Optional[Any]:
return await self.get(
"/api/v3/release",
params={"seriesId": series_id, "seasonNumber": season_number},
timeout_seconds=90.0,
)
async def search(self, series_id: int) -> Optional[Dict[str, Any]]: async def search(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id}) return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id})
+174 -182
View File
@@ -14,7 +14,6 @@ from ..clients.jellyfin import JellyfinClient
from ..clients.qbittorrent import QBittorrentClient from ..clients.qbittorrent import QBittorrentClient
from ..clients.radarr import RadarrClient from ..clients.radarr import RadarrClient
from ..clients.sonarr import SonarrClient from ..clients.sonarr import SonarrClient
from ..clients.prowlarr import ProwlarrClient
from ..ai.triage import triage_snapshot from ..ai.triage import triage_snapshot
from ..auth import get_current_user from ..auth import get_current_user
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
@@ -1420,23 +1419,6 @@ def _download_ids(records: List[Dict[str, Any]]) -> List[str]:
return ids return ids
def _normalize_categories(categories: Any) -> List[str]:
names = []
if isinstance(categories, list):
for cat in categories:
if isinstance(cat, dict):
name = cat.get("name")
if isinstance(name, str):
names.append(name.lower())
return names
def _normalize_indexer_name(value: Optional[str]) -> str:
if not isinstance(value, str):
return ""
return "".join(ch for ch in value.lower().strip() if ch.isalnum())
def _log_arr_http_error(service_label: str, action: str, exc: httpx.HTTPStatusError) -> None: def _log_arr_http_error(service_label: str, action: str, exc: httpx.HTTPStatusError) -> None:
if exc.response is None: if exc.response is None:
logger.warning("%s %s failed: %s", service_label, action, exc) logger.warning("%s %s failed: %s", service_label, action, exc)
@@ -1473,6 +1455,17 @@ def _format_rejections(rejections: Any) -> Optional[str]:
def _release_push_accepted(response: Any) -> tuple[bool, Optional[str]]: def _release_push_accepted(response: Any) -> tuple[bool, Optional[str]]:
if isinstance(response, list):
if not response:
return False, "the collector returned no download decision"
reasons: List[str] = []
for item in response:
accepted, reason = _release_push_accepted(item)
if accepted:
return True, None
if reason:
reasons.append(reason)
return False, "; ".join(dict.fromkeys(reasons)) or "rejected"
if not isinstance(response, dict): if not isinstance(response, dict):
return True, None return True, None
rejections = response.get("rejections") or response.get("rejectionReasons") rejections = response.get("rejections") or response.get("rejectionReasons")
@@ -1488,108 +1481,18 @@ def _release_push_accepted(response: Any) -> tuple[bool, Optional[str]]:
return True, None return True, None
def _resolve_arr_indexer_id( def _filter_arr_release_results(results: Any) -> List[Dict[str, Any]]:
indexers: Any, indexer_name: Optional[str], indexer_id: Optional[int], service_label: str
) -> Optional[int]:
if not isinstance(indexers, list):
return None
if not indexer_name:
if indexer_id is None:
return None
by_id = next(
(item for item in indexers if isinstance(item, dict) and item.get("id") == indexer_id),
None,
)
if by_id and by_id.get("id") is not None:
logger.debug("%s indexer id match: %s", service_label, by_id.get("id"))
return int(by_id["id"])
return None
target = indexer_name.lower().strip()
target_compact = _normalize_indexer_name(indexer_name)
exact = next(
(
item
for item in indexers
if isinstance(item, dict)
and str(item.get("name", "")).lower().strip() == target
),
None,
)
if exact and exact.get("id") is not None:
logger.debug("%s indexer match: '%s' -> %s", service_label, indexer_name, exact.get("id"))
return int(exact["id"])
compact = next(
(
item
for item in indexers
if isinstance(item, dict)
and _normalize_indexer_name(str(item.get("name", ""))) == target_compact
),
None,
)
if compact and compact.get("id") is not None:
logger.debug("%s indexer compact match: '%s' -> %s", service_label, indexer_name, compact.get("id"))
return int(compact["id"])
contains = next(
(
item
for item in indexers
if isinstance(item, dict)
and target in str(item.get("name", "")).lower()
),
None,
)
if contains and contains.get("id") is not None:
logger.debug("%s indexer contains match: '%s' -> %s", service_label, indexer_name, contains.get("id"))
return int(contains["id"])
logger.warning(
"%s indexer not found for name '%s'. Check indexer names in the Arr app.",
service_label,
indexer_name,
)
return None
async def _fallback_qbittorrent_download(
download_url: Optional[str], category: str, request_id: Optional[str] = None
) -> bool:
if not download_url:
return False
runtime = get_runtime_settings()
client = QBittorrentClient(
runtime.qbittorrent_base_url,
runtime.qbittorrent_username,
runtime.qbittorrent_password,
)
if not client.configured():
return False
request_tag = f"magent-{request_id}" if request_id else None
await client.add_torrent_url(download_url, category=category, tags=request_tag)
return True
def _resolve_qbittorrent_category(value: Optional[str], default: str) -> str:
if isinstance(value, str):
cleaned = value.strip()
if cleaned:
return cleaned
return default
def _filter_prowlarr_results(results: Any, request_type: RequestType) -> List[Dict[str, Any]]:
if not isinstance(results, list): if not isinstance(results, list):
return [] return []
keep = [] keep: List[Dict[str, Any]] = []
seen: set[tuple[Any, Any]] = set()
for item in results: for item in results:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
categories = _normalize_categories(item.get("categories")) key = (item.get("indexerId"), item.get("guid"))
if request_type == RequestType.movie: if not key[0] or not key[1] or key in seen:
if not any("movies" in name for name in categories): continue
continue seen.add(key)
elif request_type == RequestType.tv:
if not any(name.startswith("tv") or "tv/" in name for name in categories):
continue
keep.append( keep.append(
{ {
"title": item.get("title"), "title": item.get("title"),
@@ -1602,11 +1505,49 @@ def _filter_prowlarr_results(results: Any, request_type: RequestType) -> List[Di
"publishDate": item.get("publishDate"), "publishDate": item.get("publishDate"),
"infoUrl": item.get("infoUrl"), "infoUrl": item.get("infoUrl"),
"downloadUrl": item.get("downloadUrl"), "downloadUrl": item.get("downloadUrl"),
"magnetUrl": item.get("magnetUrl"),
"protocol": item.get("protocol"), "protocol": item.get("protocol"),
"approved": item.get("approved"),
"rejected": item.get("rejected"),
"temporarilyRejected": item.get("temporarilyRejected"),
"rejections": item.get("rejections"),
"downloadAllowed": item.get("downloadAllowed"),
"fullSeason": item.get("fullSeason"),
"seasonNumber": item.get("seasonNumber"),
} }
) )
keep.sort(key=lambda item: (item.get("seeders") or 0), reverse=True) return keep[:30]
return keep[:10]
def _build_release_push_payload(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
title = payload.get("title")
download_url = payload.get("downloadUrl")
magnet_url = payload.get("magnetUrl")
protocol = str(payload.get("protocol") or "").strip().lower()
if protocol not in {"torrent", "usenet"}:
protocol = "torrent" if magnet_url or str(download_url or "").startswith("magnet:") else "usenet"
if not isinstance(title, str) or not title.strip() or not download_url and not magnet_url:
return None
publish_date = payload.get("publishDate")
if not isinstance(publish_date, str) or not publish_date.strip():
publish_date = datetime.now(timezone.utc).isoformat()
result: Dict[str, Any] = {
"title": title.strip(),
"protocol": protocol,
"publishDate": publish_date,
"indexer": payload.get("indexer") or "Magent manual selection",
}
if isinstance(download_url, str) and download_url.strip():
if download_url.startswith("magnet:"):
result["magnetUrl"] = download_url
else:
result["downloadUrl"] = download_url
if isinstance(magnet_url, str) and magnet_url.strip():
result["magnetUrl"] = magnet_url
for key in ("guid", "infoUrl", "size", "seeders", "leechers"):
if payload.get(key) is not None:
result[key] = payload[key]
return result
def _missing_episode_ids_by_season(episodes: Any) -> Dict[int, List[int]]: def _missing_episode_ids_by_season(episodes: Any) -> Dict[int, List[int]]:
@@ -2114,18 +2055,74 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
if client.configured(): if client.configured():
await _ensure_request_access(client, int(request_id), user) await _ensure_request_access(client, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
prowlarr_results: List[Dict[str, Any]] = [] arr_item = snapshot.raw.get("arr", {}).get("item")
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key) if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
if not prowlarr.configured(): raise HTTPException(status_code=404, detail="Item not found in Sonarr/Radarr")
raise HTTPException(status_code=400, detail="Prowlarr not configured")
query = snapshot.title results: List[Dict[str, Any]] = []
if snapshot.year: collector = "Sonarr" if snapshot.request_type == RequestType.tv else "Radarr"
query = f"{query} {snapshot.year}"
try: try:
results = await prowlarr.search(query=query) if snapshot.request_type == RequestType.tv:
prowlarr_results = _filter_prowlarr_results(results, snapshot.request_type) sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
except httpx.HTTPStatusError: if not sonarr.configured():
prowlarr_results = [] raise HTTPException(status_code=400, detail="Sonarr not configured")
episodes = await sonarr.get_episodes(int(arr_item["id"]))
missing_by_season = _missing_episode_ids_by_season(episodes)
season_numbers = sorted(missing_by_season)
if not season_numbers:
message = "Sonarr has no missing monitored episodes to search for."
await asyncio.to_thread(
save_action,
request_id,
"search_releases",
"Search and choose a download",
"ok",
message,
)
return {"status": "ok", "message": message, "collector": collector, "releases": []}
searches = await asyncio.gather(
*(sonarr.search_releases(int(arr_item["id"]), season) for season in season_numbers)
)
for search_results in searches:
if isinstance(search_results, list):
results.extend(search_results)
elif snapshot.request_type == RequestType.movie:
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if not radarr.configured():
raise HTTPException(status_code=400, detail="Radarr not configured")
movie_results = await radarr.search_releases(int(arr_item["id"]))
if isinstance(movie_results, list):
results = movie_results
else:
raise HTTPException(status_code=400, detail="Unknown request type")
except HTTPException:
raise
except httpx.HTTPStatusError as exc:
_log_arr_http_error(collector, "interactive release search", exc)
detail = _format_upstream_error(collector, exc)
await asyncio.to_thread(
save_action,
request_id,
"search_releases",
"Search and choose a download",
"failed",
detail,
)
raise HTTPException(status_code=502, detail=detail) from exc
except Exception as exc:
logger.exception("%s interactive release search failed request_id=%s", collector, request_id)
detail = f"{collector} could not complete the release search: {exc}"
await asyncio.to_thread(
save_action,
request_id,
"search_releases",
"Search and choose a download",
"failed",
detail,
)
raise HTTPException(status_code=502, detail=detail) from exc
releases = _filter_arr_release_results(results)
await asyncio.to_thread( await asyncio.to_thread(
save_action, save_action,
@@ -2133,9 +2130,9 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
"search_releases", "search_releases",
"Search and choose a download", "Search and choose a download",
"ok", "ok",
f"Found {len(prowlarr_results)} releases.", f"{collector} found {len(releases)} releases.",
) )
return {"status": "ok", "releases": prowlarr_results} return {"status": "ok", "collector": collector, "releases": releases}
@router.post("/{request_id}/actions/search_auto") @router.post("/{request_id}/actions/search_auto")
@@ -2411,56 +2408,73 @@ async def action_grab(
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
guid = payload.get("guid") guid = payload.get("guid")
indexer_id = payload.get("indexerId") indexer_id = payload.get("indexerId")
indexer_name = payload.get("indexerName") or payload.get("indexer")
download_url = payload.get("downloadUrl")
release_title = payload.get("title") release_title = payload.get("title")
if not guid or not indexer_id: if not guid or not indexer_id:
raise HTTPException(status_code=400, detail="Missing guid or indexerId") raise HTTPException(status_code=400, detail="Missing guid or indexerId")
try: try:
prowlarr_indexer_id = int(indexer_id) arr_indexer_id = int(indexer_id)
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="indexerId must be an integer") from exc raise HTTPException(status_code=400, detail="indexerId must be an integer") from exc
logger.info( logger.info(
"Grab requested: request_id=%s guid=%s indexer_id=%s indexer_name=%s has_download_url=%s has_title=%s", "Collector grab requested: request_id=%s guid=%s indexer_id=%s has_title=%s",
request_id, request_id,
guid, guid,
indexer_id, indexer_id,
indexer_name,
bool(download_url),
bool(release_title), bool(release_title),
) )
if snapshot.request_type.value == "tv": if snapshot.request_type.value == "tv":
arr_client: Any = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) arr_client: Any = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
service_label = "Sonarr" service_label = "Sonarr"
category = _resolve_qbittorrent_category(runtime.sonarr_qbittorrent_category, "sonarr")
elif snapshot.request_type.value == "movie": elif snapshot.request_type.value == "movie":
arr_client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) arr_client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
service_label = "Radarr" service_label = "Radarr"
category = _resolve_qbittorrent_category(runtime.radarr_qbittorrent_category, "radarr")
else: else:
raise HTTPException(status_code=400, detail="Unknown request type") raise HTTPException(status_code=400, detail="Unknown request type")
if not arr_client.configured():
raise HTTPException(status_code=400, detail=f"{service_label} not configured")
arr_error: Optional[str] = None arr_error: Optional[str] = None
if arr_client.configured(): try:
try: response = await arr_client.grab_release(str(guid), arr_indexer_id)
indexers = await arr_client.get_indexers() action_message = (
arr_indexer_id = _resolve_arr_indexer_id( f"{release_title or 'Selected release'} was sent through {service_label} for download and import."
indexers, )
str(indexer_name) if indexer_name else None, await asyncio.to_thread(
prowlarr_indexer_id, save_action, request_id, "grab", "Download selected release", "ok", action_message
)
return {
"status": "ok",
"message": action_message,
"response": {"collector": service_label, "queued": True},
}
except httpx.HTTPStatusError as exc:
_log_arr_http_error(service_label, "release grab", exc)
status_code = exc.response.status_code if exc.response is not None else None
arr_error = _format_upstream_error(service_label, exc)
push_payload = _build_release_push_payload(payload) if status_code == 404 else None
if push_payload is not None:
logger.info(
"%s release cache miss; retrying through release push request_id=%s",
service_label, service_label,
request_id,
) )
if arr_indexer_id is not None: try:
response = await arr_client.grab_release(str(guid), arr_indexer_id) response = await arr_client.push_release(push_payload)
accepted, rejection = _release_push_accepted(response) accepted, rejection = _release_push_accepted(response)
if accepted: if accepted:
action_message = ( action_message = (
f"{release_title or 'Selected release'} was sent to {service_label} for download." f"{release_title or 'Selected release'} was sent through {service_label} for download and import."
) )
await asyncio.to_thread( await asyncio.to_thread(
save_action, request_id, "grab", "Download selected release", "ok", action_message save_action,
request_id,
"grab",
"Download selected release",
"ok",
action_message,
) )
return { return {
"status": "ok", "status": "ok",
@@ -2468,41 +2482,19 @@ async def action_grab(
"response": {"collector": service_label, "queued": True}, "response": {"collector": service_label, "queued": True},
} }
arr_error = rejection or f"{service_label} rejected the selected release" arr_error = rejection or f"{service_label} rejected the selected release"
else: except httpx.HTTPStatusError as push_exc:
arr_error = f"The Prowlarr indexer is not connected to {service_label}" _log_arr_http_error(service_label, "release push", push_exc)
except httpx.HTTPStatusError as exc: arr_error = _format_upstream_error(service_label, push_exc)
_log_arr_http_error(service_label, "release grab", exc) except Exception as push_exc:
arr_error = _format_upstream_error(service_label, exc) logger.exception("%s release push failed request_id=%s", service_label, request_id)
except Exception as exc: arr_error = str(push_exc)
logger.exception("%s release grab failed request_id=%s", service_label, request_id) except Exception as exc:
arr_error = str(exc) logger.exception("%s release grab failed request_id=%s", service_label, request_id)
arr_error = str(exc)
if download_url:
try:
qbittorrent_added = await _fallback_qbittorrent_download(
str(download_url), category, request_id
)
except Exception as exc:
logger.exception("qBittorrent release fallback failed request_id=%s", request_id)
qbittorrent_added = False
if not arr_error:
arr_error = str(exc)
if qbittorrent_added:
action_message = (
f"{release_title or 'Selected release'} was sent directly to qBittorrent."
)
await asyncio.to_thread(
save_action, request_id, "grab", "Download selected release", "ok", action_message
)
return {
"status": "ok",
"message": action_message,
"response": {"qbittorrent": "queued"},
}
failure_message = ( failure_message = (
"The selected release could not be started. " f"The selected release could not be started through {service_label}. "
+ (arr_error or "No compatible collector or direct download URL was available.") + (arr_error or f"{service_label} did not accept the release.")
) )
await asyncio.to_thread( await asyncio.to_thread(
save_action, request_id, "grab", "Download selected release", "failed", failure_message save_action, request_id, "grab", "Download selected release", "failed", failure_message
+174
View File
@@ -262,6 +262,180 @@ class LiveDownloadProgressTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result["torrents"][0]["progressPercent"], 13.4) self.assertEqual(result["torrents"][0]["progressPercent"], 13.4)
class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
@staticmethod
def _runtime() -> SimpleNamespace:
return SimpleNamespace(
jellyseerr_base_url=None,
jellyseerr_api_key=None,
sonarr_base_url="http://sonarr.test",
sonarr_api_key="sonarr-key",
radarr_base_url="http://radarr.test",
radarr_api_key="radarr-key",
)
async def test_tv_manual_search_uses_sonarr_and_keeps_season_packs(self) -> None:
snapshot = Snapshot(
request_id="3909",
title="Example Show",
request_type=RequestType.tv,
raw={"arr": {"item": {"id": 42}}},
)
sonarr = SimpleNamespace(
configured=lambda: True,
get_episodes=AsyncMock(
return_value=[
{"id": 101, "seasonNumber": 1, "monitored": True, "hasFile": False},
{"id": 201, "seasonNumber": 2, "monitored": True, "hasFile": False},
{"id": 202, "seasonNumber": 2, "monitored": True, "hasFile": True},
]
),
search_releases=AsyncMock(
side_effect=[
[
{
"title": "Example.Show.S01.1080p",
"guid": "season-one",
"indexerId": 7,
"indexer": "Prowlarr",
"protocol": "torrent",
"fullSeason": True,
"seasonNumber": 1,
}
],
[],
]
),
)
with patch.object(requests_router, "get_runtime_settings", return_value=self._runtime()), patch.object(
requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)
), patch.object(requests_router, "SonarrClient", return_value=sonarr), patch.object(
requests_router, "save_action"
):
result = await requests_router.action_search(
"3909", user={"username": "viewer", "role": "user"}
)
sonarr.search_releases.assert_any_await(42, 1)
sonarr.search_releases.assert_any_await(42, 2)
self.assertEqual(result["collector"], "Sonarr")
self.assertTrue(result["releases"][0]["fullSeason"])
self.assertEqual(result["releases"][0]["seasonNumber"], 1)
async def test_movie_manual_search_uses_radarr(self) -> None:
snapshot = Snapshot(
request_id="4000",
title="Example Movie",
request_type=RequestType.movie,
raw={"arr": {"item": {"id": 84}}},
)
radarr = SimpleNamespace(
configured=lambda: True,
search_releases=AsyncMock(
return_value=[
{
"title": "Example.Movie.2026.1080p",
"guid": "movie-release",
"indexerId": 9,
"indexer": "Prowlarr",
"protocol": "torrent",
}
]
),
)
with patch.object(requests_router, "get_runtime_settings", return_value=self._runtime()), patch.object(
requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)
), patch.object(requests_router, "RadarrClient", return_value=radarr), patch.object(
requests_router, "save_action"
):
result = await requests_router.action_search(
"4000", user={"username": "viewer", "role": "user"}
)
radarr.search_releases.assert_awaited_once_with(84)
self.assertEqual(result["collector"], "Radarr")
self.assertEqual(result["releases"][0]["guid"], "movie-release")
async def test_tv_manual_grab_is_sent_to_sonarr_not_qbittorrent(self) -> None:
snapshot = Snapshot(
request_id="3909",
title="Example Show",
request_type=RequestType.tv,
)
sonarr = SimpleNamespace(
configured=lambda: True,
grab_release=AsyncMock(return_value={"guid": "season-one", "indexerId": 7}),
push_release=AsyncMock(),
)
payload = {
"title": "Example.Show.S01.1080p",
"guid": "season-one",
"indexerId": 7,
"protocol": "torrent",
}
with patch.object(requests_router, "get_runtime_settings", return_value=self._runtime()), patch.object(
requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)
), patch.object(requests_router, "SonarrClient", return_value=sonarr), patch.object(
requests_router, "save_action"
):
result = await requests_router.action_grab(
"3909", payload, user={"username": "viewer", "role": "user"}
)
sonarr.grab_release.assert_awaited_once_with("season-one", 7)
sonarr.push_release.assert_not_awaited()
self.assertEqual(result["response"], {"collector": "Sonarr", "queued": True})
async def test_stale_movie_release_still_routes_through_radarr_push(self) -> None:
snapshot = Snapshot(
request_id="4000",
title="Example Movie",
request_type=RequestType.movie,
)
response = httpx.Response(
404,
request=httpx.Request("POST", "http://radarr.test/api/v3/release"),
json={"message": "release cache expired"},
)
cache_miss = httpx.HTTPStatusError(
"release cache expired",
request=response.request,
response=response,
)
radarr = SimpleNamespace(
configured=lambda: True,
grab_release=AsyncMock(side_effect=cache_miss),
push_release=AsyncMock(return_value=[{"approved": True, "downloadAllowed": True}]),
)
payload = {
"title": "Example.Movie.2026.1080p",
"guid": "stale-release",
"indexerId": 9,
"indexer": "Prowlarr",
"protocol": "torrent",
"publishDate": "2026-08-29T00:00:00Z",
"downloadUrl": "http://prowlarr.test/download/1",
}
with patch.object(requests_router, "get_runtime_settings", return_value=self._runtime()), patch.object(
requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)
), patch.object(requests_router, "RadarrClient", return_value=radarr), patch.object(
requests_router, "save_action"
):
result = await requests_router.action_grab(
"4000", payload, user={"username": "viewer", "role": "user"}
)
radarr.push_release.assert_awaited_once()
pushed = radarr.push_release.await_args.args[0]
self.assertEqual(pushed["downloadUrl"], "http://prowlarr.test/download/1")
self.assertEqual(pushed["protocol"], "torrent")
self.assertEqual(result["response"], {"collector": "Radarr", "queued": True})
class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase): class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
def test_set_user_email_is_case_insensitive(self) -> None: def test_set_user_email_is_case_insensitive(self) -> None:
created = db.create_user_if_missing( created = db.create_user_if_missing(
+8 -4
View File
@@ -72,6 +72,9 @@ type ReleaseOption = {
publishDate?: string publishDate?: string
infoUrl?: string infoUrl?: string
downloadUrl?: string downloadUrl?: string
magnetUrl?: string
fullSeason?: boolean
seasonNumber?: number
} }
type SnapshotHistory = { type SnapshotHistory = {
@@ -473,7 +476,8 @@ export default function RequestTimelinePage() {
setActionError('This release is missing the details needed to start it.') setActionError('This release is missing the details needed to start it.')
return return
} }
if (!window.confirm(`Download “${release.title ?? 'this release'}”?`)) return const collector = snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'
if (!window.confirm(`Send “${release.title ?? 'this release'}” through ${collector}?`)) return
setBusyAction(`grab:${release.guid}`) setBusyAction(`grab:${release.guid}`)
setActionError(null) setActionError(null)
try { try {
@@ -641,12 +645,12 @@ export default function RequestTimelinePage() {
{releaseOptions.length > 0 && ( {releaseOptions.length > 0 && (
<div className="request-release-picker"> <div className="request-release-picker">
<div className="request-release-heading"><div><span className="section-kicker">Manual selection</span><h3>Choose a release</h3></div><button type="button" className="ghost-button" onClick={() => setReleaseOptions([])}>Close</button></div> <div className="request-release-heading"><div><span className="section-kicker">Manual selection through {snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'}</span><h3>Choose a release</h3></div><button type="button" className="ghost-button" onClick={() => setReleaseOptions([])}>Close</button></div>
<div className="request-release-list"> <div className="request-release-list">
{releaseOptions.map((release) => ( {releaseOptions.map((release) => (
<div className="request-release" key={`${release.guid ?? release.title}`}> <div className="request-release" key={`${release.guid ?? release.title}`}>
<div><strong>{release.title ?? 'Unknown release'}</strong><span>{release.indexer ?? 'Unknown indexer'} · {release.seeders ?? 0} seeders · {formatBytes(release.size)}</span></div> <div><strong>{release.title ?? 'Unknown release'}</strong><span>{release.indexer ?? 'Unknown indexer'} · {release.seeders ?? 0} seeders · {formatBytes(release.size)}{release.fullSeason ? ` · Season ${release.seasonNumber ?? ''} pack` : ''}</span></div>
<button type="button" disabled={Boolean(busyAction) || !release.guid || !release.indexerId} onClick={() => void downloadRelease(release)}>{busyAction === `grab:${release.guid}` ? 'Starting…' : 'Download this release'}</button> <button type="button" disabled={Boolean(busyAction) || !release.guid || !release.indexerId} onClick={() => void downloadRelease(release)}>{busyAction === `grab:${release.guid}` ? 'Sending…' : `Send through ${snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'}`}</button>
</div> </div>
))} ))}
</div> </div>