diff --git a/backend/app/clients/base.py b/backend/app/clients/base.py index 718b65b..95ad889 100644 --- a/backend/app/clients/base.py +++ b/backend/app/clients/base.py @@ -36,6 +36,7 @@ class ApiClient: *, params: Optional[Dict[str, Any]] = None, payload: Optional[Dict[str, Any]] = None, + timeout_seconds: float = 10.0, ) -> Optional[Any]: if not self.base_url: 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()), ) try: - async with httpx.AsyncClient(timeout=10.0) as client: + async with httpx.AsyncClient(timeout=timeout_seconds) as client: response = await client.request( method, url, @@ -95,8 +96,15 @@ class ApiClient: ) raise - async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]: - return await self._request("GET", path, params=params) + async def get( + 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]: return await self._request("POST", path, payload=payload) diff --git a/backend/app/clients/radarr.py b/backend/app/clients/radarr.py index 83da911..dcd15d0 100644 --- a/backend/app/clients/radarr.py +++ b/backend/app/clients/radarr.py @@ -24,6 +24,11 @@ class RadarrClient(ApiClient): async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]: 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]]: return await self.get("/api/v3/indexer") diff --git a/backend/app/clients/sonarr.py b/backend/app/clients/sonarr.py index 6de81be..f541081 100644 --- a/backend/app/clients/sonarr.py +++ b/backend/app/clients/sonarr.py @@ -27,6 +27,13 @@ class SonarrClient(ApiClient): async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]: 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]]: return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id}) diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index a55581a..013acf7 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -14,7 +14,6 @@ from ..clients.jellyfin import JellyfinClient from ..clients.qbittorrent import QBittorrentClient from ..clients.radarr import RadarrClient from ..clients.sonarr import SonarrClient -from ..clients.prowlarr import ProwlarrClient from ..ai.triage import triage_snapshot from ..auth import get_current_user from ..runtime import get_runtime_settings @@ -1420,23 +1419,6 @@ def _download_ids(records: List[Dict[str, Any]]) -> List[str]: 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: if exc.response is None: 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]]: + 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): return True, None 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 -def _resolve_arr_indexer_id( - 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]]: +def _filter_arr_release_results(results: Any) -> List[Dict[str, Any]]: if not isinstance(results, list): return [] - keep = [] + keep: List[Dict[str, Any]] = [] + seen: set[tuple[Any, Any]] = set() for item in results: if not isinstance(item, dict): continue - categories = _normalize_categories(item.get("categories")) - if request_type == RequestType.movie: - if not any("movies" in name for name in categories): - continue - elif request_type == RequestType.tv: - if not any(name.startswith("tv") or "tv/" in name for name in categories): - continue + key = (item.get("indexerId"), item.get("guid")) + if not key[0] or not key[1] or key in seen: + continue + seen.add(key) keep.append( { "title": item.get("title"), @@ -1602,11 +1505,49 @@ def _filter_prowlarr_results(results: Any, request_type: RequestType) -> List[Di "publishDate": item.get("publishDate"), "infoUrl": item.get("infoUrl"), "downloadUrl": item.get("downloadUrl"), + "magnetUrl": item.get("magnetUrl"), "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[:10] + return keep[:30] + + +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]]: @@ -2114,18 +2055,74 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr if client.configured(): await _ensure_request_access(client, int(request_id), user) snapshot = await build_snapshot(request_id) - prowlarr_results: List[Dict[str, Any]] = [] - prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key) - if not prowlarr.configured(): - raise HTTPException(status_code=400, detail="Prowlarr not configured") - query = snapshot.title - if snapshot.year: - query = f"{query} {snapshot.year}" + arr_item = snapshot.raw.get("arr", {}).get("item") + if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int): + raise HTTPException(status_code=404, detail="Item not found in Sonarr/Radarr") + + results: List[Dict[str, Any]] = [] + collector = "Sonarr" if snapshot.request_type == RequestType.tv else "Radarr" try: - results = await prowlarr.search(query=query) - prowlarr_results = _filter_prowlarr_results(results, snapshot.request_type) - except httpx.HTTPStatusError: - prowlarr_results = [] + if snapshot.request_type == RequestType.tv: + sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) + if not sonarr.configured(): + 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( save_action, @@ -2133,9 +2130,9 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr "search_releases", "Search and choose a download", "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") @@ -2411,56 +2408,73 @@ async def action_grab( snapshot = await build_snapshot(request_id) guid = payload.get("guid") indexer_id = payload.get("indexerId") - indexer_name = payload.get("indexerName") or payload.get("indexer") - download_url = payload.get("downloadUrl") release_title = payload.get("title") if not guid or not indexer_id: raise HTTPException(status_code=400, detail="Missing guid or indexerId") try: - prowlarr_indexer_id = int(indexer_id) + arr_indexer_id = int(indexer_id) except (TypeError, ValueError) as exc: raise HTTPException(status_code=400, detail="indexerId must be an integer") from exc 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, guid, indexer_id, - indexer_name, - bool(download_url), bool(release_title), ) if snapshot.request_type.value == "tv": arr_client: Any = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) service_label = "Sonarr" - category = _resolve_qbittorrent_category(runtime.sonarr_qbittorrent_category, "sonarr") elif snapshot.request_type.value == "movie": arr_client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) service_label = "Radarr" - category = _resolve_qbittorrent_category(runtime.radarr_qbittorrent_category, "radarr") else: 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 - if arr_client.configured(): - try: - indexers = await arr_client.get_indexers() - arr_indexer_id = _resolve_arr_indexer_id( - indexers, - str(indexer_name) if indexer_name else None, - prowlarr_indexer_id, + try: + response = await arr_client.grab_release(str(guid), arr_indexer_id) + action_message = ( + f"{release_title or 'Selected release'} was sent through {service_label} for download and import." + ) + await asyncio.to_thread( + 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, + request_id, ) - if arr_indexer_id is not None: - response = await arr_client.grab_release(str(guid), arr_indexer_id) + try: + response = await arr_client.push_release(push_payload) accepted, rejection = _release_push_accepted(response) if accepted: 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( - save_action, request_id, "grab", "Download selected release", "ok", action_message + save_action, + request_id, + "grab", + "Download selected release", + "ok", + action_message, ) return { "status": "ok", @@ -2468,41 +2482,19 @@ async def action_grab( "response": {"collector": service_label, "queued": True}, } arr_error = rejection or f"{service_label} rejected the selected release" - else: - arr_error = f"The Prowlarr indexer is not connected to {service_label}" - except httpx.HTTPStatusError as exc: - _log_arr_http_error(service_label, "release grab", exc) - arr_error = _format_upstream_error(service_label, exc) - except Exception as 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"}, - } + except httpx.HTTPStatusError as push_exc: + _log_arr_http_error(service_label, "release push", push_exc) + arr_error = _format_upstream_error(service_label, push_exc) + except Exception as push_exc: + logger.exception("%s release push failed request_id=%s", service_label, request_id) + arr_error = str(push_exc) + except Exception as exc: + logger.exception("%s release grab failed request_id=%s", service_label, request_id) + arr_error = str(exc) failure_message = ( - "The selected release could not be started. " - + (arr_error or "No compatible collector or direct download URL was available.") + f"The selected release could not be started through {service_label}. " + + (arr_error or f"{service_label} did not accept the release.") ) await asyncio.to_thread( save_action, request_id, "grab", "Download selected release", "failed", failure_message diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 11a8281..e79ecd6 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -262,6 +262,180 @@ class LiveDownloadProgressTests(unittest.IsolatedAsyncioTestCase): 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): def test_set_user_email_is_case_insensitive(self) -> None: created = db.create_user_if_missing( diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index 888ecdc..1beb838 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -72,6 +72,9 @@ type ReleaseOption = { publishDate?: string infoUrl?: string downloadUrl?: string + magnetUrl?: string + fullSeason?: boolean + seasonNumber?: number } type SnapshotHistory = { @@ -473,7 +476,8 @@ export default function RequestTimelinePage() { setActionError('This release is missing the details needed to start it.') 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}`) setActionError(null) try { @@ -641,12 +645,12 @@ export default function RequestTimelinePage() { {releaseOptions.length > 0 && (