diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 88e9269..40e3ae8 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -1686,7 +1686,24 @@ 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) + ): + continue seen.add(key) + quality_payload = item.get("quality") + quality_name = None + if isinstance(quality_payload, dict): + quality_value = quality_payload.get("quality") + if isinstance(quality_value, dict): + quality_name = str(quality_value.get("name") or "").strip() or None + elif isinstance(quality_payload.get("name"), str): + quality_name = quality_payload["name"].strip() or None keep.append( { "title": item.get("title"), @@ -1708,9 +1725,14 @@ def _filter_arr_release_results(results: Any) -> List[Dict[str, Any]]: "downloadAllowed": item.get("downloadAllowed"), "fullSeason": item.get("fullSeason"), "seasonNumber": item.get("seasonNumber"), + "quality": quality_name, + "customFormatScore": item.get("customFormatScore"), } ) - return keep[:30] + releases = keep[:30] + for index, release in enumerate(releases): + release["bestPick"] = index == 0 + return releases def _build_release_push_payload(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: @@ -3210,9 +3232,18 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr "search_releases", "Search and choose a download", "ok", - f"{collector} found {len(releases)} releases.", + f"{collector} approved {len(releases)} releases against its assigned quality profile.", ) - return {"status": "ok", "collector": collector, "releases": releases} + return { + "status": "ok", + "collector": collector, + "qualityFiltered": True, + "message": ( + f"{collector} approved {len(releases)} release{'s' if len(releases) != 1 else ''} " + "against its assigned quality profile." + ), + "releases": releases, + } @router.post("/{request_id}/actions/search_auto") diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 2ccce25..fbebe69 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -1124,6 +1124,18 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase): "protocol": "torrent", "fullSeason": True, "seasonNumber": 1, + "approved": True, + "rejected": False, + "downloadAllowed": True, + "quality": {"quality": {"name": "WEBDL-1080p"}}, + }, + { + "title": "Example.Show.S01.2160p", + "guid": "outside-profile", + "indexerId": 7, + "approved": False, + "rejected": True, + "rejections": ["Quality is not wanted in profile"], } ], [], @@ -1143,8 +1155,12 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase): sonarr.search_releases.assert_any_await(42, 1) sonarr.search_releases.assert_any_await(42, 2) self.assertEqual(result["collector"], "Sonarr") + self.assertEqual(len(result["releases"]), 1) 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"]) async def test_movie_manual_search_uses_radarr(self) -> None: snapshot = Snapshot( @@ -1163,6 +1179,9 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase): "indexerId": 9, "indexer": "Prowlarr", "protocol": "torrent", + "approved": True, + "rejected": False, + "downloadAllowed": True, } ] ), @@ -1180,6 +1199,32 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase): radarr.search_releases.assert_awaited_once_with(84) self.assertEqual(result["collector"], "Radarr") self.assertEqual(result["releases"][0]["guid"], "movie-release") + self.assertTrue(result["releases"][0]["bestPick"]) + + def test_manual_release_filter_requires_explicit_arr_approval(self) -> None: + releases = requests_router._filter_arr_release_results( + [ + {"title": "Missing decision", "guid": "missing", "indexerId": 1}, + { + "title": "Temporarily rejected", + "guid": "temporary", + "indexerId": 1, + "approved": True, + "temporarilyRejected": True, + }, + { + "title": "Approved release", + "guid": "approved", + "indexerId": 1, + "approved": True, + "rejected": False, + "downloadAllowed": True, + }, + ] + ) + + self.assertEqual([release["guid"] for release in releases], ["approved"]) + self.assertTrue(releases[0]["bestPick"]) async def test_tv_manual_grab_is_sent_to_sonarr_not_qbittorrent(self) -> None: snapshot = Snapshot( diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css index 45ea1ce..c7b85ba 100644 --- a/frontend/app/ops-redesign.css +++ b/frontend/app/ops-redesign.css @@ -2239,10 +2239,8 @@ button:disabled, } .request-journey { display: grid; gap: 18px; padding: 20px; } -.request-journey-heading, -.request-release-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; } -.request-journey-heading h2, -.request-release-heading h3 { font-size: clamp(1.3rem, 2.2vw, 2rem); letter-spacing: -0.03em; } +.request-journey-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; } +.request-journey-heading h2 { font-size: clamp(1.3rem, 2.2vw, 2rem); letter-spacing: -0.03em; } .request-live-indicator { display: inline-flex; align-items: center; @@ -2326,12 +2324,121 @@ button:disabled, .request-stage-actions button { width: 100%; padding-inline: 10px; font-size: 0.7rem; } .request-stage-link { margin-top: auto; color: var(--request-green) !important; font-weight: 750; } -.request-release-picker { display: grid; gap: 14px; padding: 16px; border: 1px solid rgba(126, 215, 255, 0.28); border-radius: var(--ops-radius-lg); background: rgba(14, 165, 233, 0.06); } -.request-release-list { display: grid; gap: 8px; } -.request-release { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 12px; border: 1px solid var(--ops-line-soft); border-radius: var(--ops-radius); background: rgba(255, 255, 255, 0.025); } -.request-release > div { display: grid; gap: 4px; min-width: 0; } -.request-release strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.request-release span { color: var(--ops-muted); font-size: 0.74rem; } +.request-release-modal-layer { + position: fixed; + z-index: 1200; + inset: 0; + display: grid; + place-items: center; + padding: max(18px, 4vh) 18px; +} +.request-release-modal-backdrop { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + border: 0; + border-radius: 0; + background: rgba(2, 6, 18, 0.82); + backdrop-filter: blur(8px); + cursor: default; +} +.request-release-modal { + position: relative; + z-index: 1; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + width: min(980px, 100%); + max-height: min(820px, 92vh); + overflow: hidden; + border: 1px solid rgba(126, 215, 255, 0.42); + border-radius: var(--ops-radius-lg); + background: linear-gradient(145deg, rgba(19, 29, 48, 0.99), rgba(9, 15, 29, 0.99)); + box-shadow: 0 34px 100px rgba(0, 0, 0, 0.68), inset 0 2px 0 rgba(126, 215, 255, 0.72); +} +.request-release-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: 20px; + border-bottom: 1px solid var(--ops-line); + background: rgba(13, 21, 39, 0.96); +} +.request-release-modal-header > div { display: grid; gap: 5px; } +.request-release-modal-header h2 { margin: 0; font-size: clamp(1.35rem, 3vw, 2rem); } +.request-release-modal-header p { margin: 0; color: var(--ops-muted); font-size: 0.8rem; } +.request-release-modal-body { display: grid; gap: 12px; min-height: 0; padding: 16px; overflow: auto; } +.request-release-list { display: grid; gap: 9px; } +.request-release { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + padding: 14px; + border: 1px solid var(--ops-line-soft); + border-radius: var(--ops-radius); + background: rgba(255, 255, 255, 0.025); +} +.request-release.is-best-pick { + border-color: rgba(72, 224, 178, 0.48); + background: linear-gradient(135deg, rgba(72, 224, 178, 0.13), rgba(14, 165, 233, 0.06)); + box-shadow: inset 3px 0 0 var(--request-green); +} +.request-release-copy { display: grid; gap: 5px; min-width: 0; } +.request-release-copy > strong { overflow: hidden; color: var(--ops-text); text-overflow: ellipsis; white-space: nowrap; } +.request-release-copy > span, +.request-release-copy > small { color: var(--ops-muted); font-size: 0.72rem; line-height: 1.4; } +.request-release-copy > small { color: #a8cfc6; } +.request-release-badges { display: flex; flex-wrap: wrap; gap: 6px; } +.request-release-badges > span { + padding: 3px 7px; + border: 1px solid rgba(126, 215, 255, 0.22); + border-radius: 999px; + color: #b7dff2; + font-family: "JetBrains Mono", Consolas, monospace; + font-size: 0.61rem; + font-weight: 700; + text-transform: uppercase; +} +.request-release-badges > .request-release-best-badge { + border-color: rgba(72, 224, 178, 0.48); + background: rgba(72, 224, 178, 0.12); + color: var(--request-green); +} +.request-release-profile-note, +.request-release-empty, +.request-release-searching { + display: flex; + gap: 12px; + align-items: center; + padding: 14px; + border: 1px solid rgba(126, 215, 255, 0.24); + border-radius: var(--ops-radius); + background: rgba(14, 165, 233, 0.065); +} +.request-release-profile-note, +.request-release-empty { display: grid; gap: 4px; } +.request-release-profile-note strong, +.request-release-empty strong, +.request-release-searching strong { color: var(--ops-text); font-size: 0.82rem; } +.request-release-profile-note span, +.request-release-empty span, +.request-release-searching span { color: var(--ops-muted); font-size: 0.75rem; line-height: 1.45; } +.request-release-empty { min-height: 150px; align-content: center; justify-items: center; text-align: center; } +.request-release-empty.is-error { border-color: rgba(255, 112, 131, 0.36); background: rgba(255, 112, 131, 0.07); } +.request-release-searching > i { + flex: 0 0 16px; + width: 16px; + height: 16px; + border: 2px solid rgba(126, 215, 255, 0.22); + border-top-color: var(--request-cyan); + border-radius: 50%; + animation: request-release-spin 0.8s linear infinite; +} +.request-release-searching > div { display: grid; gap: 3px; } +@keyframes request-release-spin { to { transform: rotate(360deg); } } .request-advanced { overflow: hidden; } .request-advanced-toggle { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; padding: 16px 18px; border: 0; border-radius: 0; background: transparent; color: var(--ops-text); text-align: left; } @@ -2357,7 +2464,6 @@ button:disabled, .request-journey, .request-overview-block { padding: 15px; } .request-journey-heading, - .request-release-heading { align-items: flex-start; flex-direction: column; } .request-release { grid-template-columns: 1fr; } .request-live-indicator { align-self: flex-start; } .request-diagnostics-grid { grid-template-columns: 1fr; } @@ -2367,6 +2473,10 @@ button:disabled, .request-watch-button { width: 100%; min-width: 0; } .request-action-row button, .request-release button { width: 100%; } + .request-release-modal-layer { padding: 10px; } + .request-release-modal { max-height: 94vh; } + .request-release-modal-header { padding: 16px; } + .request-release-modal-body { padding: 12px; } .request-operation-heading { align-items: flex-start; flex-direction: column; } .request-repair-heading { flex-direction: column; } .request-repair-meta { justify-items: start; } diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index f4eaba0..9bbb769 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -95,6 +95,10 @@ type ReleaseOption = { magnetUrl?: string fullSeason?: boolean seasonNumber?: number + quality?: string + customFormatScore?: number + approved?: boolean + bestPick?: boolean } type SnapshotHistory = { @@ -298,6 +302,9 @@ export default function RequestTimelinePage() { const [actionError, setActionError] = useState(null) const [busyAction, setBusyAction] = useState(null) const [releaseOptions, setReleaseOptions] = useState([]) + const [releasePickerOpen, setReleasePickerOpen] = useState(false) + const [releaseCollector, setReleaseCollector] = useState(null) + const [releaseSearchMessage, setReleaseSearchMessage] = useState(null) const [historySnapshots, setHistorySnapshots] = useState([]) const [historyActions, setHistoryActions] = useState([]) const [operationProgress, setOperationProgress] = useState(null) @@ -312,6 +319,27 @@ export default function RequestTimelinePage() { !['complete', 'attention'].includes(snapshot.presentation.repairActivity.state ?? '') ) + const closeReleasePicker = () => { + if (busyAction?.startsWith('grab:')) return + setReleasePickerOpen(false) + setReleaseOptions([]) + setReleaseSearchMessage(null) + } + + useEffect(() => { + if (!releasePickerOpen) return + const previousOverflow = document.body.style.overflow + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') closeReleasePicker() + } + document.body.style.overflow = 'hidden' + window.addEventListener('keydown', handleKeyDown) + return () => { + document.body.style.overflow = previousOverflow + window.removeEventListener('keydown', handleKeyDown) + } + }, [releasePickerOpen, busyAction]) + useEffect(() => { if (!requestId) return const load = async () => { @@ -563,6 +591,8 @@ export default function RequestTimelinePage() { setActionError(null) setActionMessage(null) setReleaseOptions([]) + setReleasePickerOpen(false) + setReleaseSearchMessage(null) try { const response = await trackedPost( 'Recheck request status', @@ -603,6 +633,12 @@ export default function RequestTimelinePage() { setActionError('This action is not connected yet.') return } + if (action.id === 'search_releases') { + setReleaseOptions([]) + setReleaseCollector(snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr') + setReleaseSearchMessage(null) + setReleasePickerOpen(true) + } setBusyAction(action.id) setActionError(null) setActionMessage(null) @@ -621,11 +657,16 @@ export default function RequestTimelinePage() { if (action.id === 'search_releases') { const releases = Array.isArray(data.releases) ? data.releases : [] setReleaseOptions(releases) - setActionMessage( + setReleaseCollector(data?.collector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')) + setReleaseSearchMessage( + data?.message ?? + ( releases.length - ? `Found ${releases.length} possible release${releases.length === 1 ? '' : 's'}. Choose one below.` - : 'No matching releases were found. Magent will keep the request in the search stage.' + ? `Found ${releases.length} approved release${releases.length === 1 ? '' : 's'}.` + : 'No releases currently meet the assigned quality profile.' + ) ) + setActionMessage(null) } else { setActionMessage(data?.message ?? `${action.label} was started successfully.`) } @@ -643,7 +684,6 @@ export default function RequestTimelinePage() { return } const collector = snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr' - if (!window.confirm(`Send “${release.title ?? 'this release'}” through ${collector}?`)) return setBusyAction(`grab:${release.guid}`) setActionError(null) try { @@ -662,6 +702,8 @@ export default function RequestTimelinePage() { const data = await response.json() setActionMessage(data?.message ?? 'The selected release was queued for download.') setReleaseOptions([]) + setReleasePickerOpen(false) + setReleaseSearchMessage(null) } catch (error) { console.error(error) setActionError(error instanceof Error ? error.message : 'The selected release could not be started.') @@ -897,21 +939,103 @@ export default function RequestTimelinePage() { })} - {releaseOptions.length > 0 && ( -
-
Manual selection through {snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'}

Choose a release

-
- {releaseOptions.map((release) => ( -
-
{release.title ?? 'Unknown release'}{release.indexer ?? 'Unknown indexer'} · {release.seeders ?? 0} seeders · {formatBytes(release.size)}{release.fullSeason ? ` · Season ${release.seasonNumber ?? ''} pack` : ''}
- -
- ))} -
-
- )} + {releasePickerOpen && ( +
+ + + +
+ {busyAction === 'search_releases' && ( +
+