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

This commit is contained in:
2026-09-11 20:26:57 +12:00
parent de25255ea8
commit 4f7853b17b
13 changed files with 376 additions and 200 deletions
+3
View File
@@ -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})
+2 -1
View File
@@ -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
View File
@@ -1,3 +1,4 @@
from ..services import manual_releases
from ..services.request_language import language_info, original_profile, is_original_profile, apply_original_to_movie, movie_search_outcome, series_search_outcome from ..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)
+1 -1
View File
@@ -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
+55
View File
@@ -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
+26 -15
View File
@@ -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):
+20 -1
View File
@@ -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()
+93
View File
@@ -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()
+13
View File
@@ -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.
+2 -1
View File
@@ -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')
} }
+33 -10
View File
@@ -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&apos;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 -2
View File
@@ -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();
+59
View File
@@ -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; });