From a5b2131d4cc11936813426c1482f824dc60da36e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 26 Aug 2026 12:47:19 +0100 Subject: [PATCH] feat(web): show live download state on rows (#264) --- web/src/downloads.ts | 64 ++++++++++++++++++++++++++++++++++ web/src/main.ts | 83 +++++++++++++++++++++++++++++++++++++++++++- web/src/style.css | 47 +++++++++++++++++++++++-- 3 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 web/src/downloads.ts diff --git a/web/src/downloads.ts b/web/src/downloads.ts new file mode 100644 index 0000000..e58b939 --- /dev/null +++ b/web/src/downloads.ts @@ -0,0 +1,64 @@ +// Hand-written mirror of arr-api's downloads schema — same reasoning as +// search.ts and series.ts: the generated client is uncommitted, so CI's tsc +// cannot see it. + +export type DownloadPhase = "downloading" | "stalled" | "errored" | "seeding" | "queued"; + +export interface Download { + target_kind: string; + target_id: number; + infohash: string; + name: string; + phase: DownloadPhase; + progress: number; + download_rate: number; + eta: number | null; + error: string | null; +} + +/** §9.8: the live snapshot, degrading to nothing rather than breaking the + * page it decorates — Transmission or the daemon being down is a fact the + * signal chain already reports, not something this poll repeats. */ +export async function fetchDownloads(): Promise { + try { + const response = await fetch("/api/downloads"); + if (!response.ok) { + return []; + } + return (await response.json()) as Download[]; + } catch { + return []; + } +} + +/** The one download a row owns, joined the same way the API already joins + * it server-side: exact `target_kind` and `target_id`, nothing aggregated + * client-side. An episode inside a season grabbed as a pack has no row of + * its own here, by construction — the grab is `target_kind: "season"`. */ +export function findDownload( + downloads: Download[], + targetKind: string, + targetId: number, +): Download | null { + return ( + downloads.find( + (download) => download.target_kind === targetKind && download.target_id === targetId, + ) ?? null + ); +} + +/** What the chip says: the percentage while it means something, the phase + * word once the number stops being the story (§9.8). */ +export function downloadChipLabel(download: Download): string { + if (download.phase === "downloading") { + return `${Math.round(download.progress * 100)}%`; + } + return download.phase; +} + +export function downloadChipDetail(download: Download): string { + if (download.phase === "errored" && download.error !== null) { + return `${download.name} — errored: ${download.error}`; + } + return `${download.name} — ${download.phase}`; +} diff --git a/web/src/main.ts b/web/src/main.ts index 7eac208..cfa0125 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,3 +1,10 @@ +import { + type Download, + downloadChipDetail, + downloadChipLabel, + fetchDownloads, + findDownload, +} from "./downloads"; import { type Check, type CheckStatus, @@ -160,6 +167,10 @@ import { const POLL_MS = 15_000; +/** §9.8: the live download snapshot, refetched on the app's one poll cadence + * (POLL_MS) rather than a second timer. Read by row builders at paint time. */ +let latestDownloads: Download[] = []; + type Tone = "ok" | "warn" | "fault" | "idle"; const TONE_BY_STATUS: Record = { @@ -338,6 +349,8 @@ function main() { let inFlight = false; // assigned once the queues view exists; the poll keeps the badge honest let refreshQueuesBadge: (() => void) | null = null; + // assigned once the library and series views exist (§9.8) + let repaintDownloads: (() => void) | null = null; async function probe() { if (inFlight) { @@ -345,6 +358,8 @@ function main() { } inFlight = true; render(await probeHealth()); + latestDownloads = await fetchDownloads(); + repaintDownloads?.(); inFlight = false; refreshQueuesBadge?.(); } @@ -386,6 +401,10 @@ function main() { }; movieDetail.setRemoved(openAfterRemoval); seriesDetail.setRemoved(openAfterRemoval); + repaintDownloads = () => { + library.repaintDownloads(); + seriesDetail.repaintDownloads(); + }; refreshQueuesBadge = () => { void queues.refreshBadge(); }; @@ -868,6 +887,31 @@ function mediaStateChip(state: string, wanted: boolean): HTMLSpanElement { }); } +/** + * §9.8: live download state on the row that owns the item. Absent unless + * arr itself grabbed this exact target — a season pack shows on the season + * row only, never trickling onto its episodes, because the API only ever + * returns a row for the `target_kind`/`target_id` actually grabbed. + * + * Fixed width regardless of content (a 9%-to-10% tick, or a phase word) so + * the chips beside it never shift — the same reasoning as the subtitle + * chip's `summaryWidth` (#258), sized in CSS instead of computed here since + * the set of possible values is fixed rather than config-dependent. + */ +function downloadChip(targetKind: string, targetId: number): HTMLSpanElement | null { + const download = findDownload(latestDownloads, targetKind, targetId); + if (download === null) { + return null; + } + return chip(downloadChipLabel(download), (span) => { + span.classList.add("dl-chip"); + span.dataset.dlPhase = download.phase; + const detail = downloadChipDetail(download); + span.title = detail; + span.setAttribute("aria-label", `download ${detail}`); + }); +} + /** The ramp read off N/M itself: green when full, amber while a gap remains. */ function countsTone(available: number, wanted: number): "complete" | "partial" | null { if (wanted === 0) { @@ -1968,6 +2012,10 @@ function libraryRow( const root = roots.find((candidate) => candidate.id === movie.root_id); chips.append(chip(root ? root.audience : `root ${movie.root_id}`)); chips.append(mediaStateChip(movie.state, movie.wanted)); + const download = downloadChip("movie", movie.id); + if (download !== null) { + chips.append(download); + } const waiver = waiverChip(movie.waiver); if (waiver !== null) { chips.append(waiver); @@ -3552,6 +3600,9 @@ function releaseRow( interface LibraryView { hide: () => void; open: () => void; + /** §9.8: repaints rows in place from the latest poll — only while the + * view is actually visible, so a hidden library never rebuilds for it. */ + repaintDownloads: () => void; } interface LibraryGroup { @@ -3743,7 +3794,13 @@ function libraryMain( render(); }); - return { hide, open }; + function repaintDownloads() { + if (!view.hidden) { + render(); + } + } + + return { hide, open, repaintDownloads }; } /** @@ -3906,6 +3963,10 @@ function movieCard( const root = roots.find((candidate) => candidate.id === movie.root_id); const chips = [chip(root ? root.audience : `root ${movie.root_id}`)]; chips.push(mediaStateChip(movie.state, movie.wanted)); + const download = downloadChip("movie", movie.id); + if (download !== null) { + chips.push(download); + } const waiver = waiverChip(movie.waiver); if (waiver !== null) { chips.push(waiver); @@ -4323,6 +4384,9 @@ interface SeriesView { ) => Promise; /** Where to land once the title is gone — same contract as a movie. */ setRemoved: (handler: (parent: Route) => void) => void; + /** §9.8: repaints seasons and episodes in place from the latest poll — + * only while the view is actually visible. */ + repaintDownloads: () => void; } const PAD_TWO = (value: number): string => String(value).padStart(2, "0"); @@ -4744,6 +4808,12 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { }); head.append(name, track, seasonCountsChip(season)); + // §9.8: a season row aggregates its own pack grab — one download for + // the whole season, never one per episode. + const download = downloadChip("season", season.id); + if (download !== null) { + head.append(download); + } // #227: the season the operator's report is about read `0/10` with // nothing saying a pack had been grabbed, downloaded in full and thrown // out at import. The counts chip beside this one is exactly the number @@ -4833,6 +4903,12 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { const chips = document.createElement("span"); chips.className = "row-chips ep-chips"; chips.append(mediaStateChip(episode.state, episode.wanted)); + // §9.8: nothing here if the episode is part of a season pack grab — that + // download is `target_kind: "season"` and shows on the season row instead. + const download = downloadChip("episode", episode.id); + if (download !== null) { + chips.append(download); + } // issue 122: gone upstream while its file remained — a conflict, not a state if (episode.vanished) { chips.append( @@ -5210,6 +5286,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { setRemoved: (handler: (parent: Route) => void) => { removed = handler; }, + repaintDownloads: () => { + if (!view.hidden) { + renderSeasons(); + } + }, }; } diff --git a/web/src/style.css b/web/src/style.css index be2dafd..1142b6e 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -832,6 +832,36 @@ body { border-color: oklch(from var(--signal-warn) l c h / 55%); } +/* §9.8: live download state on the row that owns the item. Fixed width so a + percentage ticking from 9% to 10%, or a phase word replacing it, never + reflows the chips beside it — sized to the longest phase word since the + set of values is fixed, unlike the subtitle chip's configured language set. */ +.dl-chip { + min-width: 7ch; + justify-content: center; + text-align: center; +} + +.dl-chip[data-dl-phase="downloading"] { + color: var(--status-airing); + border-color: oklch(from var(--status-airing) l c h / 45%); +} + +.dl-chip[data-dl-phase="stalled"] { + color: var(--signal-warn); + border-color: oklch(from var(--signal-warn) l c h / 55%); +} + +.dl-chip[data-dl-phase="errored"] { + color: var(--signal-fault); + border-color: oklch(from var(--signal-fault) l c h / 55%); +} + +.dl-chip[data-dl-phase="seeding"] { + color: var(--signal-ok); + border-color: oklch(from var(--signal-ok) l c h / 45%); +} + /* library status ramp (§4.2): violet while activity is live, green once everything wanted is on disk, amber for a grabbable gap; waiting is between seasons with nothing to do, so it stays neutral */ @@ -2256,6 +2286,14 @@ body { flex-basis: 100%; } + /* §9.8: only the active-grab percentage survives at phone width — stalled, + errored and seeding shed like any other secondary chip. Covers the movie + row and the season row; the episode row's own rule above does the same + for .ep-chips. */ + .dl-chip:not([data-dl-phase="downloading"]) { + display: none; + } + /* the season name shrinks to its tag so the head keeps fitting beside the controls; the long form has nowhere to go on a phone */ .season-name-long { @@ -2295,8 +2333,13 @@ body { /* the state chip stays, and so does a flag — a vanished episode is a conflict, not an attribute. Resolution, language and the waived-rule - chip go; they are what a wide screen is for. */ - .ep-chips .chip:not([data-movie-state]):not([data-flag]):not(.subs-chip) { + chip go; they are what a wide screen is for. A live downloading + percentage stays too (§9.8) — everything else the download chip can + say is what a wide screen is for. */ + .ep-chips + .chip:not([data-movie-state]):not([data-flag]):not(.subs-chip):not( + .dl-chip[data-dl-phase="downloading"] + ) { display: none; }