Add live download progress updates
This commit is contained in:
@@ -47,9 +47,15 @@ from ..db import (
|
|||||||
is_seerr_media_failure_suppressed,
|
is_seerr_media_failure_suppressed,
|
||||||
record_seerr_media_failure,
|
record_seerr_media_failure,
|
||||||
clear_seerr_media_failure,
|
clear_seerr_media_failure,
|
||||||
|
get_request_download_evidence,
|
||||||
)
|
)
|
||||||
from ..models import Snapshot, TriageResult, RequestType
|
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)])
|
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)
|
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")
|
@router.get("/recent")
|
||||||
async def recent_requests(
|
async def recent_requests(
|
||||||
take: int = 6,
|
take: int = 6,
|
||||||
|
|||||||
@@ -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}"
|
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")
|
progress = torrent.get("progress")
|
||||||
try:
|
try:
|
||||||
numeric = float(progress)
|
numeric = float(progress)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
numeric = -1
|
numeric = -1
|
||||||
if 0 <= numeric <= 1:
|
if 0 <= numeric <= 1:
|
||||||
return round(numeric * 100)
|
return round(numeric * 100, 1)
|
||||||
try:
|
try:
|
||||||
size = float(torrent.get("size"))
|
size = float(torrent.get("size"))
|
||||||
amount_left = float(torrent.get("amount_left"))
|
amount_left = float(torrent.get("amount_left"))
|
||||||
@@ -397,7 +397,7 @@ def _torrent_progress(torrent: Dict[str, Any]) -> Optional[int]:
|
|||||||
return None
|
return None
|
||||||
if size <= 0:
|
if size <= 0:
|
||||||
return None
|
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(
|
def _build_presentation(
|
||||||
|
|||||||
@@ -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.routers import status as status_router
|
||||||
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
|
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
|
||||||
from backend.app.services import password_reset
|
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:
|
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):
|
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:
|
def test_episode_availability_counts_only_aired_monitored_episodes(self) -> None:
|
||||||
episodes = [
|
episodes = [
|
||||||
{"seasonNumber": 1, "episodeNumber": 1, "monitored": True, "hasFile": True},
|
{"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")
|
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):
|
class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
|
||||||
def test_set_user_email_is_case_insensitive(self) -> None:
|
def test_set_user_email_is_case_insensitive(self) -> None:
|
||||||
created = db.create_user_if_missing(
|
created = db.create_user_if_missing(
|
||||||
|
|||||||
@@ -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-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 { 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 > 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-season-row,
|
||||||
.request-missing-list > div { padding-top: 6px; border-top: 1px solid rgba(255, 255, 255, 0.045); }
|
.request-missing-list > div { padding-top: 6px; border-top: 1px solid rgba(255, 255, 255, 0.045); }
|
||||||
.request-missing-list strong,
|
.request-missing-list strong,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
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 = {
|
type TimelineHop = {
|
||||||
service: string
|
service: string
|
||||||
@@ -90,6 +90,14 @@ type ActionHistory = {
|
|||||||
created_at: string
|
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) => {
|
const readApiError = async (response: Response, fallback: string) => {
|
||||||
try {
|
try {
|
||||||
const contentType = response.headers.get('content-type') ?? ''
|
const contentType = response.headers.get('content-type') ?? ''
|
||||||
@@ -133,13 +141,45 @@ const formatBytes = (value?: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const torrentProgress = (torrent: Record<string, any>) => {
|
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)
|
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
|
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 fallbackStatusLabel = (state: string) => {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
REQUESTED: 'Waiting for approval',
|
REQUESTED: 'Waiting for approval',
|
||||||
@@ -256,38 +296,84 @@ export default function RequestTimelinePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken() || !requestId) return
|
if (!getToken() || !requestId) return
|
||||||
const baseUrl = getApiBase()
|
let stopped = false
|
||||||
let closed = false
|
const refresh = async () => {
|
||||||
let source: EventSource | null = null
|
if (document.visibilityState === 'hidden') return
|
||||||
const connect = async () => {
|
|
||||||
try {
|
try {
|
||||||
const streamToken = await getEventStreamToken()
|
const response = await authFetch(`${getApiBase()}/requests/${requestId}/snapshot`)
|
||||||
if (closed) return
|
if (response.status === 401) {
|
||||||
source = new EventSource(
|
clearToken()
|
||||||
`${baseUrl}/events/requests/${encodeURIComponent(requestId)}/stream?stream_token=${encodeURIComponent(streamToken)}`
|
router.push('/login')
|
||||||
)
|
return
|
||||||
source.onmessage = (event) => {
|
}
|
||||||
if (closed) return
|
if (!response.ok) return
|
||||||
try {
|
const payload = await response.json()
|
||||||
const payload = JSON.parse(event.data)
|
if (!stopped && isSnapshotPayload(payload)) setSnapshot(payload)
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error(error)
|
if (!stopped) console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
const timer = window.setInterval(() => void refresh(), 15_000)
|
||||||
if (!closed) console.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void connect()
|
|
||||||
return () => {
|
return () => {
|
||||||
closed = true
|
stopped = true
|
||||||
source?.close()
|
window.clearInterval(timer)
|
||||||
}
|
}
|
||||||
}, [requestId])
|
}, [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 (!stopped && !(error instanceof DOMException && error.name === 'AbortError')) console.error(error)
|
||||||
|
} finally {
|
||||||
|
controller = null
|
||||||
|
schedule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer = window.setTimeout(() => void refresh(), 750)
|
||||||
|
return () => {
|
||||||
|
stopped = true
|
||||||
|
if (timer !== undefined) window.clearTimeout(timer)
|
||||||
|
controller?.abort()
|
||||||
|
}
|
||||||
|
}, [liveDownloadKey, requestId, router])
|
||||||
|
|
||||||
const actionsById = useMemo(
|
const actionsById = useMemo(
|
||||||
() => new Map((snapshot?.actions ?? []).map((action) => [action.id, action])),
|
() => new Map((snapshot?.actions ?? []).map((action) => [action.id, action])),
|
||||||
@@ -519,8 +605,19 @@ export default function RequestTimelinePage() {
|
|||||||
const progress = torrentProgress(torrent)
|
const progress = torrentProgress(torrent)
|
||||||
return (
|
return (
|
||||||
<div className="request-torrent" key={torrent.hash ?? torrent.name}>
|
<div className="request-torrent" key={torrent.hash ?? torrent.name}>
|
||||||
<div><strong>{torrent.name ?? 'Download'}</strong><span>{progress === null ? 'Progress unavailable' : `${progress}% complete`}</span></div>
|
<div><strong>{torrent.name ?? 'Download'}</strong><span>{progress === null ? 'Progress unavailable' : formatProgress(progress)}</span></div>
|
||||||
{progress !== null && <div className="request-meter-track"><span style={{ width: `${progress}%` }} /></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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user