Route manual downloads through Arr collectors
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user