Align missing-episode searches and permit reviewed profile overrides
This commit is contained in:
@@ -66,6 +66,9 @@ class SonarrClient(ApiClient):
|
|||||||
timeout_seconds=90.0,
|
timeout_seconds=90.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def search_episode_releases(self, episode_id: int) -> Optional[Any]:
|
||||||
|
return await self.get('/api/v3/release', params={'episodeId': episode_id}, 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})
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"""Live account permissions. Invite access uses the existing users column."""
|
"""Live account permissions. Invite access uses the existing users column."""
|
||||||
from .db import _connect
|
from .db import _connect
|
||||||
|
|
||||||
FEATURES = ("stats", "requests", "new_requests", "issues", "invites")
|
FEATURES = ("stats", "requests", "new_requests", "issues", "invites", "ignore_profile_limits")
|
||||||
|
|
||||||
|
|
||||||
def permissions(user: dict) -> dict[str, bool]:
|
def permissions(user: dict) -> dict[str, bool]:
|
||||||
if user.get("role") == "admin":
|
if user.get("role") == "admin":
|
||||||
return dict.fromkeys(FEATURES, True)
|
return dict.fromkeys(FEATURES, True)
|
||||||
values = dict.fromkeys(FEATURES, True)
|
values = dict.fromkeys(FEATURES, True)
|
||||||
|
values["ignore_profile_limits"] = False
|
||||||
values["invites"] = bool(user.get("invite_management_enabled", False))
|
values["invites"] = bool(user.get("invite_management_enabled", False))
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
rows = conn.execute("""SELECT p.feature, p.enabled FROM user_feature_permissions p
|
rows = conn.execute("""SELECT p.feature, p.enabled FROM user_feature_permissions p
|
||||||
|
|||||||
+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 ..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 ..feature_guards import require_request_access
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
@@ -1658,34 +1659,7 @@ def _format_rejections(rejections: Any) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _release_push_accepted(response: Any) -> tuple[bool, Optional[str]]:
|
def _filter_arr_release_results(results: Any, include_rejected: bool = False) -> List[Dict[str, Any]]:
|
||||||
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]]:
|
|
||||||
if not isinstance(results, list):
|
if not isinstance(results, list):
|
||||||
return []
|
return []
|
||||||
keep: List[Dict[str, Any]] = []
|
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"))
|
key = (item.get("indexerId"), item.get("guid"))
|
||||||
if not key[0] or not key[1] or key in seen:
|
if not key[0] or not key[1] or key in seen:
|
||||||
continue
|
continue
|
||||||
rejections = item.get("rejections")
|
accepted, override, reasons = manual_releases.decision(item)
|
||||||
if (
|
if not accepted and not include_rejected:
|
||||||
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)
|
|
||||||
):
|
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
quality_payload = item.get("quality")
|
quality_payload = item.get("quality")
|
||||||
@@ -1728,7 +1696,7 @@ def _filter_arr_release_results(results: Any) -> List[Dict[str, Any]]:
|
|||||||
"downloadUrl": item.get("downloadUrl"),
|
"downloadUrl": item.get("downloadUrl"),
|
||||||
"magnetUrl": item.get("magnetUrl"),
|
"magnetUrl": item.get("magnetUrl"),
|
||||||
"protocol": item.get("protocol"),
|
"protocol": item.get("protocol"),
|
||||||
"approved": item.get("approved"),
|
"approved": accepted,
|
||||||
"rejected": item.get("rejected"),
|
"rejected": item.get("rejected"),
|
||||||
"temporarilyRejected": item.get("temporarilyRejected"),
|
"temporarilyRejected": item.get("temporarilyRejected"),
|
||||||
"rejections": item.get("rejections"),
|
"rejections": item.get("rejections"),
|
||||||
@@ -1736,46 +1704,20 @@ def _filter_arr_release_results(results: Any) -> List[Dict[str, Any]]:
|
|||||||
"fullSeason": item.get("fullSeason"),
|
"fullSeason": item.get("fullSeason"),
|
||||||
"seasonNumber": item.get("seasonNumber"),
|
"seasonNumber": item.get("seasonNumber"),
|
||||||
"quality": quality_name,
|
"quality": quality_name,
|
||||||
|
"requiresOverride": override,
|
||||||
|
"selectable": accepted or override,
|
||||||
|
"rejections": reasons,
|
||||||
|
"episodeNumbers": item.get("mappedEpisodeNumbers") or item.get("episodeNumbers"),
|
||||||
"customFormatScore": item.get("customFormatScore"),
|
"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):
|
for index, release in enumerate(releases):
|
||||||
release["bestPick"] = index == 0
|
release["bestPick"] = index == 0 and release.get("approved") is True
|
||||||
return releases
|
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]]:
|
def _missing_episode_ids_by_season(episodes: Any) -> Dict[int, List[int]]:
|
||||||
if not isinstance(episodes, list):
|
if not isinstance(episodes, list):
|
||||||
return {}
|
return {}
|
||||||
@@ -3356,7 +3298,11 @@ async def accept_request_language(request_id: str, payload: dict, user: dict = D
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{request_id}/actions/search")
|
@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()
|
runtime = get_runtime_settings()
|
||||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||||
if client.configured():
|
if client.configured():
|
||||||
@@ -3387,15 +3333,23 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
|
|||||||
message,
|
message,
|
||||||
)
|
)
|
||||||
return {"status": "ok", "message": message, "collector": collector, "releases": []}
|
return {"status": "ok", "message": message, "collector": collector, "releases": []}
|
||||||
searches = await asyncio.gather(
|
missing_ids = [identity for season in season_numbers for identity in missing_by_season[season]]
|
||||||
*(sonarr.search_releases(int(arr_item["id"]), season) for season in season_numbers)
|
total_missing = len(missing_ids)
|
||||||
)
|
batch = missing_ids[offset:offset + 20]
|
||||||
season_results = [item for item in searches if isinstance(item, list)]
|
next_offset = offset + len(batch) if offset + len(batch) < total_missing else None
|
||||||
longest_result = max((len(item) for item in season_results), default=0)
|
semaphore = asyncio.Semaphore(3)
|
||||||
for position in range(longest_result):
|
async def search_episode(identity):
|
||||||
for search_results in season_results:
|
async with semaphore:
|
||||||
if position < len(search_results):
|
found = await sonarr.search_episode_releases(identity)
|
||||||
results.append(search_results[position])
|
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:
|
elif snapshot.request_type == RequestType.movie:
|
||||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||||
if not radarr.configured():
|
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
|
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]
|
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."
|
message = (f'{len(releases)} releases shown; {approved} meet the assigned profile. Review the reasons on other releases.'
|
||||||
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.'))
|
if releases else 'No releases were returned for the missing content. Try again later or check the indexers.')
|
||||||
|
if len(results) > len(releases):
|
||||||
await asyncio.to_thread(
|
message += ' Duplicate results are combined; up to 200 ranked releases are shown.'
|
||||||
save_action,
|
if total_missing:
|
||||||
request_id,
|
message += f' Searched episodes {offset + 1}?{min(offset + 20, total_missing)} of {total_missing} missing monitored episodes.'
|
||||||
"search_releases",
|
await asyncio.to_thread(save_action, request_id, 'search_releases', 'Search and choose a download', 'ok', message)
|
||||||
"Search and choose a download",
|
return {'status': 'ok', 'collector': collector, 'qualityFiltered': False, 'message': message,
|
||||||
"ok",
|
'outcome': 'matches' if approved else 'attention', 'rejectionReasons': rejection_reasons,
|
||||||
f"{collector} approved {len(releases)} releases against its assigned quality profile.",
|
'canIgnoreProfileLimits': override_allowed, 'nextOffset': next_offset,
|
||||||
)
|
'totalMissingEpisodes': total_missing, 'releases': releases}
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"collector": collector,
|
|
||||||
"qualityFiltered": True,
|
|
||||||
"message": result_message,
|
|
||||||
"outcome": "matches" if releases else "attention",
|
|
||||||
"rejectionReasons": rejection_reasons,
|
|
||||||
"releases": releases,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{request_id}/actions/search_auto")
|
@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)
|
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||||
if not client.configured():
|
if not client.configured():
|
||||||
raise HTTPException(status_code=400, detail="Sonarr not 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"]))
|
episodes = await client.get_episodes(int(arr_item["id"]))
|
||||||
missing_by_season = _missing_episode_ids_by_season(episodes)
|
missing_by_season = _missing_episode_ids_by_season(episodes)
|
||||||
if not missing_by_season:
|
if not missing_by_season:
|
||||||
message = "No missing monitored episodes found."
|
message = "No missing monitored episodes found."
|
||||||
if profile_message:
|
|
||||||
message = f"{profile_message} {message}"
|
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
|
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])
|
outcome = await series_search_outcome(client, int(arr_item["id"]), [item['response'] for item in responses])
|
||||||
message = outcome['message']
|
message = outcome['message']
|
||||||
if profile_message:
|
|
||||||
message = f"{profile_message} {message}"
|
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
|
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)
|
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||||
if not client.configured():
|
if not client.configured():
|
||||||
raise HTTPException(status_code=400, detail="Radarr not 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"]))
|
response = await client.search(int(arr_item["id"]))
|
||||||
outcome = await movie_search_outcome(client, int(arr_item["id"]), response)
|
outcome = await movie_search_outcome(client, int(arr_item["id"]), response)
|
||||||
message = outcome['message']
|
message = outcome['message']
|
||||||
if profile_message:
|
|
||||||
message = f"{profile_message} {message}"
|
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
|
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():
|
if not arr_client.configured():
|
||||||
raise HTTPException(status_code=400, detail=f"{service_label} not 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
|
arr_error: Optional[str] = None
|
||||||
try:
|
try:
|
||||||
response = await arr_client.grab_release(str(guid), arr_indexer_id)
|
response = await arr_client.grab_release(str(guid), arr_indexer_id)
|
||||||
action_message = (
|
action_message = (
|
||||||
f"{release_title or 'Selected release'} was sent through {service_label} for download and import."
|
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(
|
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
|
||||||
@@ -3804,40 +3729,8 @@ async def action_grab(
|
|||||||
_log_arr_http_error(service_label, "release grab", exc)
|
_log_arr_http_error(service_label, "release grab", exc)
|
||||||
status_code = exc.response.status_code if exc.response is not None else None
|
status_code = exc.response.status_code if exc.response is not None else None
|
||||||
arr_error = _format_upstream_error(service_label, exc)
|
arr_error = _format_upstream_error(service_label, exc)
|
||||||
push_payload = _build_release_push_payload(payload) if status_code == 404 else None
|
if status_code == 404:
|
||||||
if push_payload is not None:
|
raise HTTPException(409, 'The collector no longer has this release cached. Search again before downloading.') from exc
|
||||||
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)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("%s release grab failed request_id=%s", service_label, request_id)
|
logger.exception("%s release grab failed request_id=%s", service_label, request_id)
|
||||||
arr_error = str(exc)
|
arr_error = str(exc)
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ def build_preview(report, local, runtime, state, user_id, keep_id=None):
|
|||||||
kept = next(account for account in accounts if account['id'] == keep_id)
|
kept = next(account for account in accounts if account['id'] == keep_id)
|
||||||
overrides = {(entry['user_id'], entry['feature']): bool(entry['enabled']) for entry in state['user_feature_permissions']}
|
overrides = {(entry['user_id'], entry['feature']): bool(entry['enabled']) for entry in state['user_feature_permissions']}
|
||||||
features = {key: all(bool(account['invite_management_enabled']) if key == 'invites' else
|
features = {key: all(bool(account['invite_management_enabled']) if key == 'invites' else
|
||||||
overrides.get((account['id'], key), True) for account in accounts) for key in FEATURES}
|
overrides.get((account['id'], key), key != 'ignore_profile_limits') for account in accounts) for key in FEATURES}
|
||||||
expiries = [account['expires_at'] for account in accounts if account['expires_at']]
|
expiries = [account['expires_at'] for account in accounts if account['expires_at']]
|
||||||
try:
|
try:
|
||||||
expiry = min(expiries, key=lambda value: db._parse_datetime_value(value).timestamp()) if expiries else None
|
expiry = min(expiries, key=lambda value: db._parse_datetime_value(value).timestamp()) if expiries else None
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Manual collector decisions and short-lived, request-bound selection receipts."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import hashlib
|
||||||
|
import jwt
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def can_override(user):
|
||||||
|
return user.get('role') == 'admin' or (user.get('features') or {}).get('ignore_profile_limits') is True
|
||||||
|
|
||||||
|
|
||||||
|
def decision(item):
|
||||||
|
reasons = [str(r) for r in (item.get('rejections') or [])]
|
||||||
|
accepted = (item.get('approved') is True and not reasons and not item.get('rejected')
|
||||||
|
and not item.get('temporarilyRejected') and item.get('downloadAllowed') is not False)
|
||||||
|
# Unknown/operational rejections remain blocked. This permission only relaxes profile limits.
|
||||||
|
profile_only = bool(reasons) and all(any(term in reason.lower() for term in (
|
||||||
|
'quality profile', 'not wanted in profile', 'custom format', 'minimum score',
|
||||||
|
'quality is not', 'quality for', 'language', 'maximum size', 'minimum size',
|
||||||
|
'larger than', 'smaller than', 'size limit', 'release profile',
|
||||||
|
)) for reason in reasons)
|
||||||
|
override = not accepted and profile_only and item.get('downloadAllowed') is not False and not item.get('temporarilyRejected')
|
||||||
|
return accepted, override, reasons
|
||||||
|
|
||||||
|
|
||||||
|
def source_id(url):
|
||||||
|
return hashlib.sha256(str(url).rstrip('/').encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def issue_selection(release, request_id, user, source, item_id):
|
||||||
|
return jwt.encode({'aud': 'manual-release', 'sub': user['username'], 'request': str(request_id),
|
||||||
|
'source': source_id(source), 'item': item_id, 'guid': release['guid'],
|
||||||
|
'indexer': release['indexerId'], 'title': release.get('title'),
|
||||||
|
'override': release['requiresOverride'], 'rejections': release['rejections'],
|
||||||
|
'exp': datetime.now(timezone.utc) + timedelta(minutes=10)},
|
||||||
|
settings.jwt_secret, algorithm='HS256')
|
||||||
|
|
||||||
|
|
||||||
|
def verify_selection(payload, request_id, user, source, item_id):
|
||||||
|
try:
|
||||||
|
receipt = jwt.decode(payload.get('selectionToken', ''), settings.jwt_secret,
|
||||||
|
algorithms=['HS256'], audience='manual-release')
|
||||||
|
except jwt.InvalidTokenError as exc:
|
||||||
|
raise HTTPException(409, 'This release selection expired or is invalid. Search again before downloading.') from exc
|
||||||
|
if (receipt.get('sub') != user.get('username') or receipt.get('request') != str(request_id)
|
||||||
|
or receipt.get('source') != source_id(source) or receipt.get('item') != item_id
|
||||||
|
or receipt.get('guid') != payload.get('guid') or receipt.get('indexer') != payload.get('indexerId')):
|
||||||
|
raise HTTPException(409, 'This release does not belong to this account and request. Search again.')
|
||||||
|
if receipt.get('override'):
|
||||||
|
if not can_override(user):
|
||||||
|
raise HTTPException(403, 'Ignore profile limits is disabled for your account.')
|
||||||
|
if payload.get('ignoreProfileLimits') is not True:
|
||||||
|
raise HTTPException(400, 'Explicitly confirm ignoring the profile limits for this release.')
|
||||||
|
return receipt
|
||||||
@@ -1128,6 +1128,18 @@ class ArrAddPayloadTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from backend.app.config import settings
|
||||||
|
secret = patch.object(settings, 'jwt_secret', 'manual-release-tests-secret-1234567890123456')
|
||||||
|
secret.start()
|
||||||
|
self.addCleanup(secret.stop)
|
||||||
|
|
||||||
|
def selection(self, payload, request_id, source):
|
||||||
|
payload['selectionToken'] = requests_router.manual_releases.issue_selection(
|
||||||
|
{**payload, 'requiresOverride': False, 'rejections': []}, request_id,
|
||||||
|
{'username': 'viewer'}, source, None)
|
||||||
|
return payload
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _runtime() -> SimpleNamespace:
|
def _runtime() -> SimpleNamespace:
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
@@ -1155,7 +1167,7 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
{"id": 202, "seasonNumber": 2, "monitored": True, "hasFile": True},
|
{"id": 202, "seasonNumber": 2, "monitored": True, "hasFile": True},
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
search_releases=AsyncMock(
|
search_episode_releases=AsyncMock(
|
||||||
side_effect=[
|
side_effect=[
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -1194,15 +1206,17 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"3909", user={"username": "viewer", "role": "user"}
|
"3909", user={"username": "viewer", "role": "user"}
|
||||||
)
|
)
|
||||||
|
|
||||||
sonarr.search_releases.assert_any_await(42, 1)
|
sonarr.search_episode_releases.assert_any_await(101)
|
||||||
sonarr.search_releases.assert_any_await(42, 2)
|
sonarr.search_episode_releases.assert_any_await(201)
|
||||||
self.assertEqual(result["collector"], "Sonarr")
|
self.assertEqual(result["collector"], "Sonarr")
|
||||||
self.assertEqual(len(result["releases"]), 1)
|
self.assertEqual(len(result["releases"]), 2)
|
||||||
self.assertTrue(result["releases"][0]["fullSeason"])
|
self.assertTrue(result["releases"][0]["fullSeason"])
|
||||||
self.assertEqual(result["releases"][0]["seasonNumber"], 1)
|
self.assertEqual(result["releases"][0]["seasonNumber"], 1)
|
||||||
self.assertEqual(result["releases"][0]["quality"], "WEBDL-1080p")
|
self.assertEqual(result["releases"][0]["quality"], "WEBDL-1080p")
|
||||||
self.assertTrue(result["releases"][0]["bestPick"])
|
self.assertTrue(result["releases"][0]["bestPick"])
|
||||||
self.assertTrue(result["qualityFiltered"])
|
self.assertFalse(result["qualityFiltered"])
|
||||||
|
self.assertNotIn("selectionToken", result["releases"][1])
|
||||||
|
self.assertTrue(result["releases"][1]["requiresOverride"])
|
||||||
|
|
||||||
async def test_movie_manual_search_uses_radarr(self) -> None:
|
async def test_movie_manual_search_uses_radarr(self) -> None:
|
||||||
snapshot = Snapshot(
|
snapshot = Snapshot(
|
||||||
@@ -1292,14 +1306,14 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
requests_router, "save_action"
|
requests_router, "save_action"
|
||||||
):
|
):
|
||||||
result = await requests_router.action_grab(
|
result = await requests_router.action_grab(
|
||||||
"3909", payload, user={"username": "viewer", "role": "user"}
|
"3909", self.selection(payload, "3909", self._runtime().sonarr_base_url), user={"username": "viewer", "role": "user"}
|
||||||
)
|
)
|
||||||
|
|
||||||
sonarr.grab_release.assert_awaited_once_with("season-one", 7)
|
sonarr.grab_release.assert_awaited_once_with("season-one", 7)
|
||||||
sonarr.push_release.assert_not_awaited()
|
sonarr.push_release.assert_not_awaited()
|
||||||
self.assertEqual(result["response"], {"collector": "Sonarr", "queued": True})
|
self.assertEqual(result["response"], {"collector": "Sonarr", "queued": True})
|
||||||
|
|
||||||
async def test_stale_movie_release_still_routes_through_radarr_push(self) -> None:
|
async def test_stale_movie_release_requires_fresh_search(self) -> None:
|
||||||
snapshot = Snapshot(
|
snapshot = Snapshot(
|
||||||
request_id="4000",
|
request_id="4000",
|
||||||
title="Example Movie",
|
title="Example Movie",
|
||||||
@@ -1335,15 +1349,12 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
), patch.object(requests_router, "RadarrClient", return_value=radarr), patch.object(
|
), patch.object(requests_router, "RadarrClient", return_value=radarr), patch.object(
|
||||||
requests_router, "save_action"
|
requests_router, "save_action"
|
||||||
):
|
):
|
||||||
result = await requests_router.action_grab(
|
with self.assertRaises(HTTPException) as error:
|
||||||
"4000", payload, user={"username": "viewer", "role": "user"}
|
await requests_router.action_grab(
|
||||||
)
|
"4000", self.selection(payload, "4000", self._runtime().radarr_base_url), user={"username": "viewer", "role": "user"})
|
||||||
|
self.assertEqual(error.exception.status_code, 409)
|
||||||
|
radarr.push_release.assert_not_awaited()
|
||||||
|
|
||||||
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):
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
|
|||||||
self.client.headers['Authorization'] = 'Bearer ' + create_access_token(self.user['username'], 'user')
|
self.client.headers['Authorization'] = 'Bearer ' + create_access_token(self.user['username'], 'user')
|
||||||
|
|
||||||
def test_defaults_persist_and_invites_share_existing_setting(self):
|
def test_defaults_persist_and_invites_share_existing_setting(self):
|
||||||
self.assertEqual(permissions(self.user), dict(stats=True, requests=True, new_requests=True, issues=True, invites=False))
|
self.assertEqual(permissions(self.user), dict(stats=True, requests=True, new_requests=True, issues=True, invites=False, ignore_profile_limits=False))
|
||||||
update_permissions({'stats': False, 'invites': True}, self.user['username'])
|
update_permissions({'stats': False, 'invites': True}, self.user['username'])
|
||||||
db.init_db()
|
db.init_db()
|
||||||
fresh = db.get_user_by_username(self.user['username'])
|
fresh = db.get_user_by_username(self.user['username'])
|
||||||
@@ -118,3 +118,22 @@ class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
|
|||||||
self.assertEqual(self.client.post('/portal/items', json={'kind': kind}).status_code, 403)
|
self.assertEqual(self.client.post('/portal/items', json={'kind': kind}).status_code, 403)
|
||||||
self.assertEqual(self.client.post('/portal/items', json={'kind': None}).status_code, 403)
|
self.assertEqual(self.client.post('/portal/items', json={'kind': None}).status_code, 403)
|
||||||
self.assertEqual(self.client.post('/portal/items', json={}).status_code, 403)
|
self.assertEqual(self.client.post('/portal/items', json={}).status_code, 403)
|
||||||
|
|
||||||
|
def test_manual_override_permission_is_checked_again_at_download(self):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from backend.app.models import Snapshot, RequestType
|
||||||
|
from backend.app.services import manual_releases
|
||||||
|
runtime=SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test')
|
||||||
|
snapshot=Snapshot(request_id='42',title='Example',request_type=RequestType.tv,raw={'arr':{'item':{'id':55}}})
|
||||||
|
release={'guid':'out','indexerId':1,'title':'Example','requiresOverride':True,'rejections':['Quality is not wanted in profile']}
|
||||||
|
payload={**release,'ignoreProfileLimits':True,'selectionToken':manual_releases.issue_selection(release,'42',self.user,'http://sonarr',55)}
|
||||||
|
collector=SimpleNamespace(configured=lambda:True,grab_release=AsyncMock(return_value={}))
|
||||||
|
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'build_snapshot',new=AsyncMock(return_value=snapshot)),patch.object(requests,'SonarrClient',return_value=collector),patch.object(requests,'save_action'):
|
||||||
|
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,403)
|
||||||
|
collector.grab_release.assert_not_awaited()
|
||||||
|
update_permissions({'ignore_profile_limits':True},self.user['username'])
|
||||||
|
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,200)
|
||||||
|
update_permissions({'ignore_profile_limits':False},self.user['username'])
|
||||||
|
self.assertEqual(self.client.post('/requests/42/actions/grab',json={**payload,'requiresOverride':False,'approved':True}).status_code,403)
|
||||||
|
collector.grab_release.assert_awaited_once()
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import asyncio
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from backend.app.config import settings
|
||||||
|
from backend.app.services import manual_releases as manual
|
||||||
|
from backend.app.routers import requests
|
||||||
|
from backend.app.models import Snapshot, RequestType
|
||||||
|
from backend.app.feature_access import permissions, update_permissions
|
||||||
|
from backend.app import db
|
||||||
|
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||||
|
|
||||||
|
|
||||||
|
class ManualSelectionTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
secret = patch.object(settings, 'jwt_secret', 'manual-selection-test-secret-123456789')
|
||||||
|
secret.start(); self.addCleanup(secret.stop)
|
||||||
|
self.user = {'username': 'viewer', 'role': 'user', 'features': {'ignore_profile_limits': True}}
|
||||||
|
self.release = {'guid': 'release', 'indexerId': 7, 'title': 'Example', 'requiresOverride': True,
|
||||||
|
'rejections': ['WEBDL-2160p is not wanted in profile']}
|
||||||
|
self.payload = {**self.release, 'ignoreProfileLimits': True,
|
||||||
|
'selectionToken': manual.issue_selection(self.release, '42', self.user, 'http://sonarr', 55)}
|
||||||
|
|
||||||
|
def test_profile_only_rejections_are_overridable(self):
|
||||||
|
for reason in ['WEBDL-2160p is not wanted in profile', 'Custom format score below minimum', 'File is larger than maximum size', 'Language is not wanted']:
|
||||||
|
self.assertTrue(manual.decision({'approved': False, 'rejections': [reason]})[1])
|
||||||
|
for reason in ['Unknown series', 'Release is blocklisted', 'No download client available', 'Already in queue']:
|
||||||
|
self.assertFalse(manual.decision({'rejections': [self.release['rejections'][0], reason]})[1])
|
||||||
|
self.assertFalse(manual.decision({'approved': True, 'downloadAllowed': False})[0])
|
||||||
|
|
||||||
|
def test_receipt_binds_request_user_source_item_and_release(self):
|
||||||
|
self.assertTrue(manual.verify_selection(self.payload, '42', self.user, 'http://sonarr', 55)['override'])
|
||||||
|
attempts = [({**self.payload, 'guid': 'other'}, '42', self.user, 'http://sonarr', 55),
|
||||||
|
(self.payload, '43', self.user, 'http://sonarr', 55),
|
||||||
|
(self.payload, '42', {**self.user, 'username': 'other'}, 'http://sonarr', 55),
|
||||||
|
(self.payload, '42', self.user, 'http://other', 55),
|
||||||
|
(self.payload, '42', self.user, 'http://sonarr', 56),
|
||||||
|
({**self.payload, 'selectionToken': 'forged'}, '42', self.user, 'http://sonarr', 55)]
|
||||||
|
for args in attempts:
|
||||||
|
with self.assertRaises(HTTPException): manual.verify_selection(*args)
|
||||||
|
|
||||||
|
def test_permission_revocation_and_literal_confirmation_enforced(self):
|
||||||
|
for payload, user, code in [(self.payload, {**self.user, 'features': {}}, 403),
|
||||||
|
({**self.payload, 'ignoreProfileLimits': 'true'}, self.user, 400)]:
|
||||||
|
with self.assertRaises(HTTPException) as error:
|
||||||
|
manual.verify_selection(payload, '42', user, 'http://sonarr', 55)
|
||||||
|
self.assertEqual(error.exception.status_code, code)
|
||||||
|
|
||||||
|
|
||||||
|
class ManualPermissionTests(TempDatabaseMixin, unittest.TestCase):
|
||||||
|
def test_default_off_individual_and_bulk(self):
|
||||||
|
for name in ('one', 'two'): db.create_user(name, 'Password123!', role='user')
|
||||||
|
one, two = [db.get_user_by_username(n) for n in ('one', 'two')]
|
||||||
|
self.assertFalse(permissions(one)['ignore_profile_limits'])
|
||||||
|
update_permissions({'ignore_profile_limits': True}, 'one')
|
||||||
|
self.assertTrue(permissions(one)['ignore_profile_limits'])
|
||||||
|
self.assertFalse(permissions(two)['ignore_profile_limits'])
|
||||||
|
update_permissions({'ignore_profile_limits': False})
|
||||||
|
self.assertFalse(permissions(one)['ignore_profile_limits'])
|
||||||
|
|
||||||
|
|
||||||
|
class ManualEpisodeSearchTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_episode_batch_is_bounded_and_exposes_next_page(self):
|
||||||
|
episodes = [{'id': i, 'seasonNumber': 1, 'monitored': True, 'hasFile': False} for i in range(1, 26)]
|
||||||
|
episodes += [{'id': 26, 'seasonNumber': 1, 'monitored': True, 'hasFile': True}]
|
||||||
|
active = peak = 0
|
||||||
|
async def search(identity):
|
||||||
|
nonlocal active, peak
|
||||||
|
active += 1; peak = max(peak, active)
|
||||||
|
await asyncio.sleep(0.001); active -= 1
|
||||||
|
return []
|
||||||
|
sonarr = SimpleNamespace(configured=lambda: True, get_episodes=AsyncMock(return_value=episodes), search_episode_releases=AsyncMock(side_effect=search))
|
||||||
|
runtime = SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test')
|
||||||
|
snapshot = Snapshot(request_id='42',request_type=RequestType.tv,title='Example',raw={'arr':{'item':{'id':55,'qualityProfileId':9}}})
|
||||||
|
with patch.object(requests, 'get_runtime_settings', return_value=runtime), patch.object(requests, 'build_snapshot', new=AsyncMock(return_value=snapshot)), patch.object(requests,'SonarrClient',return_value=sonarr), patch.object(requests,'save_action'):
|
||||||
|
first = await requests.action_search('42', {'username':'viewer','role':'user'})
|
||||||
|
second = await requests.action_search('42', {'username':'viewer','role':'user'}, offset=20)
|
||||||
|
self.assertEqual(first['nextOffset'],20); self.assertIsNone(second['nextOffset'])
|
||||||
|
self.assertEqual(sonarr.search_episode_releases.await_count,25)
|
||||||
|
self.assertLessEqual(peak,3)
|
||||||
|
self.assertEqual(first['totalMissingEpisodes'],25)
|
||||||
|
|
||||||
|
async def test_auto_search_preserves_current_profile(self):
|
||||||
|
for kind, service in [(RequestType.tv,'SonarrClient'),(RequestType.movie,'RadarrClient')]:
|
||||||
|
client=SimpleNamespace(configured=lambda:True, update_series=AsyncMock(), update_movie=AsyncMock(),
|
||||||
|
get_episodes=AsyncMock(return_value=[{'id':1,'seasonNumber':1,'monitored':True,'hasFile':False}]),
|
||||||
|
search_episodes=AsyncMock(return_value={'id':1}), search=AsyncMock(return_value={'id':1}))
|
||||||
|
runtime=SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test',radarr_base_url='http://radarr',radarr_api_key='test',sonarr_quality_profile_id=6,radarr_quality_profile_id=6)
|
||||||
|
snapshot=Snapshot(request_id='42',request_type=kind,title='Example',raw={'arr':{'item':{'id':55,'qualityProfileId':9}}})
|
||||||
|
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'build_snapshot',new=AsyncMock(return_value=snapshot)),patch.object(requests,service,return_value=client),patch.object(requests,'save_action'),patch.object(requests,'series_search_outcome',new=AsyncMock(return_value={'status':'attention','message':'Nothing queued'})),patch.object(requests,'movie_search_outcome',new=AsyncMock(return_value={'status':'attention','message':'Nothing queued'})):
|
||||||
|
await requests.action_search_auto('42',{'username':'admin','role':'admin'})
|
||||||
|
client.update_series.assert_not_awaited(); client.update_movie.assert_not_awaited()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Manual release selection
|
||||||
|
|
||||||
|
Manual TV searches query Sonarr by each missing monitored episode ID, with three concurrent searches and batches of 20. Season packs returned by those searches remain visible. Larger requests offer the next batch. Movie searches use the Radarr movie ID. Both manual and automatic searches retain the assigned quality profile; admin defaults apply when creating requests.
|
||||||
|
|
||||||
|
Results include rejection reasons instead of silently filtering everything out. Approved releases can be selected normally. The **Ignore profile limits** permission defaults off for non-admin users and is available in User management > Manage users and Manage this user > Feature access. Administrators retain access.
|
||||||
|
|
||||||
|
Permitted users enable the override in the release picker and explicitly confirm each out-of-profile download. Quality, size, language and custom-format/profile rejections can be overridden. Other rejection reasons remain blocked. Downloads go through Sonarr/Radarr's native manual release endpoint without modifying quality profiles or bypassing the collector.
|
||||||
|
|
||||||
|
Selections carry a ten-minute signed receipt bound to the user, request, collector, media item and release. The backend rechecks the current permission and explicit override consent on download. Expired collector caches require another search; arbitrary client-provided download URLs are not pushed upstream.
|
||||||
|
|
||||||
|
Validation covers per-episode batching, profile preservation, default-off and bulk/individual permissions, permission revocation with an existing login, forged selections, rejection classification, desktop/mobile confirmation and blocked results.
|
||||||
|
|
||||||
|
Upstream reference: [Sonarr ReleaseController](https://github.com/Sonarr/Sonarr/blob/develop/src/Sonarr.Api.V3/Indexers/ReleaseController.cs) exposes episode-specific interactive search and the collector's manual grab operation.
|
||||||
@@ -4,6 +4,7 @@ export const FEATURES = [
|
|||||||
{ key: 'new_requests', label: 'New Requests', description: 'Search for movies and TV shows and submit new requests.' },
|
{ key: 'new_requests', label: 'New Requests', description: 'Search for movies and TV shows and submit new requests.' },
|
||||||
{ key: 'issues', label: 'Issues', description: 'Report problems, follow up on issues and use available repair tools.' },
|
{ key: 'issues', label: 'Issues', description: 'Report problems, follow up on issues and use available repair tools.' },
|
||||||
{ key: 'invites', label: 'Invites', description: 'Create and manage invitations within the existing invite limits.' },
|
{ key: 'invites', label: 'Invites', description: 'Create and manage invitations within the existing invite limits.' },
|
||||||
|
{ key: 'ignore_profile_limits', label: 'Ignore profile limits', description: 'Allow explicit manual downloads outside quality, size, language or custom-format limits. Automatic searches keep the assigned profile.' },
|
||||||
] as const
|
] as const
|
||||||
export type Feature = typeof FEATURES[number]['key']
|
export type Feature = typeof FEATURES[number]['key']
|
||||||
export type FeatureAccess = Record<Feature, boolean>
|
export type FeatureAccess = Record<Feature, boolean>
|
||||||
@@ -19,5 +20,5 @@ export function canAccess(user: { role?: string; features?: Partial<FeatureAcces
|
|||||||
if (!feature) return true
|
if (!feature) return true
|
||||||
if (!user) return false
|
if (!user) return false
|
||||||
if (user.role === 'admin') return true
|
if (user.role === 'admin') return true
|
||||||
return user.features?.[feature] ?? (feature === 'invites' ? Boolean(user.invite_management_enabled) : true)
|
return user.features?.[feature] ?? (feature === 'invites' ? Boolean(user.invite_management_enabled) : feature !== 'ignore_profile_limits')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ type ReleaseOption = {
|
|||||||
quality?: string
|
quality?: string
|
||||||
customFormatScore?: number
|
customFormatScore?: number
|
||||||
approved?: boolean
|
approved?: boolean
|
||||||
|
selectionToken?: string
|
||||||
|
requiresOverride?: boolean
|
||||||
|
selectable?: boolean
|
||||||
|
rejections?: string[]
|
||||||
bestPick?: boolean
|
bestPick?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,6 +315,9 @@ export default function RequestTimelinePage() {
|
|||||||
const [busyAction, setBusyAction] = useState<string | null>(null)
|
const [busyAction, setBusyAction] = useState<string | null>(null)
|
||||||
const [releaseOptions, setReleaseOptions] = useState<ReleaseOption[]>([])
|
const [releaseOptions, setReleaseOptions] = useState<ReleaseOption[]>([])
|
||||||
const [releasePickerOpen, setReleasePickerOpen] = useState(false)
|
const [releasePickerOpen, setReleasePickerOpen] = useState(false)
|
||||||
|
const [canIgnoreProfileLimits, setCanIgnoreProfileLimits] = useState(false)
|
||||||
|
const [ignoreProfileLimits, setIgnoreProfileLimits] = useState(false)
|
||||||
|
const [nextSearchOffset, setNextSearchOffset] = useState<number | null>(null)
|
||||||
const [releaseCollector, setReleaseCollector] = useState<string | null>(null)
|
const [releaseCollector, setReleaseCollector] = useState<string | null>(null)
|
||||||
const [releaseSearchMessage, setReleaseSearchMessage] = useState<string | null>(null)
|
const [releaseSearchMessage, setReleaseSearchMessage] = useState<string | null>(null)
|
||||||
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([])
|
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([])
|
||||||
@@ -646,7 +653,7 @@ export default function RequestTimelinePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const runAction = async (action: RequestAction) => {
|
const runAction = async (action: RequestAction, searchOffset = 0) => {
|
||||||
if (action.requires_confirmation && !window.confirm(`Run “${action.label}”?`)) return
|
if (action.requires_confirmation && !window.confirm(`Run “${action.label}”?`)) return
|
||||||
const actionPaths: Record<string, string> = {
|
const actionPaths: Record<string, string> = {
|
||||||
search_releases: 'actions/search',
|
search_releases: 'actions/search',
|
||||||
@@ -661,6 +668,8 @@ export default function RequestTimelinePage() {
|
|||||||
}
|
}
|
||||||
if (action.id === 'search_releases') {
|
if (action.id === 'search_releases') {
|
||||||
setReleaseOptions([])
|
setReleaseOptions([])
|
||||||
|
setIgnoreProfileLimits(false)
|
||||||
|
setNextSearchOffset(null)
|
||||||
setReleaseCollector(snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')
|
setReleaseCollector(snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')
|
||||||
setReleaseSearchMessage(null)
|
setReleaseSearchMessage(null)
|
||||||
setReleasePickerOpen(false)
|
setReleasePickerOpen(false)
|
||||||
@@ -671,7 +680,7 @@ export default function RequestTimelinePage() {
|
|||||||
try {
|
try {
|
||||||
const response = await trackedPost(
|
const response = await trackedPost(
|
||||||
action.label,
|
action.label,
|
||||||
`${getApiBase()}/requests/${snapshot.request_id}/${path}`
|
`${getApiBase()}/requests/${snapshot.request_id}/${path}${action.id === 'search_releases' ? `?offset=${searchOffset}` : ''}`
|
||||||
)
|
)
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
clearToken()
|
clearToken()
|
||||||
@@ -683,6 +692,8 @@ export default function RequestTimelinePage() {
|
|||||||
if (action.id === 'search_releases') {
|
if (action.id === 'search_releases') {
|
||||||
const releases = Array.isArray(data.releases) ? data.releases : []
|
const releases = Array.isArray(data.releases) ? data.releases : []
|
||||||
setReleaseOptions(releases)
|
setReleaseOptions(releases)
|
||||||
|
setCanIgnoreProfileLimits(data.canIgnoreProfileLimits === true)
|
||||||
|
setNextSearchOffset(typeof data.nextOffset === 'number' ? data.nextOffset : null)
|
||||||
setReleasePickerOpen(true)
|
setReleasePickerOpen(true)
|
||||||
setReleaseCollector(data?.collector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'))
|
setReleaseCollector(data?.collector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'))
|
||||||
setReleaseSearchMessage(
|
setReleaseSearchMessage(
|
||||||
@@ -710,6 +721,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 (release.requiresOverride && (!canIgnoreProfileLimits || !ignoreProfileLimits)) return
|
||||||
|
if (release.requiresOverride && !window.confirm(`Download this release outside the assigned profile?\n\n${release.title}\n${(release.rejections || []).join('\n')}\n\nThe assigned profile will stay unchanged.`)) return
|
||||||
const collector = snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'
|
const collector = snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'
|
||||||
setBusyAction(`grab:${release.guid}`)
|
setBusyAction(`grab:${release.guid}`)
|
||||||
setActionError(null)
|
setActionError(null)
|
||||||
@@ -718,7 +731,7 @@ export default function RequestTimelinePage() {
|
|||||||
`Send release through ${collector}`,
|
`Send release through ${collector}`,
|
||||||
`${getApiBase()}/requests/${snapshot.request_id}/actions/grab`, {
|
`${getApiBase()}/requests/${snapshot.request_id}/actions/grab`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(release),
|
body: JSON.stringify({ ...release, ignoreProfileLimits: release.requiresOverride === true && ignoreProfileLimits }),
|
||||||
})
|
})
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
clearToken()
|
clearToken()
|
||||||
@@ -953,9 +966,9 @@ export default function RequestTimelinePage() {
|
|||||||
>
|
>
|
||||||
<header className="request-release-modal-header">
|
<header className="request-release-modal-header">
|
||||||
<div>
|
<div>
|
||||||
<span className="section-kicker">Approved by {releaseCollector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')}</span>
|
<span className="section-kicker">Results from {releaseCollector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')}</span>
|
||||||
<h2 id="release-picker-title">Choose an available download</h2>
|
<h2 id="release-picker-title">Choose an available download</h2>
|
||||||
<p>Only releases accepted by the title's assigned quality profile are shown.</p>
|
<p>Search results include the collector?s rejection reasons. The assigned profile stays unchanged.</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="ghost-button" onClick={closeReleasePicker} disabled={busyAction?.startsWith('grab:')}>Close</button>
|
<button type="button" className="ghost-button" onClick={closeReleasePicker} disabled={busyAction?.startsWith('grab:')}>Close</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -973,7 +986,7 @@ export default function RequestTimelinePage() {
|
|||||||
|
|
||||||
{busyAction !== 'search_releases' && releaseSearchMessage && (
|
{busyAction !== 'search_releases' && releaseSearchMessage && (
|
||||||
<div className="request-release-profile-note">
|
<div className="request-release-profile-note">
|
||||||
<strong>Quality limits applied</strong>
|
<strong>Search results</strong>
|
||||||
<span>{releaseSearchMessage}</span>
|
<span>{releaseSearchMessage}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -988,19 +1001,26 @@ export default function RequestTimelinePage() {
|
|||||||
{busyAction !== 'search_releases' && !actionError && releaseOptions.length === 0 && (
|
{busyAction !== 'search_releases' && !actionError && releaseOptions.length === 0 && (
|
||||||
<div className="request-release-empty">
|
<div className="request-release-empty">
|
||||||
<strong>No suitable downloads are available right now</strong>
|
<strong>No suitable downloads are available right now</strong>
|
||||||
<span>{releaseCollector ?? 'The collector'} did not approve anything within the assigned quality limits. Nothing outside those limits has been shown.</span>
|
<span>No releases were returned for this search. Check the indexers or try again later.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canIgnoreProfileLimits && <label className="feature-access-row">
|
||||||
|
<input type="checkbox" checked={ignoreProfileLimits} disabled={Boolean(busyAction)} onChange={event => setIgnoreProfileLimits(event.target.checked)} />
|
||||||
|
<span><strong>Ignore profile limits</strong><small>Enable selection outside quality, size, language and custom-format limits for this search. Each download requires confirmation. Other rejection reasons remain blocked.</small></span>
|
||||||
|
</label>}
|
||||||
|
{nextSearchOffset !== null && <button type="button" className="ghost-button" disabled={Boolean(busyAction)} onClick={() => void runAction({ id: 'search_releases', label: 'Search next missing episodes', risk: 'low', requires_confirmation: false }, nextSearchOffset)}>Search next missing episodes</button>}
|
||||||
{releaseOptions.length > 0 && (
|
{releaseOptions.length > 0 && (
|
||||||
<div className="request-release-list">
|
<div className="request-release-list">
|
||||||
{releaseOptions.map((release, index) => {
|
{releaseOptions.map((release) => {
|
||||||
const isBestPick = release.bestPick || index === 0
|
const isBestPick = release.bestPick === true
|
||||||
return (
|
return (
|
||||||
<article className={`request-release ${isBestPick ? 'is-best-pick' : ''}`} key={`${release.indexerId ?? ''}:${release.guid ?? release.title}`}>
|
<article className={`request-release ${isBestPick ? 'is-best-pick' : ''}`} key={`${release.indexerId ?? ''}:${release.guid ?? release.title}`}>
|
||||||
<div className="request-release-copy">
|
<div className="request-release-copy">
|
||||||
<div className="request-release-badges">
|
<div className="request-release-badges">
|
||||||
{isBestPick && <span className="request-release-best-badge">Best pick</span>}
|
{isBestPick && <span className="request-release-best-badge">Best pick</span>}
|
||||||
|
{release.requiresOverride && <span>Outside profile</span>}
|
||||||
|
{release.selectable === false && <span>Unavailable for selection</span>}
|
||||||
{release.quality && <span>{release.quality}</span>}
|
{release.quality && <span>{release.quality}</span>}
|
||||||
{release.fullSeason && <span>Season {release.seasonNumber ?? ''} pack</span>}
|
{release.fullSeason && <span>Season {release.seasonNumber ?? ''} pack</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -1009,15 +1029,18 @@ export default function RequestTimelinePage() {
|
|||||||
{release.indexer ?? 'Unknown indexer'} · {release.seeders ?? 0} seeders · {formatBytes(release.size)}
|
{release.indexer ?? 'Unknown indexer'} · {release.seeders ?? 0} seeders · {formatBytes(release.size)}
|
||||||
{typeof release.customFormatScore === 'number' ? ` · Score ${release.customFormatScore}` : ''}
|
{typeof release.customFormatScore === 'number' ? ` · Score ${release.customFormatScore}` : ''}
|
||||||
</span>
|
</span>
|
||||||
|
{!!release.rejections?.length && <small className="release-rejection-reasons">{release.rejections.join(' ? ')}</small>}
|
||||||
{isBestPick && <small>This is the highest-ranked release approved by {releaseCollector ?? 'the collector'}.</small>}
|
{isBestPick && <small>This is the highest-ranked release approved by {releaseCollector ?? 'the collector'}.</small>}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={Boolean(busyAction) || !release.guid || !release.indexerId}
|
disabled={Boolean(busyAction) || !release.selectionToken || release.selectable === false || (release.requiresOverride && (!canIgnoreProfileLimits || !ignoreProfileLimits))}
|
||||||
onClick={() => void downloadRelease(release)}
|
onClick={() => void downloadRelease(release)}
|
||||||
>
|
>
|
||||||
{busyAction === `grab:${release.guid}`
|
{busyAction === `grab:${release.guid}`
|
||||||
? 'Sending…'
|
? 'Sending…'
|
||||||
|
: release.requiresOverride
|
||||||
|
? 'Download outside profile'
|
||||||
: isBestPick
|
: isBestPick
|
||||||
? 'Download best pick'
|
? 'Download best pick'
|
||||||
: 'Download this release'}
|
: 'Download this release'}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const base = process.env.REVIEW_BASE || 'http://localhost:3114';
|
|||||||
try {
|
try {
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]);
|
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]);
|
||||||
const all = { stats: true, requests: true, new_requests: true, issues: true, invites: true };
|
const all = { stats: true, requests: true, new_requests: true, issues: true, invites: true, ignore_profile_limits: false };
|
||||||
const viewer = { id: 2, username: 'Georgia', role: 'user', email: 'georgia@example.test', features: { ...all }, stats: { total: 12, ready: 7, in_progress: 5 } };
|
const viewer = { id: 2, username: 'Georgia', role: 'user', email: 'georgia@example.test', features: { ...all }, stats: { total: 12, ready: 7, in_progress: 5 } };
|
||||||
const other = { ...viewer, id: 3, username: 'Other viewer', features: { ...all, issues: false } };
|
const other = { ...viewer, id: 3, username: 'Other viewer', features: { ...all, issues: false } };
|
||||||
let signedIn = { username: 'Admin', role: 'admin', features: all };
|
let signedIn = { username: 'Admin', role: 'admin', features: all };
|
||||||
@@ -38,16 +38,21 @@ const base = process.env.REVIEW_BASE || 'http://localhost:3114';
|
|||||||
const dialog = page.getByRole('dialog');
|
const dialog = page.getByRole('dialog');
|
||||||
await dialog.getByRole('checkbox', { name: /My Stats/ }).waitFor();
|
await dialog.getByRole('checkbox', { name: /My Stats/ }).waitFor();
|
||||||
assert(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth), `No modal overflow at ${width}`);
|
assert(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth), `No modal overflow at ${width}`);
|
||||||
assert.equal(await dialog.getByRole('checkbox').count(), 7);
|
assert.equal(await dialog.getByRole('checkbox').count(), 8);
|
||||||
assert(await dialog.getByText('Restrict access or delete accounts', { exact: true }).count());
|
assert(await dialog.getByText('Restrict access or delete accounts', { exact: true }).count());
|
||||||
await dialog.getByRole('checkbox', { name: /^Issues/ }).uncheck();
|
await dialog.getByRole('checkbox', { name: /^Issues/ }).uncheck();
|
||||||
await dialog.getByRole('button', { name: 'Save feature access', exact: true }).click();
|
await dialog.getByRole('button', { name: 'Save feature access', exact: true }).click();
|
||||||
await dialog.getByRole('status').filter({ hasText: 'Feature access saved.' }).waitFor();
|
await dialog.getByRole('status').filter({ hasText: 'Feature access saved.' }).waitFor();
|
||||||
assert.deepEqual(writes.at(-1).payload, { issues: false });
|
assert.deepEqual(writes.at(-1).payload, { issues: false });
|
||||||
|
await dialog.getByRole('checkbox', { name: /^Ignore profile limits/ }).check();
|
||||||
|
await dialog.getByRole('button', { name: 'Save feature access', exact: true }).click();
|
||||||
|
await dialog.getByRole('status').filter({ hasText: 'Feature access saved.' }).waitFor();
|
||||||
|
assert.deepEqual(writes.at(-1).payload, { ignore_profile_limits: true });
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await dialog.waitFor({ state: 'hidden' });
|
await dialog.waitFor({ state: 'hidden' });
|
||||||
assert(await page.getByRole('button', { name: 'Manage this user', exact: true }).evaluate((element) => element === document.activeElement));
|
assert(await page.getByRole('button', { name: 'Manage this user', exact: true }).evaluate((element) => element === document.activeElement));
|
||||||
viewer.features.issues = true;
|
viewer.features.issues = true;
|
||||||
|
viewer.features.ignore_profile_limits = false;
|
||||||
}
|
}
|
||||||
await page.goto(`${base}/users`);
|
await page.goto(`${base}/users`);
|
||||||
await page.getByRole('button', { name: 'Manage users', exact: true }).click();
|
await page.getByRole('button', { name: 'Manage users', exact: true }).click();
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { chromium } = require(process.env.PLAYWRIGHT_PACKAGE || 'playwright');
|
||||||
|
const base = process.env.REVIEW_BASE || 'http://localhost:3114';
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext();
|
||||||
|
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]);
|
||||||
|
let allowed = true; const writes = [], errors = [];
|
||||||
|
await context.route('**/api/**', async route => {
|
||||||
|
const request = route.request(), path = new URL(request.url()).pathname;
|
||||||
|
const reply = json => route.fulfill({ json });
|
||||||
|
if (path === '/api/auth/me') return reply({ username: 'Viewer', role: 'user', features: { requests: true, ignore_profile_limits: allowed } });
|
||||||
|
if (path.endsWith('/snapshot')) return reply({ request_id: '42', title: 'Trying', request_type: 'tv', state: 'ADDED_TO_ARR', timeline: [], actions: [{ id: 'search_releases', label: 'Search and choose a download', requires_confirmation: false }], presentation: { status: { label: 'Waiting', meaning: 'Two missing episodes.' }, nextStep: { title: 'Search', description: 'Find missing episodes.', actionIds: ['search_releases'] }, pipeline: [] } });
|
||||||
|
if (path.endsWith('/language')) return reply({ language: null });
|
||||||
|
if (path.includes('/operations/')) return reply({ id: path.split('/').pop(), status: 'complete', events: [] });
|
||||||
|
if (path.endsWith('/actions/search')) return reply({ status: 'ok', outcome: 'attention', canIgnoreProfileLimits: allowed, nextOffset: 20, message: '2 releases found; none meet the assigned profile.', releases: [
|
||||||
|
{ guid: 'out', indexerId: 1, title: 'Trying.S05E03.2160p', quality: 'WEBDL-2160p', requiresOverride: true, selectable: true, selectionToken: allowed ? 'signed-fixture' : undefined, rejections: ['WEBDL-2160p is not wanted in profile'] },
|
||||||
|
{ guid: 'wrong', indexerId: 1, title: 'Wrong.Show.S05E03', selectable: false, rejections: ['Unknown series'] },
|
||||||
|
] });
|
||||||
|
if (path.endsWith('/actions/grab')) { writes.push(request.postDataJSON()); return reply({ status: 'ok', message: 'Selected release sent to Sonarr.' }); }
|
||||||
|
if (path.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' });
|
||||||
|
return reply({});
|
||||||
|
});
|
||||||
|
const page = await context.newPage(); page.on('pageerror', e => errors.push(e.message));
|
||||||
|
for (const width of [1440, 390]) {
|
||||||
|
for (const permission of [true, false]) {
|
||||||
|
allowed = permission; await page.setViewportSize({ width, height: 950 });
|
||||||
|
await page.goto(base + '/requests/42');
|
||||||
|
await page.getByRole('button', { name: 'Search and choose a download', exact: true }).first().click();
|
||||||
|
await page.getByRole('button', { name: 'Dismiss activity' }).click();
|
||||||
|
const picker = page.getByRole('dialog', { name: 'Choose an available download' }); await picker.waitFor();
|
||||||
|
await picker.getByText('WEBDL-2160p is not wanted in profile', { exact: true }).waitFor();
|
||||||
|
assert.equal(await picker.getByText('Best pick', { exact: true }).count(), 0);
|
||||||
|
assert(await picker.getByRole('button', { name: 'Download outside profile' }).isDisabled());
|
||||||
|
assert(await picker.getByRole('button', { name: 'Download this release' }).isDisabled());
|
||||||
|
const toggle = picker.getByRole('checkbox', { name: /^Ignore profile limits/ });
|
||||||
|
assert.equal(await toggle.count(), allowed ? 1 : 0);
|
||||||
|
assert(await picker.evaluate(el => el.scrollWidth <= el.clientWidth));
|
||||||
|
if (allowed) {
|
||||||
|
await toggle.check();
|
||||||
|
assert(await picker.getByRole('button', { name: 'Download outside profile' }).isEnabled());
|
||||||
|
assert(await picker.getByRole('button', { name: 'Download this release' }).isDisabled());
|
||||||
|
page.once('dialog', dialog => dialog.dismiss());
|
||||||
|
const before = writes.length;
|
||||||
|
await picker.getByRole('button', { name: 'Download outside profile' }).click();
|
||||||
|
assert.equal(writes.length, before);
|
||||||
|
page.once('dialog', dialog => dialog.accept());
|
||||||
|
await picker.getByRole('button', { name: 'Download outside profile' }).click();
|
||||||
|
await page.getByText('Selected release sent to Sonarr.', { exact: true }).first().waitFor();
|
||||||
|
assert.equal(writes.at(-1).ignoreProfileLimits, true);
|
||||||
|
assert.equal(writes.at(-1).selectionToken, 'signed-fixture');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log('Passed: desktop/mobile rejected-release reasons, per-user override visibility, explicit confirmation/cancel, blocked operational failures, signed selection payload, and no overflow. All APIs intercepted.');
|
||||||
|
} finally { await browser.close(); }
|
||||||
|
})().catch(e => { console.error(e); process.exitCode = 1; });
|
||||||
Reference in New Issue
Block a user