|
|
|
@@ -1,3 +1,4 @@
|
|
|
|
|
from ..services import manual_releases
|
|
|
|
|
from ..services.request_language import language_info, original_profile, is_original_profile, apply_original_to_movie, movie_search_outcome, series_search_outcome
|
|
|
|
|
from ..feature_guards import require_request_access
|
|
|
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
@@ -1658,34 +1659,7 @@ def _format_rejections(rejections: Any) -> Optional[str]:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
reason = _format_rejections(rejections)
|
|
|
|
|
if reason:
|
|
|
|
|
return False, reason
|
|
|
|
|
if response.get("rejected") is True:
|
|
|
|
|
return False, "rejected"
|
|
|
|
|
if response.get("downloadAllowed") is False:
|
|
|
|
|
return False, "download not allowed"
|
|
|
|
|
if response.get("approved") is False:
|
|
|
|
|
return False, "not approved"
|
|
|
|
|
return True, None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _filter_arr_release_results(results: Any) -> List[Dict[str, Any]]:
|
|
|
|
|
def _filter_arr_release_results(results: Any, include_rejected: bool = False) -> List[Dict[str, Any]]:
|
|
|
|
|
if not isinstance(results, list):
|
|
|
|
|
return []
|
|
|
|
|
keep: List[Dict[str, Any]] = []
|
|
|
|
@@ -1696,14 +1670,8 @@ def _filter_arr_release_results(results: Any) -> List[Dict[str, Any]]:
|
|
|
|
|
key = (item.get("indexerId"), item.get("guid"))
|
|
|
|
|
if not key[0] or not key[1] or key in seen:
|
|
|
|
|
continue
|
|
|
|
|
rejections = item.get("rejections")
|
|
|
|
|
if (
|
|
|
|
|
item.get("approved") is not True
|
|
|
|
|
or item.get("rejected") is True
|
|
|
|
|
or item.get("temporarilyRejected") is True
|
|
|
|
|
or item.get("downloadAllowed") is False
|
|
|
|
|
or isinstance(rejections, list) and bool(rejections)
|
|
|
|
|
):
|
|
|
|
|
accepted, override, reasons = manual_releases.decision(item)
|
|
|
|
|
if not accepted and not include_rejected:
|
|
|
|
|
continue
|
|
|
|
|
seen.add(key)
|
|
|
|
|
quality_payload = item.get("quality")
|
|
|
|
@@ -1728,7 +1696,7 @@ def _filter_arr_release_results(results: Any) -> List[Dict[str, Any]]:
|
|
|
|
|
"downloadUrl": item.get("downloadUrl"),
|
|
|
|
|
"magnetUrl": item.get("magnetUrl"),
|
|
|
|
|
"protocol": item.get("protocol"),
|
|
|
|
|
"approved": item.get("approved"),
|
|
|
|
|
"approved": accepted,
|
|
|
|
|
"rejected": item.get("rejected"),
|
|
|
|
|
"temporarilyRejected": item.get("temporarilyRejected"),
|
|
|
|
|
"rejections": item.get("rejections"),
|
|
|
|
@@ -1736,46 +1704,20 @@ def _filter_arr_release_results(results: Any) -> List[Dict[str, Any]]:
|
|
|
|
|
"fullSeason": item.get("fullSeason"),
|
|
|
|
|
"seasonNumber": item.get("seasonNumber"),
|
|
|
|
|
"quality": quality_name,
|
|
|
|
|
"requiresOverride": override,
|
|
|
|
|
"selectable": accepted or override,
|
|
|
|
|
"rejections": reasons,
|
|
|
|
|
"episodeNumbers": item.get("mappedEpisodeNumbers") or item.get("episodeNumbers"),
|
|
|
|
|
"customFormatScore": item.get("customFormatScore"),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
releases = keep[:30]
|
|
|
|
|
keep.sort(key=lambda item: (not bool(item.get("approved")), not item["requiresOverride"]))
|
|
|
|
|
releases = keep[:200]
|
|
|
|
|
for index, release in enumerate(releases):
|
|
|
|
|
release["bestPick"] = index == 0
|
|
|
|
|
release["bestPick"] = index == 0 and release.get("approved") is True
|
|
|
|
|
return releases
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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]]:
|
|
|
|
|
if not isinstance(episodes, list):
|
|
|
|
|
return {}
|
|
|
|
@@ -3356,7 +3298,11 @@ async def accept_request_language(request_id: str, payload: dict, user: dict = D
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/{request_id}/actions/search")
|
|
|
|
|
async def action_search(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
|
|
|
|
async def action_search(request_id: str, user: Dict[str, str] = Depends(get_current_user), offset: int = 0) -> dict:
|
|
|
|
|
if offset < 0:
|
|
|
|
|
raise HTTPException(400, 'Search offset must be zero or greater.')
|
|
|
|
|
total_missing = 0
|
|
|
|
|
next_offset = None
|
|
|
|
|
runtime = get_runtime_settings()
|
|
|
|
|
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
|
|
|
|
if client.configured():
|
|
|
|
@@ -3387,15 +3333,23 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
|
|
|
|
|
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)
|
|
|
|
|
)
|
|
|
|
|
season_results = [item for item in searches if isinstance(item, list)]
|
|
|
|
|
longest_result = max((len(item) for item in season_results), default=0)
|
|
|
|
|
for position in range(longest_result):
|
|
|
|
|
for search_results in season_results:
|
|
|
|
|
if position < len(search_results):
|
|
|
|
|
results.append(search_results[position])
|
|
|
|
|
missing_ids = [identity for season in season_numbers for identity in missing_by_season[season]]
|
|
|
|
|
total_missing = len(missing_ids)
|
|
|
|
|
batch = missing_ids[offset:offset + 20]
|
|
|
|
|
next_offset = offset + len(batch) if offset + len(batch) < total_missing else None
|
|
|
|
|
semaphore = asyncio.Semaphore(3)
|
|
|
|
|
async def search_episode(identity):
|
|
|
|
|
async with semaphore:
|
|
|
|
|
found = await sonarr.search_episode_releases(identity)
|
|
|
|
|
if not isinstance(found, list):
|
|
|
|
|
raise HTTPException(502, 'Sonarr did not return valid episode search results. Try again.')
|
|
|
|
|
return found
|
|
|
|
|
searches = await asyncio.gather(*(search_episode(identity) for identity in batch))
|
|
|
|
|
# Interleave per-episode rankings so a prolific episode cannot hide the others.
|
|
|
|
|
for position in range(max((len(items) for items in searches), default=0)):
|
|
|
|
|
for items in searches:
|
|
|
|
|
if position < len(items):
|
|
|
|
|
results.append(items[position])
|
|
|
|
|
elif snapshot.request_type == RequestType.movie:
|
|
|
|
|
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
|
|
|
|
if not radarr.configured():
|
|
|
|
@@ -3432,28 +3386,27 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(status_code=502, detail=detail) from exc
|
|
|
|
|
|
|
|
|
|
releases = _filter_arr_release_results(results)
|
|
|
|
|
releases = _filter_arr_release_results(results, include_rejected=True)
|
|
|
|
|
approved = sum(not r['requiresOverride'] and r['selectable'] for r in releases)
|
|
|
|
|
source = runtime.sonarr_base_url if collector == 'Sonarr' else runtime.radarr_base_url
|
|
|
|
|
override_allowed = manual_releases.can_override(user)
|
|
|
|
|
for release in releases:
|
|
|
|
|
if release['selectable'] and (not release['requiresOverride'] or override_allowed):
|
|
|
|
|
release['selectionToken'] = manual_releases.issue_selection(release, request_id, user, source, arr_item['id'])
|
|
|
|
|
for key in ('downloadUrl', 'magnetUrl'):
|
|
|
|
|
release.pop(key, None)
|
|
|
|
|
rejection_reasons = sorted({str(reason) for result in results for reason in (result.get('rejections') or [])})[:8]
|
|
|
|
|
result_message = (f"{collector} approved {len(releases)} releases against its assigned quality profile."
|
|
|
|
|
if releases else f"No approved releases were found. " + (' '.join(rejection_reasons) if rejection_reasons else 'The indexers returned no suitable results. Try again later or review the audio language.'))
|
|
|
|
|
|
|
|
|
|
await asyncio.to_thread(
|
|
|
|
|
save_action,
|
|
|
|
|
request_id,
|
|
|
|
|
"search_releases",
|
|
|
|
|
"Search and choose a download",
|
|
|
|
|
"ok",
|
|
|
|
|
f"{collector} approved {len(releases)} releases against its assigned quality profile.",
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"collector": collector,
|
|
|
|
|
"qualityFiltered": True,
|
|
|
|
|
"message": result_message,
|
|
|
|
|
"outcome": "matches" if releases else "attention",
|
|
|
|
|
"rejectionReasons": rejection_reasons,
|
|
|
|
|
"releases": releases,
|
|
|
|
|
}
|
|
|
|
|
message = (f'{len(releases)} releases shown; {approved} meet the assigned profile. Review the reasons on other releases.'
|
|
|
|
|
if releases else 'No releases were returned for the missing content. Try again later or check the indexers.')
|
|
|
|
|
if len(results) > len(releases):
|
|
|
|
|
message += ' Duplicate results are combined; up to 200 ranked releases are shown.'
|
|
|
|
|
if total_missing:
|
|
|
|
|
message += f' Searched episodes {offset + 1}?{min(offset + 20, total_missing)} of {total_missing} missing monitored episodes.'
|
|
|
|
|
await asyncio.to_thread(save_action, request_id, 'search_releases', 'Search and choose a download', 'ok', message)
|
|
|
|
|
return {'status': 'ok', 'collector': collector, 'qualityFiltered': False, 'message': message,
|
|
|
|
|
'outcome': 'matches' if approved else 'attention', 'rejectionReasons': rejection_reasons,
|
|
|
|
|
'canIgnoreProfileLimits': override_allowed, 'nextOffset': next_offset,
|
|
|
|
|
'totalMissingEpisodes': total_missing, 'releases': releases}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/{request_id}/actions/search_auto")
|
|
|
|
@@ -3473,23 +3426,10 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
|
|
|
|
|
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
|
|
|
|
if not client.configured():
|
|
|
|
|
raise HTTPException(status_code=400, detail="Sonarr not configured")
|
|
|
|
|
target_profile_id = _quality_profile_id(runtime.sonarr_quality_profile_id)
|
|
|
|
|
current_profile_id = _quality_profile_id(arr_item.get("qualityProfileId"))
|
|
|
|
|
profile_message = None
|
|
|
|
|
series_id = _quality_profile_id(arr_item.get("id"))
|
|
|
|
|
if target_profile_id and series_id and current_profile_id != target_profile_id:
|
|
|
|
|
series = await client.get_series(series_id)
|
|
|
|
|
if not isinstance(series, dict):
|
|
|
|
|
raise HTTPException(status_code=502, detail="Could not load Sonarr series before search")
|
|
|
|
|
series["qualityProfileId"] = target_profile_id
|
|
|
|
|
await client.update_series(series)
|
|
|
|
|
profile_message = f"Sonarr quality profile updated to {target_profile_id} before search."
|
|
|
|
|
episodes = await client.get_episodes(int(arr_item["id"]))
|
|
|
|
|
missing_by_season = _missing_episode_ids_by_season(episodes)
|
|
|
|
|
if not missing_by_season:
|
|
|
|
|
message = "No missing monitored episodes found."
|
|
|
|
|
if profile_message:
|
|
|
|
|
message = f"{profile_message} {message}"
|
|
|
|
|
await asyncio.to_thread(
|
|
|
|
|
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
|
|
|
|
|
)
|
|
|
|
@@ -3504,8 +3444,6 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
|
|
|
|
|
)
|
|
|
|
|
outcome = await series_search_outcome(client, int(arr_item["id"]), [item['response'] for item in responses])
|
|
|
|
|
message = outcome['message']
|
|
|
|
|
if profile_message:
|
|
|
|
|
message = f"{profile_message} {message}"
|
|
|
|
|
await asyncio.to_thread(
|
|
|
|
|
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
|
|
|
|
|
)
|
|
|
|
@@ -3514,27 +3452,9 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
|
|
|
|
|
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
|
|
|
|
if not client.configured():
|
|
|
|
|
raise HTTPException(status_code=400, detail="Radarr not configured")
|
|
|
|
|
target_profile_id = _quality_profile_id(runtime.radarr_quality_profile_id)
|
|
|
|
|
current_profile_id = _quality_profile_id(arr_item.get("qualityProfileId"))
|
|
|
|
|
if current_profile_id and current_profile_id != target_profile_id:
|
|
|
|
|
profiles = await client.get_quality_profiles()
|
|
|
|
|
current = next((p for p in profiles if p.get("id") == current_profile_id), {})
|
|
|
|
|
if is_original_profile(current):
|
|
|
|
|
target_profile_id = current_profile_id
|
|
|
|
|
profile_message = None
|
|
|
|
|
movie_id = _quality_profile_id(arr_item.get("id"))
|
|
|
|
|
if target_profile_id and movie_id and current_profile_id != target_profile_id:
|
|
|
|
|
movie = await client.get_movie(movie_id)
|
|
|
|
|
if not isinstance(movie, dict):
|
|
|
|
|
raise HTTPException(status_code=502, detail="Could not load Radarr movie before search")
|
|
|
|
|
movie["qualityProfileId"] = target_profile_id
|
|
|
|
|
await client.update_movie(movie)
|
|
|
|
|
profile_message = f"Radarr quality profile updated to {target_profile_id} before search."
|
|
|
|
|
response = await client.search(int(arr_item["id"]))
|
|
|
|
|
outcome = await movie_search_outcome(client, int(arr_item["id"]), response)
|
|
|
|
|
message = outcome['message']
|
|
|
|
|
if profile_message:
|
|
|
|
|
message = f"{profile_message} {message}"
|
|
|
|
|
await asyncio.to_thread(
|
|
|
|
|
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
|
|
|
|
|
)
|
|
|
|
@@ -3786,11 +3706,16 @@ async def action_grab(
|
|
|
|
|
if not arr_client.configured():
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"{service_label} not configured")
|
|
|
|
|
|
|
|
|
|
arr_item = snapshot.raw.get('arr', {}).get('item') or {}
|
|
|
|
|
source = runtime.sonarr_base_url if service_label == 'Sonarr' else runtime.radarr_base_url
|
|
|
|
|
receipt = manual_releases.verify_selection(payload, request_id, user, source, arr_item.get('id'))
|
|
|
|
|
release_title = receipt.get('title')
|
|
|
|
|
arr_error: Optional[str] = None
|
|
|
|
|
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."
|
|
|
|
|
+ (' Profile limits explicitly overridden: ' + '; '.join(receipt['rejections']) if receipt['override'] else '')
|
|
|
|
|
)
|
|
|
|
|
await asyncio.to_thread(
|
|
|
|
|
save_action, request_id, "grab", "Download selected release", "ok", action_message
|
|
|
|
@@ -3804,40 +3729,8 @@ async def action_grab(
|
|
|
|
|
_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,
|
|
|
|
|
)
|
|
|
|
|
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 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},
|
|
|
|
|
}
|
|
|
|
|
arr_error = rejection or f"{service_label} rejected the selected release"
|
|
|
|
|
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)
|
|
|
|
|
if status_code == 404:
|
|
|
|
|
raise HTTPException(409, 'The collector no longer has this release cached. Search again before downloading.') from exc
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.exception("%s release grab failed request_id=%s", service_label, request_id)
|
|
|
|
|
arr_error = str(exc)
|
|
|
|
|