diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 8fb17c7..2e11773 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -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) +@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") async def get_download_progress( request_id: str, user: Dict[str, str] = Depends(get_current_user) diff --git a/backend/app/services/snapshot.py b/backend/app/services/snapshot.py index e169e06..8b45ed2 100644 --- a/backend/app/services/snapshot.py +++ b/backend/app/services/snapshot.py @@ -207,7 +207,20 @@ async def _get_seerr_media_details( 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 runtime = get_runtime_settings() client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) @@ -223,8 +236,9 @@ async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None: pass previous = await asyncio.to_thread(get_recent_snapshots, snapshot.request_id, 1) if previous: - prev_state = previous[0].get("state") - if prev_state in {NormalizedState.available.value, NormalizedState.completed.value}: + previous_payload = previous[0].get("payload") or {} + previous_jellyfin = (previous_payload.get("raw") or {}).get("jellyfin") or {} + if previous_jellyfin.get("found"): return try: await client.refresh_library() @@ -463,8 +477,15 @@ def _build_presentation( status_label = "Download in progress" meaning = "A release has been collected and is currently downloading." elif snapshot.state == NormalizedState.importing: - status_label = "Downloaded — waiting for library import" - meaning = f"The download has finished and {collector} is preparing it for the media server." + if arr_state == "available" and not jellyfin_found: + 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": status_label = "Unable to read the library queue" meaning = ( @@ -527,6 +548,13 @@ def _build_presentation( next_title = "Let the current download finish" next_description = "Magent is tracking the active download; no action is needed right now." 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}: next_title = "Ready to watch" 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: 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: search_state = "complete" search_summary = "A release was found" @@ -593,6 +623,11 @@ def _build_presentation( download_stage_state, download_summary = "complete", completed_download_summary pipeline_download_visible = False 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: download_stage_state = { "downloading": "active", @@ -613,6 +648,8 @@ def _build_presentation( available_state, available_summary = "partial", f"{available} of {total} episodes available" elif jellyfin_found: 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: 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 elif qbit_state == "completed": if arr_state == "available": - snapshot.state = NormalizedState.completed - snapshot.state_reason = "In your library and ready to watch." + snapshot.state = NormalizedState.importing + snapshot.state_reason = "The collector imported the file. Waiting for the media server to index it." else: snapshot.state = NormalizedState.importing 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_reason = "Searching for a matching release." elif arr_state == "available": - snapshot.state = NormalizedState.completed - snapshot.state_reason = "In your library and ready to watch." + snapshot.state = NormalizedState.importing + 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: snapshot.state = NormalizedState.added_to_arr snapshot.state_reason = "Item is present in Sonarr/Radarr" diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index f368f47..fd8f0be 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -310,6 +310,96 @@ class RequestPresentationTests(unittest.TestCase): self.assertEqual(download_stage["state"], "attention") 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): async def test_live_download_progress_uses_saved_hash_and_current_qbittorrent_value(self) -> None: diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css index 2c41e43..9748787 100644 --- a/frontend/app/ops-redesign.css +++ b/frontend/app/ops-redesign.css @@ -1993,6 +1993,16 @@ button:disabled, font-size: 0.78rem; 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 { grid-column: 1 / -1; padding: 13px 18px; diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index 1beb838..8609a9f 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -427,6 +427,38 @@ export default function RequestTimelinePage() { const posterUrl = snapshot.artwork?.poster_url 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) => { if (action.requires_confirmation && !window.confirm(`Run “${action.label}”?`)) return const actionPaths: Record = { @@ -538,15 +570,22 @@ export default function RequestTimelinePage() { Next step {nextStep.title}

{nextStep.description}

- {recommendedActions.length > 0 && ( -
- {recommendedActions.map((action) => ( - - ))} -
- )} +
+ {recommendedActions.map((action) => ( + + ))} + +
{(actionMessage || actionError) && (