Align missing-episode searches and permit reviewed profile overrides
Magent CI/CD / deploy-beta (push) Successful in 50s
Magent CI/CD / verify (push) Successful in 2m4s
Magent CI/CD / deploy-prod (push) Skipped

This commit is contained in:
2026-09-11 20:26:57 +12:00
parent de25255ea8
commit 4f7853b17b
13 changed files with 376 additions and 200 deletions
+61 -168
View File
@@ -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)