Add live download progress updates
Magent CI/CD / verify (push) Successful in 12m15s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 21s

This commit is contained in:
2026-08-29 20:52:49 +12:00
parent 655e2f8158
commit 96fc43365f
5 changed files with 242 additions and 35 deletions
+1
View File
@@ -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,
+127 -30
View File
@@ -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<Record<string, any>>
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<string, any>) => {
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<string, string> = {
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 (
<div className="request-torrent" key={torrent.hash ?? torrent.name}>
<div><strong>{torrent.name ?? 'Download'}</strong><span>{progress === null ? 'Progress unavailable' : `${progress}% complete`}</span></div>
{progress !== null && <div className="request-meter-track"><span style={{ width: `${progress}%` }} /></div>}
<div><strong>{torrent.name ?? 'Download'}</strong><span>{progress === null ? 'Progress unavailable' : formatProgress(progress)}</span></div>
{progress !== null && (
<div
className="request-meter-track is-live-download"
role="progressbar"
aria-label={`${torrent.name ?? 'Download'} progress`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
>
<span style={{ width: `${progress}%` }} />
</div>
)}
</div>
)
})}