Merge remote-tracking branch 'origin/issue/264-download-chips' into blitz/download-visibility

This commit is contained in:
Miguel Palhas
2026-08-26 12:48:13 +01:00
3 changed files with 191 additions and 3 deletions
+64
View File
@@ -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<Download[]> {
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}`;
}
+82 -1
View File
@@ -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<CheckStatus, Tone> = {
@@ -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<void>;
/** 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();
}
},
};
}
+45 -2
View File
@@ -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;
}