Add live request pipeline recheck
This commit is contained in:
@@ -1599,6 +1599,69 @@ async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_curre
|
|||||||
return _filter_snapshot_actions_for_user(snapshot, user)
|
return _filter_snapshot_actions_for_user(snapshot, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/actions/recheck")
|
||||||
|
async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
||||||
|
if not request_id.isdigit():
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid request id")
|
||||||
|
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||||
|
if not seerr.configured():
|
||||||
|
raise HTTPException(status_code=400, detail="Seerr is not configured")
|
||||||
|
await _ensure_request_access(seerr, int(request_id), user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
fresh_request = await seerr.get_request(request_id)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
detail = _format_upstream_error("Seerr", exc)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
save_action,
|
||||||
|
request_id,
|
||||||
|
"recheck_pipeline",
|
||||||
|
"Recheck request status",
|
||||||
|
"failed",
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=502, detail=detail) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Request recheck failed while reading Seerr: request_id=%s error=%s", request_id, exc)
|
||||||
|
detail = "Magent could not reach Seerr to recheck this request."
|
||||||
|
await asyncio.to_thread(
|
||||||
|
save_action,
|
||||||
|
request_id,
|
||||||
|
"recheck_pipeline",
|
||||||
|
"Recheck request status",
|
||||||
|
"failed",
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=502, detail=detail) from exc
|
||||||
|
|
||||||
|
if not isinstance(fresh_request, dict):
|
||||||
|
raise HTTPException(status_code=404, detail="Request not found in Seerr")
|
||||||
|
|
||||||
|
parsed = _parse_request_payload(fresh_request)
|
||||||
|
if parsed.get("request_id") != int(request_id):
|
||||||
|
raise HTTPException(status_code=502, detail="Seerr returned an unexpected request record")
|
||||||
|
|
||||||
|
cache_record = _build_request_cache_record(parsed, fresh_request)
|
||||||
|
await asyncio.to_thread(upsert_request_cache, **cache_record)
|
||||||
|
_cache_set(f"request:{request_id}", fresh_request)
|
||||||
|
_refresh_recent_cache_from_db()
|
||||||
|
|
||||||
|
snapshot = _filter_snapshot_actions_for_user(await build_snapshot(request_id), user)
|
||||||
|
status_label = str((snapshot.presentation.get("status") or {}).get("label") or "Status updated")
|
||||||
|
message = f"Recheck complete. {status_label}."
|
||||||
|
await asyncio.to_thread(
|
||||||
|
save_action,
|
||||||
|
request_id,
|
||||||
|
"recheck_pipeline",
|
||||||
|
"Recheck request status",
|
||||||
|
"ok",
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
return {"status": "ok", "message": message, "snapshot": snapshot}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{request_id}/download-progress")
|
@router.get("/{request_id}/download-progress")
|
||||||
async def get_download_progress(
|
async def get_download_progress(
|
||||||
request_id: str, user: Dict[str, str] = Depends(get_current_user)
|
request_id: str, user: Dict[str, str] = Depends(get_current_user)
|
||||||
|
|||||||
@@ -207,7 +207,20 @@ async def _get_seerr_media_details(
|
|||||||
|
|
||||||
|
|
||||||
async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
|
async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
|
||||||
if snapshot.state not in {NormalizedState.available, NormalizedState.completed}:
|
collector_item = snapshot.raw.get("arr", {}).get("item") if isinstance(snapshot.raw, dict) else None
|
||||||
|
collector_stats = collector_item.get("statistics") if isinstance(collector_item, dict) else None
|
||||||
|
collector_has_file = bool(
|
||||||
|
isinstance(collector_item, dict)
|
||||||
|
and (
|
||||||
|
collector_item.get("hasFile")
|
||||||
|
or snapshot.request_type == RequestType.tv
|
||||||
|
and isinstance(collector_stats, dict)
|
||||||
|
and collector_stats.get("episodeFileCount")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if snapshot.state not in {NormalizedState.available, NormalizedState.completed} and not (
|
||||||
|
snapshot.state == NormalizedState.importing and collector_has_file
|
||||||
|
):
|
||||||
return
|
return
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||||
@@ -223,8 +236,9 @@ async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
|
|||||||
pass
|
pass
|
||||||
previous = await asyncio.to_thread(get_recent_snapshots, snapshot.request_id, 1)
|
previous = await asyncio.to_thread(get_recent_snapshots, snapshot.request_id, 1)
|
||||||
if previous:
|
if previous:
|
||||||
prev_state = previous[0].get("state")
|
previous_payload = previous[0].get("payload") or {}
|
||||||
if prev_state in {NormalizedState.available.value, NormalizedState.completed.value}:
|
previous_jellyfin = (previous_payload.get("raw") or {}).get("jellyfin") or {}
|
||||||
|
if previous_jellyfin.get("found"):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
await client.refresh_library()
|
await client.refresh_library()
|
||||||
@@ -463,8 +477,15 @@ def _build_presentation(
|
|||||||
status_label = "Download in progress"
|
status_label = "Download in progress"
|
||||||
meaning = "A release has been collected and is currently downloading."
|
meaning = "A release has been collected and is currently downloading."
|
||||||
elif snapshot.state == NormalizedState.importing:
|
elif snapshot.state == NormalizedState.importing:
|
||||||
status_label = "Downloaded — waiting for library import"
|
if arr_state == "available" and not jellyfin_found:
|
||||||
meaning = f"The download has finished and {collector} is preparing it for the media server."
|
status_label = "Collected — waiting for the media server"
|
||||||
|
meaning = (
|
||||||
|
f"{collector} has collected and imported this title, but it is not visible on "
|
||||||
|
"the media server yet."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
status_label = "Downloaded — waiting for library import"
|
||||||
|
meaning = f"The download has finished and {collector} is preparing it for the media server."
|
||||||
elif arr_state == "error":
|
elif arr_state == "error":
|
||||||
status_label = "Unable to read the library queue"
|
status_label = "Unable to read the library queue"
|
||||||
meaning = (
|
meaning = (
|
||||||
@@ -527,6 +548,13 @@ def _build_presentation(
|
|||||||
next_title = "Let the current download finish"
|
next_title = "Let the current download finish"
|
||||||
next_description = "Magent is tracking the active download; no action is needed right now."
|
next_description = "Magent is tracking the active download; no action is needed right now."
|
||||||
recommended = []
|
recommended = []
|
||||||
|
elif snapshot.state == NormalizedState.importing and arr_state == "available":
|
||||||
|
next_title = "Wait for the media server to index this title"
|
||||||
|
next_description = (
|
||||||
|
f"{collector} has completed its work. Use Recheck request to see whether the title "
|
||||||
|
"has appeared on the media server."
|
||||||
|
)
|
||||||
|
recommended = []
|
||||||
elif snapshot.state in {NormalizedState.completed, NormalizedState.available}:
|
elif snapshot.state in {NormalizedState.completed, NormalizedState.available}:
|
||||||
next_title = "Ready to watch"
|
next_title = "Ready to watch"
|
||||||
next_description = "Collection is complete. Open the title on the media server when you are ready."
|
next_description = "Collection is complete. Open the title on the media server when you are ready."
|
||||||
@@ -572,6 +600,8 @@ def _build_presentation(
|
|||||||
|
|
||||||
if fully_available:
|
if fully_available:
|
||||||
search_state, search_summary = "complete", "No further search needed"
|
search_state, search_summary = "complete", "No further search needed"
|
||||||
|
elif arr_state == "available":
|
||||||
|
search_state, search_summary = "complete", "A release was collected"
|
||||||
elif download_visible:
|
elif download_visible:
|
||||||
search_state = "complete"
|
search_state = "complete"
|
||||||
search_summary = "A release was found"
|
search_summary = "A release was found"
|
||||||
@@ -593,6 +623,11 @@ def _build_presentation(
|
|||||||
download_stage_state, download_summary = "complete", completed_download_summary
|
download_stage_state, download_summary = "complete", completed_download_summary
|
||||||
pipeline_download_visible = False
|
pipeline_download_visible = False
|
||||||
pipeline_torrents: List[Dict[str, Any]] = []
|
pipeline_torrents: List[Dict[str, Any]] = []
|
||||||
|
elif arr_state == "available":
|
||||||
|
download_stage_state = "complete"
|
||||||
|
download_summary = f"{collector} has imported the collected file"
|
||||||
|
pipeline_download_visible = False
|
||||||
|
pipeline_torrents = []
|
||||||
elif download_visible:
|
elif download_visible:
|
||||||
download_stage_state = {
|
download_stage_state = {
|
||||||
"downloading": "active",
|
"downloading": "active",
|
||||||
@@ -613,6 +648,8 @@ def _build_presentation(
|
|||||||
available_state, available_summary = "partial", f"{available} of {total} episodes available"
|
available_state, available_summary = "partial", f"{available} of {total} episodes available"
|
||||||
elif jellyfin_found:
|
elif jellyfin_found:
|
||||||
available_state, available_summary = "complete", "Available to watch"
|
available_state, available_summary = "complete", "Available to watch"
|
||||||
|
elif arr_state == "available":
|
||||||
|
available_state, available_summary = "active", "Waiting for the media server to index this title"
|
||||||
else:
|
else:
|
||||||
available_state, available_summary = "waiting", "Not available on the media server yet"
|
available_state, available_summary = "waiting", "Not available on the media server yet"
|
||||||
|
|
||||||
@@ -1098,8 +1135,8 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
snapshot.state_reason = qbit_message
|
snapshot.state_reason = qbit_message
|
||||||
elif qbit_state == "completed":
|
elif qbit_state == "completed":
|
||||||
if arr_state == "available":
|
if arr_state == "available":
|
||||||
snapshot.state = NormalizedState.completed
|
snapshot.state = NormalizedState.importing
|
||||||
snapshot.state_reason = "In your library and ready to watch."
|
snapshot.state_reason = "The collector imported the file. Waiting for the media server to index it."
|
||||||
else:
|
else:
|
||||||
snapshot.state = NormalizedState.importing
|
snapshot.state = NormalizedState.importing
|
||||||
snapshot.state_reason = "Download finished. Waiting for library import."
|
snapshot.state_reason = "Download finished. Waiting for library import."
|
||||||
@@ -1115,8 +1152,8 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
snapshot.state = NormalizedState.searching
|
snapshot.state = NormalizedState.searching
|
||||||
snapshot.state_reason = "Searching for a matching release."
|
snapshot.state_reason = "Searching for a matching release."
|
||||||
elif arr_state == "available":
|
elif arr_state == "available":
|
||||||
snapshot.state = NormalizedState.completed
|
snapshot.state = NormalizedState.importing
|
||||||
snapshot.state_reason = "In your library and ready to watch."
|
snapshot.state_reason = "Collected by Sonarr/Radarr and waiting for the media server to index it."
|
||||||
elif arr_state == "added" and snapshot.state == NormalizedState.approved:
|
elif arr_state == "added" and snapshot.state == NormalizedState.approved:
|
||||||
snapshot.state = NormalizedState.added_to_arr
|
snapshot.state = NormalizedState.added_to_arr
|
||||||
snapshot.state_reason = "Item is present in Sonarr/Radarr"
|
snapshot.state_reason = "Item is present in Sonarr/Radarr"
|
||||||
|
|||||||
@@ -310,6 +310,96 @@ class RequestPresentationTests(unittest.TestCase):
|
|||||||
self.assertEqual(download_stage["state"], "attention")
|
self.assertEqual(download_stage["state"], "attention")
|
||||||
self.assertTrue(download_stage["visible"])
|
self.assertTrue(download_stage["visible"])
|
||||||
|
|
||||||
|
def test_collector_file_waits_for_media_server_before_marking_available(self) -> None:
|
||||||
|
snapshot = Snapshot(
|
||||||
|
request_id="3914",
|
||||||
|
title="I See You",
|
||||||
|
request_type=RequestType.movie,
|
||||||
|
state=NormalizedState.importing,
|
||||||
|
actions=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
presentation = _build_presentation(
|
||||||
|
snapshot,
|
||||||
|
approved=True,
|
||||||
|
arr_state="available",
|
||||||
|
arr_details={
|
||||||
|
"availability": {"available": 1, "missing": 0, "total": 1, "seasons": []}
|
||||||
|
},
|
||||||
|
prowlarr_state="ok",
|
||||||
|
download={
|
||||||
|
"visible": False,
|
||||||
|
"state": "not_started",
|
||||||
|
"summary": "No download attempt has been observed.",
|
||||||
|
"torrents": [],
|
||||||
|
},
|
||||||
|
jellyfin_found=False,
|
||||||
|
jellyfin_link=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(presentation["status"]["label"], "Collected — waiting for the media server")
|
||||||
|
self.assertEqual(presentation["nextStep"]["title"], "Wait for the media server to index this title")
|
||||||
|
search_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "search")
|
||||||
|
download_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "download")
|
||||||
|
available_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "available")
|
||||||
|
self.assertEqual(search_stage["state"], "complete")
|
||||||
|
self.assertEqual(download_stage["state"], "complete")
|
||||||
|
self.assertEqual(available_stage["state"], "active")
|
||||||
|
|
||||||
|
|
||||||
|
class RequestRecheckTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_recheck_refreshes_seerr_cache_and_returns_rebuilt_snapshot(self) -> None:
|
||||||
|
runtime = SimpleNamespace(
|
||||||
|
jellyseerr_base_url="http://seerr.test",
|
||||||
|
jellyseerr_api_key="seerr-key",
|
||||||
|
)
|
||||||
|
fresh_request = {
|
||||||
|
"id": 3914,
|
||||||
|
"type": "movie",
|
||||||
|
"status": 2,
|
||||||
|
"createdAt": "2026-08-30T00:00:00Z",
|
||||||
|
"updatedAt": "2026-08-30T01:00:00Z",
|
||||||
|
"requestedBy": {"username": "viewer"},
|
||||||
|
"media": {
|
||||||
|
"id": 9001,
|
||||||
|
"mediaType": "movie",
|
||||||
|
"tmdbId": 524251,
|
||||||
|
"title": "I See You",
|
||||||
|
"year": 2019,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
seerr = SimpleNamespace(
|
||||||
|
configured=lambda: True,
|
||||||
|
get_request=AsyncMock(return_value=fresh_request),
|
||||||
|
)
|
||||||
|
snapshot = Snapshot(
|
||||||
|
request_id="3914",
|
||||||
|
title="I See You",
|
||||||
|
request_type=RequestType.movie,
|
||||||
|
state=NormalizedState.importing,
|
||||||
|
presentation={
|
||||||
|
"status": {"label": "Collected — waiting for the media server"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(requests_router, "get_runtime_settings", return_value=runtime), patch.object(
|
||||||
|
requests_router, "JellyseerrClient", return_value=seerr
|
||||||
|
), patch.object(requests_router, "upsert_request_cache") as upsert, patch.object(
|
||||||
|
requests_router, "_cache_set"
|
||||||
|
) as cache_set, patch.object(requests_router, "_refresh_recent_cache_from_db"), patch.object(
|
||||||
|
requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)
|
||||||
|
), patch.object(requests_router, "save_action") as save_action:
|
||||||
|
result = await requests_router.action_recheck(
|
||||||
|
"3914", user={"username": "viewer", "role": "user"}
|
||||||
|
)
|
||||||
|
|
||||||
|
seerr.get_request.assert_awaited_once_with("3914")
|
||||||
|
upsert.assert_called_once()
|
||||||
|
cache_set.assert_called_once_with("request:3914", fresh_request)
|
||||||
|
save_action.assert_called_once()
|
||||||
|
self.assertEqual(result["status"], "ok")
|
||||||
|
self.assertIs(result["snapshot"], snapshot)
|
||||||
|
|
||||||
|
|
||||||
class LiveDownloadProgressTests(unittest.IsolatedAsyncioTestCase):
|
class LiveDownloadProgressTests(unittest.IsolatedAsyncioTestCase):
|
||||||
async def test_live_download_progress_uses_saved_hash_and_current_qbittorrent_value(self) -> None:
|
async def test_live_download_progress_uses_saved_hash_and_current_qbittorrent_value(self) -> None:
|
||||||
|
|||||||
@@ -1993,6 +1993,16 @@ button:disabled,
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
.request-action-row .request-recheck-button {
|
||||||
|
margin-left: auto;
|
||||||
|
border-color: var(--ops-line);
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
color: var(--ops-text);
|
||||||
|
}
|
||||||
|
.request-action-row .request-recheck-button:hover:not(:disabled) {
|
||||||
|
border-color: rgba(126, 215, 255, 0.48);
|
||||||
|
background: rgba(126, 215, 255, 0.09);
|
||||||
|
}
|
||||||
.request-action-feedback {
|
.request-action-feedback {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
padding: 13px 18px;
|
padding: 13px 18px;
|
||||||
|
|||||||
@@ -427,6 +427,38 @@ export default function RequestTimelinePage() {
|
|||||||
const posterUrl = snapshot.artwork?.poster_url
|
const posterUrl = snapshot.artwork?.poster_url
|
||||||
const resolvedPoster = posterUrl?.startsWith('http') ? posterUrl : posterUrl ? `${getApiBase()}${posterUrl}` : null
|
const resolvedPoster = posterUrl?.startsWith('http') ? posterUrl : posterUrl ? `${getApiBase()}${posterUrl}` : null
|
||||||
|
|
||||||
|
const recheckRequest = async () => {
|
||||||
|
setBusyAction('recheck_pipeline')
|
||||||
|
setActionError(null)
|
||||||
|
setActionMessage(null)
|
||||||
|
setReleaseOptions([])
|
||||||
|
try {
|
||||||
|
const response = await authFetch(
|
||||||
|
`${getApiBase()}/requests/${snapshot.request_id}/actions/recheck`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
)
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readApiError(response, 'The request could not be rechecked.'))
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
if (!isSnapshotPayload(data?.snapshot)) {
|
||||||
|
throw new Error('The request was checked, but Magent did not return a valid pipeline.')
|
||||||
|
}
|
||||||
|
setSnapshot(data.snapshot)
|
||||||
|
setActionMessage(data?.message ?? 'Request status rebuilt from live service data.')
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
setActionError(error instanceof Error ? error.message : 'The request could not be rechecked.')
|
||||||
|
} finally {
|
||||||
|
setBusyAction(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const runAction = async (action: RequestAction) => {
|
const runAction = async (action: RequestAction) => {
|
||||||
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> = {
|
||||||
@@ -538,15 +570,22 @@ export default function RequestTimelinePage() {
|
|||||||
<span className="request-overview-label">Next step</span>
|
<span className="request-overview-label">Next step</span>
|
||||||
<strong>{nextStep.title}</strong>
|
<strong>{nextStep.title}</strong>
|
||||||
<p>{nextStep.description}</p>
|
<p>{nextStep.description}</p>
|
||||||
{recommendedActions.length > 0 && (
|
<div className="request-action-row">
|
||||||
<div className="request-action-row">
|
{recommendedActions.map((action) => (
|
||||||
{recommendedActions.map((action) => (
|
<button key={action.id} type="button" disabled={Boolean(busyAction)} onClick={() => void runAction(action)}>
|
||||||
<button key={action.id} type="button" disabled={Boolean(busyAction)} onClick={() => void runAction(action)}>
|
{busyAction === action.id ? 'Working…' : action.label}
|
||||||
{busyAction === action.id ? 'Working…' : action.label}
|
</button>
|
||||||
</button>
|
))}
|
||||||
))}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
)}
|
className="request-recheck-button"
|
||||||
|
disabled={Boolean(busyAction)}
|
||||||
|
onClick={() => void recheckRequest()}
|
||||||
|
title="Recheck Seerr, the library collector, qBittorrent, and the media server"
|
||||||
|
>
|
||||||
|
{busyAction === 'recheck_pipeline' ? 'Rechecking…' : 'Recheck request'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(actionMessage || actionError) && (
|
{(actionMessage || actionError) && (
|
||||||
<div className={`request-action-feedback ${actionError ? 'is-error' : 'is-success'}`} role="status">
|
<div className={`request-action-feedback ${actionError ? 'is-error' : 'is-success'}`} role="status">
|
||||||
|
|||||||
Reference in New Issue
Block a user