Add quality-aware release picker modal
This commit is contained in:
@@ -1686,7 +1686,24 @@ 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")
|
||||||
|
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)
|
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(
|
keep.append(
|
||||||
{
|
{
|
||||||
"title": item.get("title"),
|
"title": item.get("title"),
|
||||||
@@ -1708,9 +1725,14 @@ def _filter_arr_release_results(results: Any) -> List[Dict[str, Any]]:
|
|||||||
"downloadAllowed": item.get("downloadAllowed"),
|
"downloadAllowed": item.get("downloadAllowed"),
|
||||||
"fullSeason": item.get("fullSeason"),
|
"fullSeason": item.get("fullSeason"),
|
||||||
"seasonNumber": item.get("seasonNumber"),
|
"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]]:
|
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_releases",
|
||||||
"Search and choose a download",
|
"Search and choose a download",
|
||||||
"ok",
|
"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")
|
@router.post("/{request_id}/actions/search_auto")
|
||||||
|
|||||||
@@ -1124,6 +1124,18 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"protocol": "torrent",
|
"protocol": "torrent",
|
||||||
"fullSeason": True,
|
"fullSeason": True,
|
||||||
"seasonNumber": 1,
|
"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, 1)
|
||||||
sonarr.search_releases.assert_any_await(42, 2)
|
sonarr.search_releases.assert_any_await(42, 2)
|
||||||
self.assertEqual(result["collector"], "Sonarr")
|
self.assertEqual(result["collector"], "Sonarr")
|
||||||
|
self.assertEqual(len(result["releases"]), 1)
|
||||||
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.assertTrue(result["releases"][0]["bestPick"])
|
||||||
|
self.assertTrue(result["qualityFiltered"])
|
||||||
|
|
||||||
async def test_movie_manual_search_uses_radarr(self) -> None:
|
async def test_movie_manual_search_uses_radarr(self) -> None:
|
||||||
snapshot = Snapshot(
|
snapshot = Snapshot(
|
||||||
@@ -1163,6 +1179,9 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"indexerId": 9,
|
"indexerId": 9,
|
||||||
"indexer": "Prowlarr",
|
"indexer": "Prowlarr",
|
||||||
"protocol": "torrent",
|
"protocol": "torrent",
|
||||||
|
"approved": True,
|
||||||
|
"rejected": False,
|
||||||
|
"downloadAllowed": True,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
@@ -1180,6 +1199,32 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
radarr.search_releases.assert_awaited_once_with(84)
|
radarr.search_releases.assert_awaited_once_with(84)
|
||||||
self.assertEqual(result["collector"], "Radarr")
|
self.assertEqual(result["collector"], "Radarr")
|
||||||
self.assertEqual(result["releases"][0]["guid"], "movie-release")
|
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:
|
async def test_tv_manual_grab_is_sent_to_sonarr_not_qbittorrent(self) -> None:
|
||||||
snapshot = Snapshot(
|
snapshot = Snapshot(
|
||||||
|
|||||||
+121
-11
@@ -2239,10 +2239,8 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.request-journey { display: grid; gap: 18px; padding: 20px; }
|
.request-journey { display: grid; gap: 18px; padding: 20px; }
|
||||||
.request-journey-heading,
|
.request-journey-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||||
.request-release-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-journey-heading h2,
|
|
||||||
.request-release-heading h3 { font-size: clamp(1.3rem, 2.2vw, 2rem); letter-spacing: -0.03em; }
|
|
||||||
.request-live-indicator {
|
.request-live-indicator {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2326,12 +2324,121 @@ button:disabled,
|
|||||||
.request-stage-actions button { width: 100%; padding-inline: 10px; font-size: 0.7rem; }
|
.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-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-modal-layer {
|
||||||
.request-release-list { display: grid; gap: 8px; }
|
position: fixed;
|
||||||
.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); }
|
z-index: 1200;
|
||||||
.request-release > div { display: grid; gap: 4px; min-width: 0; }
|
inset: 0;
|
||||||
.request-release strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
display: grid;
|
||||||
.request-release span { color: var(--ops-muted); font-size: 0.74rem; }
|
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 { 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; }
|
.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-journey,
|
||||||
.request-overview-block { padding: 15px; }
|
.request-overview-block { padding: 15px; }
|
||||||
.request-journey-heading,
|
.request-journey-heading,
|
||||||
.request-release-heading { align-items: flex-start; flex-direction: column; }
|
|
||||||
.request-release { grid-template-columns: 1fr; }
|
.request-release { grid-template-columns: 1fr; }
|
||||||
.request-live-indicator { align-self: flex-start; }
|
.request-live-indicator { align-self: flex-start; }
|
||||||
.request-diagnostics-grid { grid-template-columns: 1fr; }
|
.request-diagnostics-grid { grid-template-columns: 1fr; }
|
||||||
@@ -2367,6 +2473,10 @@ button:disabled,
|
|||||||
.request-watch-button { width: 100%; min-width: 0; }
|
.request-watch-button { width: 100%; min-width: 0; }
|
||||||
.request-action-row button,
|
.request-action-row button,
|
||||||
.request-release button { width: 100%; }
|
.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-operation-heading { align-items: flex-start; flex-direction: column; }
|
||||||
.request-repair-heading { flex-direction: column; }
|
.request-repair-heading { flex-direction: column; }
|
||||||
.request-repair-meta { justify-items: start; }
|
.request-repair-meta { justify-items: start; }
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ type ReleaseOption = {
|
|||||||
magnetUrl?: string
|
magnetUrl?: string
|
||||||
fullSeason?: boolean
|
fullSeason?: boolean
|
||||||
seasonNumber?: number
|
seasonNumber?: number
|
||||||
|
quality?: string
|
||||||
|
customFormatScore?: number
|
||||||
|
approved?: boolean
|
||||||
|
bestPick?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type SnapshotHistory = {
|
type SnapshotHistory = {
|
||||||
@@ -298,6 +302,9 @@ export default function RequestTimelinePage() {
|
|||||||
const [actionError, setActionError] = useState<string | null>(null)
|
const [actionError, setActionError] = useState<string | null>(null)
|
||||||
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 [releaseCollector, setReleaseCollector] = useState<string | null>(null)
|
||||||
|
const [releaseSearchMessage, setReleaseSearchMessage] = useState<string | null>(null)
|
||||||
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([])
|
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([])
|
||||||
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([])
|
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([])
|
||||||
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null)
|
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null)
|
||||||
@@ -312,6 +319,27 @@ export default function RequestTimelinePage() {
|
|||||||
!['complete', 'attention'].includes(snapshot.presentation.repairActivity.state ?? '')
|
!['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(() => {
|
useEffect(() => {
|
||||||
if (!requestId) return
|
if (!requestId) return
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -563,6 +591,8 @@ export default function RequestTimelinePage() {
|
|||||||
setActionError(null)
|
setActionError(null)
|
||||||
setActionMessage(null)
|
setActionMessage(null)
|
||||||
setReleaseOptions([])
|
setReleaseOptions([])
|
||||||
|
setReleasePickerOpen(false)
|
||||||
|
setReleaseSearchMessage(null)
|
||||||
try {
|
try {
|
||||||
const response = await trackedPost(
|
const response = await trackedPost(
|
||||||
'Recheck request status',
|
'Recheck request status',
|
||||||
@@ -603,6 +633,12 @@ export default function RequestTimelinePage() {
|
|||||||
setActionError('This action is not connected yet.')
|
setActionError('This action is not connected yet.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (action.id === 'search_releases') {
|
||||||
|
setReleaseOptions([])
|
||||||
|
setReleaseCollector(snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')
|
||||||
|
setReleaseSearchMessage(null)
|
||||||
|
setReleasePickerOpen(true)
|
||||||
|
}
|
||||||
setBusyAction(action.id)
|
setBusyAction(action.id)
|
||||||
setActionError(null)
|
setActionError(null)
|
||||||
setActionMessage(null)
|
setActionMessage(null)
|
||||||
@@ -621,11 +657,16 @@ 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)
|
||||||
setActionMessage(
|
setReleaseCollector(data?.collector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'))
|
||||||
|
setReleaseSearchMessage(
|
||||||
|
data?.message ??
|
||||||
|
(
|
||||||
releases.length
|
releases.length
|
||||||
? `Found ${releases.length} possible release${releases.length === 1 ? '' : 's'}. Choose one below.`
|
? `Found ${releases.length} approved release${releases.length === 1 ? '' : 's'}.`
|
||||||
: 'No matching releases were found. Magent will keep the request in the search stage.'
|
: 'No releases currently meet the assigned quality profile.'
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
setActionMessage(null)
|
||||||
} else {
|
} else {
|
||||||
setActionMessage(data?.message ?? `${action.label} was started successfully.`)
|
setActionMessage(data?.message ?? `${action.label} was started successfully.`)
|
||||||
}
|
}
|
||||||
@@ -643,7 +684,6 @@ export default function RequestTimelinePage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const collector = snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'
|
const collector = snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'
|
||||||
if (!window.confirm(`Send “${release.title ?? 'this release'}” through ${collector}?`)) return
|
|
||||||
setBusyAction(`grab:${release.guid}`)
|
setBusyAction(`grab:${release.guid}`)
|
||||||
setActionError(null)
|
setActionError(null)
|
||||||
try {
|
try {
|
||||||
@@ -662,6 +702,8 @@ export default function RequestTimelinePage() {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setActionMessage(data?.message ?? 'The selected release was queued for download.')
|
setActionMessage(data?.message ?? 'The selected release was queued for download.')
|
||||||
setReleaseOptions([])
|
setReleaseOptions([])
|
||||||
|
setReleasePickerOpen(false)
|
||||||
|
setReleaseSearchMessage(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
setActionError(error instanceof Error ? error.message : 'The selected release could not be started.')
|
setActionError(error instanceof Error ? error.message : 'The selected release could not be started.')
|
||||||
@@ -897,20 +939,102 @@ export default function RequestTimelinePage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{releaseOptions.length > 0 && (
|
</section>
|
||||||
<div className="request-release-picker">
|
|
||||||
<div className="request-release-heading"><div><span className="section-kicker">Manual selection through {snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'}</span><h3>Choose a release</h3></div><button type="button" className="ghost-button" onClick={() => setReleaseOptions([])}>Close</button></div>
|
{releasePickerOpen && (
|
||||||
<div className="request-release-list">
|
<div className="request-release-modal-layer">
|
||||||
{releaseOptions.map((release) => (
|
<button
|
||||||
<div className="request-release" key={`${release.guid ?? release.title}`}>
|
type="button"
|
||||||
<div><strong>{release.title ?? 'Unknown release'}</strong><span>{release.indexer ?? 'Unknown indexer'} · {release.seeders ?? 0} seeders · {formatBytes(release.size)}{release.fullSeason ? ` · Season ${release.seasonNumber ?? ''} pack` : ''}</span></div>
|
className="request-release-modal-backdrop"
|
||||||
<button type="button" disabled={Boolean(busyAction) || !release.guid || !release.indexerId} onClick={() => void downloadRelease(release)}>{busyAction === `grab:${release.guid}` ? 'Sending…' : `Send through ${snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'}`}</button>
|
aria-label="Close available releases"
|
||||||
|
onClick={closeReleasePicker}
|
||||||
|
/>
|
||||||
|
<section
|
||||||
|
className="request-release-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="release-picker-title"
|
||||||
|
>
|
||||||
|
<header className="request-release-modal-header">
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Approved by {releaseCollector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')}</span>
|
||||||
|
<h2 id="release-picker-title">Choose an available download</h2>
|
||||||
|
<p>Only releases accepted by the title's assigned quality profile are shown.</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<button type="button" className="ghost-button" onClick={closeReleasePicker} disabled={busyAction?.startsWith('grab:')}>Close</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="request-release-modal-body">
|
||||||
|
{busyAction === 'search_releases' && (
|
||||||
|
<div className="request-release-searching" role="status">
|
||||||
|
<i aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>Checking available releases</strong>
|
||||||
|
<span>{releaseCollector ?? 'The collector'} is applying its quality profile and ranking the results.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{busyAction !== 'search_releases' && releaseSearchMessage && (
|
||||||
|
<div className="request-release-profile-note">
|
||||||
|
<strong>Quality limits applied</strong>
|
||||||
|
<span>{releaseSearchMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{busyAction !== 'search_releases' && actionError && (
|
||||||
|
<div className="request-release-empty is-error" role="alert">
|
||||||
|
<strong>The release search could not be completed</strong>
|
||||||
|
<span>{actionError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{busyAction !== 'search_releases' && !actionError && releaseOptions.length === 0 && (
|
||||||
|
<div className="request-release-empty">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{releaseOptions.length > 0 && (
|
||||||
|
<div className="request-release-list">
|
||||||
|
{releaseOptions.map((release, index) => {
|
||||||
|
const isBestPick = release.bestPick || index === 0
|
||||||
|
return (
|
||||||
|
<article className={`request-release ${isBestPick ? 'is-best-pick' : ''}`} key={`${release.indexerId ?? ''}:${release.guid ?? release.title}`}>
|
||||||
|
<div className="request-release-copy">
|
||||||
|
<div className="request-release-badges">
|
||||||
|
{isBestPick && <span className="request-release-best-badge">Best pick</span>}
|
||||||
|
{release.quality && <span>{release.quality}</span>}
|
||||||
|
{release.fullSeason && <span>Season {release.seasonNumber ?? ''} pack</span>}
|
||||||
|
</div>
|
||||||
|
<strong>{release.title ?? 'Unknown release'}</strong>
|
||||||
|
<span>
|
||||||
|
{release.indexer ?? 'Unknown indexer'} · {release.seeders ?? 0} seeders · {formatBytes(release.size)}
|
||||||
|
{typeof release.customFormatScore === 'number' ? ` · Score ${release.customFormatScore}` : ''}
|
||||||
|
</span>
|
||||||
|
{isBestPick && <small>This is the highest-ranked release approved by {releaseCollector ?? 'the collector'}.</small>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={Boolean(busyAction) || !release.guid || !release.indexerId}
|
||||||
|
onClick={() => void downloadRelease(release)}
|
||||||
|
>
|
||||||
|
{busyAction === `grab:${release.guid}`
|
||||||
|
? 'Sending…'
|
||||||
|
: isBestPick
|
||||||
|
? 'Download best pick'
|
||||||
|
: 'Download this release'}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isAdmin && <section className="request-advanced">
|
{isAdmin && <section className="request-advanced">
|
||||||
<button type="button" className="request-advanced-toggle" aria-expanded={showDetails} onClick={() => setShowDetails((current) => !current)}>
|
<button type="button" className="request-advanced-toggle" aria-expanded={showDetails} onClick={() => setShowDetails((current) => !current)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user