From 4f7853b17bd81dfb9a172fb67b68456d5d9e0e14 Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Fri, 11 Sep 2026 20:26:57 +1200 Subject: [PATCH] Align missing-episode searches and permit reviewed profile overrides --- backend/app/clients/sonarr.py | 3 + backend/app/feature_access.py | 3 +- backend/app/routers/requests.py | 229 ++++++--------------- backend/app/services/duplicate_accounts.py | 2 +- backend/app/services/manual_releases.py | 55 +++++ backend/tests/test_backend_quality.py | 41 ++-- backend/tests/test_feature_access.py | 21 +- backend/tests/test_manual_selection.py | 93 +++++++++ docs/manual-release-selection.md | 13 ++ frontend/app/lib/features.ts | 3 +- frontend/app/requests/[id]/page.tsx | 45 +++- scripts/review_feature_access_ui.cjs | 9 +- scripts/review_manual_selection_ui.cjs | 59 ++++++ 13 files changed, 376 insertions(+), 200 deletions(-) create mode 100644 backend/app/services/manual_releases.py create mode 100644 backend/tests/test_manual_selection.py create mode 100644 docs/manual-release-selection.md create mode 100644 scripts/review_manual_selection_ui.cjs diff --git a/backend/app/clients/sonarr.py b/backend/app/clients/sonarr.py index e8d25d2..9a0121b 100644 --- a/backend/app/clients/sonarr.py +++ b/backend/app/clients/sonarr.py @@ -66,6 +66,9 @@ class SonarrClient(ApiClient): 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]]: return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id}) diff --git a/backend/app/feature_access.py b/backend/app/feature_access.py index 43bb5d6..7a377cc 100644 --- a/backend/app/feature_access.py +++ b/backend/app/feature_access.py @@ -1,13 +1,14 @@ """Live account permissions. Invite access uses the existing users column.""" 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]: if user.get("role") == "admin": return dict.fromkeys(FEATURES, True) values = dict.fromkeys(FEATURES, True) + values["ignore_profile_limits"] = False values["invites"] = bool(user.get("invite_management_enabled", False)) with _connect() as conn: rows = conn.execute("""SELECT p.feature, p.enabled FROM user_feature_permissions p diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 698be91..591a6e3 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -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) diff --git a/backend/app/services/duplicate_accounts.py b/backend/app/services/duplicate_accounts.py index 6f99ab8..aed62fb 100644 --- a/backend/app/services/duplicate_accounts.py +++ b/backend/app/services/duplicate_accounts.py @@ -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) 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 - 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']] try: expiry = min(expiries, key=lambda value: db._parse_datetime_value(value).timestamp()) if expiries else None diff --git a/backend/app/services/manual_releases.py b/backend/app/services/manual_releases.py new file mode 100644 index 0000000..1029384 --- /dev/null +++ b/backend/app/services/manual_releases.py @@ -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 diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index a64c19e..d967337 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -1128,6 +1128,18 @@ class ArrAddPayloadTests(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 def _runtime() -> SimpleNamespace: return SimpleNamespace( @@ -1155,7 +1167,7 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase): {"id": 202, "seasonNumber": 2, "monitored": True, "hasFile": True}, ] ), - search_releases=AsyncMock( + search_episode_releases=AsyncMock( side_effect=[ [ { @@ -1194,15 +1206,17 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase): "3909", user={"username": "viewer", "role": "user"} ) - sonarr.search_releases.assert_any_await(42, 1) - sonarr.search_releases.assert_any_await(42, 2) + sonarr.search_episode_releases.assert_any_await(101) + sonarr.search_episode_releases.assert_any_await(201) self.assertEqual(result["collector"], "Sonarr") - self.assertEqual(len(result["releases"]), 1) + self.assertEqual(len(result["releases"]), 2) self.assertTrue(result["releases"][0]["fullSeason"]) self.assertEqual(result["releases"][0]["seasonNumber"], 1) self.assertEqual(result["releases"][0]["quality"], "WEBDL-1080p") 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: snapshot = Snapshot( @@ -1292,14 +1306,14 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase): requests_router, "save_action" ): 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.push_release.assert_not_awaited() 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( request_id="4000", title="Example Movie", @@ -1335,15 +1349,12 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase): ), patch.object(requests_router, "RadarrClient", return_value=radarr), patch.object( requests_router, "save_action" ): - result = await requests_router.action_grab( - "4000", payload, user={"username": "viewer", "role": "user"} - ) + with self.assertRaises(HTTPException) as error: + 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): diff --git a/backend/tests/test_feature_access.py b/backend/tests/test_feature_access.py index 3dfd1b5..f9991a3 100644 --- a/backend/tests/test_feature_access.py +++ b/backend/tests/test_feature_access.py @@ -26,7 +26,7 @@ class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase): self.client.headers['Authorization'] = 'Bearer ' + create_access_token(self.user['username'], 'user') 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']) db.init_db() 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': None}).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() diff --git a/backend/tests/test_manual_selection.py b/backend/tests/test_manual_selection.py new file mode 100644 index 0000000..3856fcd --- /dev/null +++ b/backend/tests/test_manual_selection.py @@ -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() diff --git a/docs/manual-release-selection.md b/docs/manual-release-selection.md new file mode 100644 index 0000000..2edb2d9 --- /dev/null +++ b/docs/manual-release-selection.md @@ -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. diff --git a/frontend/app/lib/features.ts b/frontend/app/lib/features.ts index f3ee956..9bd69f2 100644 --- a/frontend/app/lib/features.ts +++ b/frontend/app/lib/features.ts @@ -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: '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: '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 export type Feature = typeof FEATURES[number]['key'] export type FeatureAccess = Record @@ -19,5 +20,5 @@ export function canAccess(user: { role?: string; features?: Partial(null) const [releaseOptions, setReleaseOptions] = useState([]) const [releasePickerOpen, setReleasePickerOpen] = useState(false) + const [canIgnoreProfileLimits, setCanIgnoreProfileLimits] = useState(false) + const [ignoreProfileLimits, setIgnoreProfileLimits] = useState(false) + const [nextSearchOffset, setNextSearchOffset] = useState(null) const [releaseCollector, setReleaseCollector] = useState(null) const [releaseSearchMessage, setReleaseSearchMessage] = useState(null) const [historySnapshots, setHistorySnapshots] = useState([]) @@ -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 const actionPaths: Record = { search_releases: 'actions/search', @@ -661,6 +668,8 @@ export default function RequestTimelinePage() { } if (action.id === 'search_releases') { setReleaseOptions([]) + setIgnoreProfileLimits(false) + setNextSearchOffset(null) setReleaseCollector(snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr') setReleaseSearchMessage(null) setReleasePickerOpen(false) @@ -671,7 +680,7 @@ export default function RequestTimelinePage() { try { const response = await trackedPost( action.label, - `${getApiBase()}/requests/${snapshot.request_id}/${path}` + `${getApiBase()}/requests/${snapshot.request_id}/${path}${action.id === 'search_releases' ? `?offset=${searchOffset}` : ''}` ) if (response.status === 401) { clearToken() @@ -683,6 +692,8 @@ export default function RequestTimelinePage() { if (action.id === 'search_releases') { const releases = Array.isArray(data.releases) ? data.releases : [] setReleaseOptions(releases) + setCanIgnoreProfileLimits(data.canIgnoreProfileLimits === true) + setNextSearchOffset(typeof data.nextOffset === 'number' ? data.nextOffset : null) setReleasePickerOpen(true) setReleaseCollector(data?.collector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')) setReleaseSearchMessage( @@ -710,6 +721,8 @@ export default function RequestTimelinePage() { setActionError('This release is missing the details needed to start it.') 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' setBusyAction(`grab:${release.guid}`) setActionError(null) @@ -718,7 +731,7 @@ export default function RequestTimelinePage() { `Send release through ${collector}`, `${getApiBase()}/requests/${snapshot.request_id}/actions/grab`, { headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(release), + body: JSON.stringify({ ...release, ignoreProfileLimits: release.requiresOverride === true && ignoreProfileLimits }), }) if (response.status === 401) { clearToken() @@ -953,9 +966,9 @@ export default function RequestTimelinePage() { >
- Approved by {releaseCollector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')} + Results from {releaseCollector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')}

Choose an available download

-

Only releases accepted by the title's assigned quality profile are shown.

+

Search results include the collector?s rejection reasons. The assigned profile stays unchanged.

@@ -973,7 +986,7 @@ export default function RequestTimelinePage() { {busyAction !== 'search_releases' && releaseSearchMessage && (
- Quality limits applied + Search results {releaseSearchMessage}
)} @@ -988,19 +1001,26 @@ export default function RequestTimelinePage() { {busyAction !== 'search_releases' && !actionError && releaseOptions.length === 0 && (
No suitable downloads are available right now - {releaseCollector ?? 'The collector'} did not approve anything within the assigned quality limits. Nothing outside those limits has been shown. + No releases were returned for this search. Check the indexers or try again later.
)} + {canIgnoreProfileLimits && } + {nextSearchOffset !== null && } {releaseOptions.length > 0 && (
- {releaseOptions.map((release, index) => { - const isBestPick = release.bestPick || index === 0 + {releaseOptions.map((release) => { + const isBestPick = release.bestPick === true return (
{isBestPick && Best pick} + {release.requiresOverride && Outside profile} + {release.selectable === false && Unavailable for selection} {release.quality && {release.quality}} {release.fullSeason && Season {release.seasonNumber ?? ''} pack}
@@ -1009,16 +1029,19 @@ export default function RequestTimelinePage() { {release.indexer ?? 'Unknown indexer'} · {release.seeders ?? 0} seeders · {formatBytes(release.size)} {typeof release.customFormatScore === 'number' ? ` · Score ${release.customFormatScore}` : ''} + {!!release.rejections?.length && {release.rejections.join(' ? ')}} {isBestPick && This is the highest-ranked release approved by {releaseCollector ?? 'the collector'}.}
diff --git a/scripts/review_feature_access_ui.cjs b/scripts/review_feature_access_ui.cjs index 4513a5f..c95fb8b 100644 --- a/scripts/review_feature_access_ui.cjs +++ b/scripts/review_feature_access_ui.cjs @@ -7,7 +7,7 @@ const base = process.env.REVIEW_BASE || 'http://localhost:3114'; try { const context = await browser.newContext(); 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 other = { ...viewer, id: 3, username: 'Other viewer', features: { ...all, issues: false } }; 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'); await dialog.getByRole('checkbox', { name: /My Stats/ }).waitFor(); 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()); await dialog.getByRole('checkbox', { name: /^Issues/ }).uncheck(); 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, { 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 dialog.waitFor({ state: 'hidden' }); assert(await page.getByRole('button', { name: 'Manage this user', exact: true }).evaluate((element) => element === document.activeElement)); viewer.features.issues = true; + viewer.features.ignore_profile_limits = false; } await page.goto(`${base}/users`); await page.getByRole('button', { name: 'Manage users', exact: true }).click(); diff --git a/scripts/review_manual_selection_ui.cjs b/scripts/review_manual_selection_ui.cjs new file mode 100644 index 0000000..3efd6eb --- /dev/null +++ b/scripts/review_manual_selection_ui.cjs @@ -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; });