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,
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)
+5
View File
@@ -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")
+7
View File
@@ -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})
+174 -182
View File
@@ -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
+174
View File
@@ -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(
+8 -4
View File
@@ -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 && (
<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">
{releaseOptions.map((release) => (
<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>
<button type="button" disabled={Boolean(busyAction) || !release.guid || !release.indexerId} onClick={() => void downloadRelease(release)}>{busyAction === `grab:${release.guid}` ? 'Starting…' : 'Download this release'}</button>
<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}` ? 'Sending…' : `Send through ${snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'}`}</button>
</div>
))}
</div>