Align missing-episode searches and permit reviewed profile overrides
This commit is contained in:
+61
-168
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user