diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 67e84fc..a55581a 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -47,9 +47,15 @@ from ..db import ( is_seerr_media_failure_suppressed, record_seerr_media_failure, clear_seerr_media_failure, + get_request_download_evidence, ) from ..models import Snapshot, TriageResult, RequestType -from ..services.snapshot import build_snapshot, jellyfin_item_matches_request +from ..services.snapshot import ( + _summarize_qbit, + _torrent_progress, + build_snapshot, + jellyfin_item_matches_request, +) router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user)]) @@ -1644,6 +1650,73 @@ async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_curre return _filter_snapshot_actions_for_user(snapshot, user) +@router.get("/{request_id}/download-progress") +async def get_download_progress( + request_id: str, user: Dict[str, str] = Depends(get_current_user) +) -> Dict[str, Any]: + """Return a lightweight qBittorrent update for an open request page.""" + 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 seerr.configured(): + await _ensure_request_access(seerr, int(request_id), user) + + evidence = await asyncio.to_thread(get_request_download_evidence, request_id, 20) + historical_torrents = evidence.get("torrents") if isinstance(evidence, dict) else [] + hashes: List[str] = [] + if isinstance(historical_torrents, list): + hashes = list( + dict.fromkeys( + str(torrent.get("hash") or "").strip() + for torrent in historical_torrents + if isinstance(torrent, dict) and torrent.get("hash") + ) + ) + + qbittorrent = QBittorrentClient( + runtime.qbittorrent_base_url, + runtime.qbittorrent_username, + runtime.qbittorrent_password, + ) + if not qbittorrent.configured(): + raise HTTPException(status_code=503, detail="qBittorrent is not configured") + + try: + if hashes: + result = await qbittorrent.get_torrents_by_hashes("|".join(hashes)) + else: + result = await qbittorrent.get_torrents_by_tag(f"magent-{request_id}") + except Exception as exc: + logger.warning("Live qBittorrent progress failed request_id=%s: %s", request_id, exc) + raise HTTPException(status_code=502, detail="Live download progress is temporarily unavailable") from exc + + torrents = result if isinstance(result, list) else [] + for torrent in torrents: + if isinstance(torrent, dict): + torrent["progressPercent"] = _torrent_progress(torrent) + + if torrents: + summary = _summarize_qbit(torrents) + state = str(summary.get("state") or "idle") + message = str(summary.get("message") or "Download found in qBittorrent.") + elif evidence.get("observed"): + state = "missing" + message = "The previous download is no longer visible in qBittorrent." + else: + state = "not_started" + message = "No download attempt has been observed." + + return { + "request_id": request_id, + "state": state, + "summary": message, + "torrents": torrents, + "updated_at": datetime.now(timezone.utc).isoformat(), + } + + @router.get("/recent") async def recent_requests( take: int = 6, diff --git a/backend/app/services/snapshot.py b/backend/app/services/snapshot.py index 88fd3c0..ffbb50b 100644 --- a/backend/app/services/snapshot.py +++ b/backend/app/services/snapshot.py @@ -382,14 +382,14 @@ def _artwork_url(path: Optional[str], size: str, cache_mode: str) -> Optional[st return f"https://image.tmdb.org/t/p/{size}{path}" -def _torrent_progress(torrent: Dict[str, Any]) -> Optional[int]: +def _torrent_progress(torrent: Dict[str, Any]) -> Optional[float]: progress = torrent.get("progress") try: numeric = float(progress) except (TypeError, ValueError): numeric = -1 if 0 <= numeric <= 1: - return round(numeric * 100) + return round(numeric * 100, 1) try: size = float(torrent.get("size")) amount_left = float(torrent.get("amount_left")) @@ -397,7 +397,7 @@ def _torrent_progress(torrent: Dict[str, Any]) -> Optional[int]: return None if size <= 0: return None - return max(0, min(100, round(((size - amount_left) / size) * 100))) + return max(0.0, min(100.0, round(((size - amount_left) / size) * 100, 1))) def _build_presentation( diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index bb9930d..11a8281 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -19,7 +19,7 @@ from backend.app.routers import site as site_router from backend.app.routers import status as status_router from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy from backend.app.services import password_reset -from backend.app.services.snapshot import _build_presentation, _episode_availability +from backend.app.services.snapshot import _build_presentation, _episode_availability, _torrent_progress def _build_request(ip: str = "127.0.0.1", user_agent: str = "backend-test") -> Request: @@ -173,6 +173,9 @@ class RequestCacheTests(unittest.TestCase): class RequestPresentationTests(unittest.TestCase): + def test_torrent_progress_keeps_tenths_for_live_updates(self) -> None: + self.assertEqual(_torrent_progress({"progress": 0.1344}), 13.4) + def test_episode_availability_counts_only_aired_monitored_episodes(self) -> None: episodes = [ {"seasonNumber": 1, "episodeNumber": 1, "monitored": True, "hasFile": True}, @@ -226,6 +229,39 @@ class RequestPresentationTests(unittest.TestCase): self.assertEqual(download_stage["summary"], "No download attempt yet") +class LiveDownloadProgressTests(unittest.IsolatedAsyncioTestCase): + async def test_live_download_progress_uses_saved_hash_and_current_qbittorrent_value(self) -> None: + runtime = SimpleNamespace( + jellyseerr_base_url=None, + jellyseerr_api_key=None, + qbittorrent_base_url="http://qbittorrent.test", + qbittorrent_username="magent", + qbittorrent_password="secret", + ) + evidence = { + "observed": True, + "torrents": [{"hash": "abc123", "progress": 0.12}], + } + current = [{"hash": "abc123", "name": "Example", "progress": 0.1344, "state": "downloading"}] + + with patch.object(requests_router, "get_runtime_settings", return_value=runtime), patch.object( + requests_router, + "get_request_download_evidence", + return_value=evidence, + ), patch.object( + requests_router.QBittorrentClient, + "get_torrents_by_hashes", + new=AsyncMock(return_value=current), + ) as get_torrents: + result = await requests_router.get_download_progress( + "3909", user={"username": "viewer", "role": "user"} + ) + + get_torrents.assert_awaited_once_with("abc123") + self.assertEqual(result["state"], "downloading") + self.assertEqual(result["torrents"][0]["progressPercent"], 13.4) + + class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase): def test_set_user_email_is_case_insensitive(self) -> None: created = db.create_user_if_missing( diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css index 109812c..793438a 100644 --- a/frontend/app/ops-redesign.css +++ b/frontend/app/ops-redesign.css @@ -1660,6 +1660,7 @@ button:disabled, .request-torrent { display: grid; gap: 8px; margin-top: 4px; padding-top: 10px; border-top: 1px solid var(--ops-line-soft); } .request-meter-track { height: 7px; overflow: hidden; border: 1px solid var(--ops-line-soft); border-radius: 999px; background: rgba(255, 255, 255, 0.06); } .request-meter-track > span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--request-green), var(--request-cyan)); box-shadow: 0 0 14px rgba(72, 224, 178, 0.5); } +.request-meter-track.is-live-download > span { transition: width 1.8s linear; will-change: width; } .request-season-row, .request-missing-list > div { padding-top: 6px; border-top: 1px solid rgba(255, 255, 255, 0.045); } .request-missing-list strong, diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index 39c6b5a..888ecdc 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import { useParams, useRouter } from 'next/navigation' import { useEffect, useMemo, useState } from 'react' -import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from '../../lib/auth' +import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' type TimelineHop = { service: string @@ -90,6 +90,14 @@ type ActionHistory = { created_at: string } +type LiveDownloadProgress = { + request_id: string + state: string + summary: string + torrents: Array> + updated_at: string +} + const readApiError = async (response: Response, fallback: string) => { try { const contentType = response.headers.get('content-type') ?? '' @@ -133,13 +141,45 @@ const formatBytes = (value?: number) => { } const torrentProgress = (torrent: Record) => { - const supplied = Number(torrent.progressPercent) - if (!Number.isNaN(supplied) && supplied >= 0 && supplied <= 100) return Math.round(supplied) const progress = Number(torrent.progress) - if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) return Math.round(progress * 100) + if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) return Math.round(progress * 1000) / 10 + const supplied = Number(torrent.progressPercent) + if (!Number.isNaN(supplied) && supplied >= 0 && supplied <= 100) return Math.round(supplied * 10) / 10 return null } +const formatProgress = (progress: number) => `${progress.toFixed(1).replace(/\.0$/, '')}% complete` + +const mergeLiveDownload = (current: Snapshot, live: LiveDownloadProgress): Snapshot => { + if (String(current.request_id) !== String(live.request_id)) return current + const stageState = live.state === 'completed' + ? 'complete' + : ['missing', 'error'].includes(live.state) + ? 'attention' + : 'active' + const presentation = current.presentation ?? {} + const pipeline = (presentation.pipeline ?? fallbackPipeline(current)).map((stage) => + stage.id === 'download' + ? { ...stage, state: stageState, summary: live.summary, visible: true, torrents: live.torrents } + : stage + ) + return { + ...current, + presentation: { + ...presentation, + download: { + ...(presentation.download ?? {}), + visible: true, + state: live.state, + summary: live.summary, + torrents: live.torrents, + lastSeenAt: live.updated_at, + }, + pipeline, + }, + } +} + const fallbackStatusLabel = (state: string) => { const labels: Record = { REQUESTED: 'Waiting for approval', @@ -256,38 +296,84 @@ export default function RequestTimelinePage() { useEffect(() => { if (!getToken() || !requestId) return - const baseUrl = getApiBase() - let closed = false - let source: EventSource | null = null - const connect = async () => { + let stopped = false + const refresh = async () => { + if (document.visibilityState === 'hidden') return try { - const streamToken = await getEventStreamToken() - if (closed) return - source = new EventSource( - `${baseUrl}/events/requests/${encodeURIComponent(requestId)}/stream?stream_token=${encodeURIComponent(streamToken)}` - ) - source.onmessage = (event) => { - if (closed) return - try { - const payload = JSON.parse(event.data) - if (payload?.type !== 'request_live' || String(payload.request_id ?? '') !== String(requestId)) return - if (isSnapshotPayload(payload.snapshot)) setSnapshot(payload.snapshot) - if (Array.isArray(payload.history)) setHistorySnapshots(payload.history) - if (Array.isArray(payload.actions)) setHistoryActions(payload.actions) - } catch (error) { - console.error(error) + const response = await authFetch(`${getApiBase()}/requests/${requestId}/snapshot`) + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + if (!response.ok) return + const payload = await response.json() + if (!stopped && isSnapshotPayload(payload)) setSnapshot(payload) + } catch (error) { + if (!stopped) console.error(error) + } + } + const timer = window.setInterval(() => void refresh(), 15_000) + return () => { + stopped = true + window.clearInterval(timer) + } + }, [requestId, router]) + + const liveDownloadKey = useMemo(() => { + const downloadStage = snapshot?.presentation?.pipeline?.find((stage) => stage.id === 'download') + if (!downloadStage?.visible || downloadStage.state !== 'active') return '' + return (downloadStage.torrents ?? []) + .filter((torrent) => (torrentProgress(torrent) ?? 100) < 100) + .map((torrent) => String(torrent.hash ?? torrent.name ?? 'download')) + .sort() + .join('|') + }, [snapshot]) + + useEffect(() => { + if (!getToken() || !requestId || !liveDownloadKey) return + let stopped = false + let timer: number | undefined + let controller: AbortController | null = null + const schedule = () => { + if (!stopped) timer = window.setTimeout(() => void refresh(), 2_000) + } + const refresh = async () => { + if (document.visibilityState === 'hidden') { + schedule() + return + } + controller = new AbortController() + try { + const response = await authFetch(`${getApiBase()}/requests/${requestId}/download-progress`, { + signal: controller.signal, + cache: 'no-store', + }) + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + if (response.ok) { + const payload = await response.json() as LiveDownloadProgress + if (!stopped && Array.isArray(payload.torrents)) { + setSnapshot((current) => current ? mergeLiveDownload(current, payload) : current) } } } catch (error) { - if (!closed) console.error(error) + if (!stopped && !(error instanceof DOMException && error.name === 'AbortError')) console.error(error) + } finally { + controller = null + schedule() } } - void connect() + timer = window.setTimeout(() => void refresh(), 750) return () => { - closed = true - source?.close() + stopped = true + if (timer !== undefined) window.clearTimeout(timer) + controller?.abort() } - }, [requestId]) + }, [liveDownloadKey, requestId, router]) const actionsById = useMemo( () => new Map((snapshot?.actions ?? []).map((action) => [action.id, action])), @@ -519,8 +605,19 @@ export default function RequestTimelinePage() { const progress = torrentProgress(torrent) return (
-
{torrent.name ?? 'Download'}{progress === null ? 'Progress unavailable' : `${progress}% complete`}
- {progress !== null &&
} +
{torrent.name ?? 'Download'}{progress === null ? 'Progress unavailable' : formatProgress(progress)}
+ {progress !== null && ( +
+ +
+ )}
) })}