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