Merge remote-tracking branch 'origin/issue/264-download-chips' into blitz/download-visibility
This commit is contained in:
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user