Add live download progress updates
This commit is contained in:
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user