Library views with derived status (#83)
ci / web (push) Successful in 33s
ci / rust (push) Successful in 1m29s
e2e / e2e (push) Successful in 1m12s

This commit was merged in pull request #83.
This commit is contained in:
2026-08-22 23:33:05 +01:00
parent 0343783a57
commit f38fb1277b
18 changed files with 578 additions and 46 deletions
+48
View File
@@ -43,12 +43,28 @@
row names its killing rule; one click on a waived row writes the
override and grabs. No horizontal scroll at any viewport. Esc returns
to the search deck.
LIBRARY (§4.2, issue #32): the LIBRARY control on the rail opens the
library deck — SERIES then MOVIES, machined rows with the title left
and chips right. Status is derived and only displayed: series carry a
§4.2 status chip (channel-violet activity, neutral satisfaction, the
mono word always present), movies carry their state chip. There is no
monitored flag anywhere; intent is wanted, at the leaf. The default
list shows what needs attention — airing and incomplete series,
wanted-and-missing movies — and everything satisfied collapses behind
ONE toggle with its count. A file imported under a waiver reads
honestly ("english, no dub") as a dashed amber chip, never as a clean
match. A movie row opens the release deck and returns here on Esc.
-->
<header class="rail">
<div class="rail-id">
<span class="lamp" id="master-lamp" data-state="probing" aria-hidden="true"></span>
<h1 class="wordmark">arr</h1>
</div>
<nav class="rail-nav" aria-label="views">
<button type="button" class="control" id="nav-library" aria-pressed="false">
library
</button>
</nav>
<div class="rail-search">
<input
id="search"
@@ -95,6 +111,38 @@
</section>
</main>
<main class="deck" id="library" hidden aria-label="library">
<header class="deck-head library-bar">
<h2 class="deck-label">library</h2>
<span class="deck-count readout" id="library-summary"></span>
<button
type="button"
class="bucket-toggle readout"
id="library-toggle"
aria-expanded="false"
aria-controls="library-series library-movies"
hidden
></button>
</header>
<p class="deck-status readout" id="library-status" role="status" hidden></p>
<section class="deck-group" id="library-series" hidden aria-labelledby="label-lib-series">
<header class="deck-head">
<h3 class="deck-label" id="label-lib-series">series</h3>
<span class="deck-count readout" id="count-lib-series"></span>
</header>
<ul class="deck-rows" id="rows-lib-series"></ul>
</section>
<section class="deck-group" id="library-movies" hidden aria-labelledby="label-lib-movies">
<header class="deck-head">
<h3 class="deck-label" id="label-lib-movies">movies</h3>
<span class="deck-count readout" id="count-lib-movies"></span>
</header>
<ul class="deck-rows" id="rows-lib-movies"></ul>
</section>
</main>
<main class="deck releases" id="releases" hidden>
<header class="releases-head">
<button type="button" class="control" id="releases-back">back</button>
+105
View File
@@ -0,0 +1,105 @@
// Hand-written mirror of arr-api's /api/series and /api/movies list schemas —
// same reasoning as search.ts: the generated client (src/api/) is
// uncommitted, so CI's tsc cannot see it.
import type { LibraryMovie } from "./search";
/** §4.2 derived status — displayed, never editable. */
export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended";
export interface LibrarySeries {
id: number;
tmdb_id: number;
title: string;
year: number | null;
original_language: string | null;
root_id: number;
auto_track: boolean;
upstream_ended: boolean;
blocked: boolean;
status: SeriesStatus;
wanted_episodes: number;
available_episodes: number;
}
export type LibraryOutcome =
| { kind: "results"; series: LibrarySeries[]; movies: LibraryMovie[] }
| { kind: "error"; detail: string };
export async function fetchLibrary(): Promise<LibraryOutcome> {
try {
const [series, movies] = await Promise.all([fetch("/api/series"), fetch("/api/movies")]);
if (!series.ok) {
return { kind: "error", detail: await errorDetail(series) };
}
if (!movies.ok) {
return { kind: "error", detail: await errorDetail(movies) };
}
return {
kind: "results",
series: (await series.json()) as LibrarySeries[],
movies: (await movies.json()) as LibraryMovie[],
};
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/**
* §4.2: the default list shows `airing` and `incomplete`; everything else
* collapses behind one toggle. A satisfied title leaves by itself.
*/
export function seriesNeedsAttention(series: LibrarySeries): boolean {
return series.status === "airing" || series.status === "incomplete";
}
/**
* The movie analogue of §4.2's default set: wanted and not yet on disk.
* Available and unwanted titles are satisfied and collapse with the rest.
*/
export function movieNeedsAttention(movie: LibraryMovie): boolean {
return movie.wanted && movie.state !== "available";
}
/**
* The §5.7 waiver, worded honestly: a file imported under
* `allow_english_audio` reads as "english, no dub", not as a clean match.
* The import pipeline records the relaxed rule; both the bare rule name and
* an object carrying one are accepted.
*/
export function waiverLabel(waiver: unknown): string | null {
const rule = waiverRule(waiver);
if (rule === null) {
return null;
}
switch (rule) {
case "required_audio":
return "english, no dub";
case "resolution":
return "below wanted resolution";
default:
return `waived · ${rule.replaceAll("_", " ")}`;
}
}
function waiverRule(waiver: unknown): string | null {
if (waiver === null || waiver === undefined) {
return null;
}
if (typeof waiver === "string") {
return waiver;
}
if (typeof waiver === "object" && "rule" in waiver && typeof waiver.rule === "string") {
return waiver.rule;
}
return "unknown_rule";
}
async function errorDetail(response: Response): Promise<string> {
try {
const body = (await response.json()) as { error?: string };
return body.error ?? `http ${response.status}`;
} catch {
return `http ${response.status}`;
}
}
+241 -10
View File
@@ -1,4 +1,11 @@
import { type CheckStatus, type Probe, probeHealth } from "./health";
import {
fetchLibrary,
type LibrarySeries,
movieNeedsAttention,
seriesNeedsAttention,
waiverLabel,
} from "./library";
import {
bucketOf,
formatAudio,
@@ -19,6 +26,7 @@ import {
} from "./releases";
import {
addMovie,
allRoots,
type LibraryMovie,
movieRoots,
parseManualInput,
@@ -183,7 +191,9 @@ function main() {
}, POLL_MS);
void probe();
searchMain(board, releasesMain());
const releases = releasesMain();
const library = libraryMain(board, releases);
searchMain(board, releases, library);
}
const DEBOUNCE_MS = 250;
@@ -199,7 +209,7 @@ interface DeckRefs {
manualIntake: HTMLElement;
}
function searchMain(board: HTMLElement, releases: ReleasesView) {
function searchMain(board: HTMLElement, releases: ReleasesView, library: LibraryView) {
const input = must<HTMLInputElement>("#search");
const hint = must<HTMLElement>("#search-hint");
const refs: DeckRefs = {
@@ -241,12 +251,14 @@ function searchMain(board: HTMLElement, releases: ReleasesView) {
controller?.abort();
controller = null;
releases.hide();
library.hide();
refs.deck.hidden = true;
board.hidden = false;
}
function showDeck() {
releases.hide();
library.hide();
board.hidden = true;
refs.deck.hidden = false;
}
@@ -310,7 +322,11 @@ function searchMain(board: HTMLElement, releases: ReleasesView) {
refs.groups.library.section.hidden = false;
refs.groups.library.count.textContent = String(response.library.length);
for (const movie of response.library) {
refs.groups.library.rows.append(libraryRow(movie, roots, releases.open));
refs.groups.library.rows.append(
libraryRow(movie, roots, (target, origin) => {
releases.open(target, origin, refs.deck);
}),
);
}
}
if (response.tmdb.length > 0) {
@@ -441,6 +457,15 @@ function libraryRow(
span.dataset.movieState = movie.state;
}),
);
// §5.7 honesty: a waived import is never presented as a clean match
const waiver = waiverLabel(movie.waiver);
if (waiver !== null) {
chips.append(
chip(waiver, (span) => {
span.dataset.verdict = "waived";
}),
);
}
if (!movie.wanted) {
chips.append(chip("not wanted"));
}
@@ -623,7 +648,8 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML
/* ---- release deck (§9.3, issue #31) ---------------------------------- */
interface ReleasesView {
open: (movie: LibraryMovie, origin: HTMLElement) => void;
/** Opens over `returnTo`, which back and Esc restore. */
open: (movie: LibraryMovie, origin: HTMLElement, returnTo: HTMLElement) => void;
hide: () => void;
}
@@ -662,6 +688,7 @@ function releasesMain(): ReleasesView {
let current: LibraryMovie | null = null;
let origin: HTMLElement | null = null;
let returnTo: HTMLElement = deck;
const actions: ReleaseActions = {
reload: () => load(),
notify: (text, tone) => setStatus(text, tone),
@@ -770,12 +797,13 @@ function releasesMain(): ReleasesView {
render(outcome.releases);
}
function open(movie: LibraryMovie, from: HTMLElement) {
function open(movie: LibraryMovie, from: HTMLElement, container: HTMLElement) {
current = movie;
origin = from;
returnTo = container;
title.textContent = movie.title;
year.textContent = movie.year === null ? "—" : String(movie.year);
deck.hidden = true;
container.hidden = true;
view.hidden = false;
clearBuckets();
sweep.disabled = false;
@@ -793,19 +821,19 @@ function releasesMain(): ReleasesView {
function close() {
const target = origin;
hide();
deck.hidden = false;
returnTo.hidden = false;
target?.focus();
}
back.addEventListener("click", close);
// capture + stopPropagation: one Escape steps back one layer, instead
// of falling through to the search deck's own Escape handler
// capture + stopImmediatePropagation: one Escape steps back one layer
// the library and search decks also listen for Escape on this window
window.addEventListener(
"keydown",
(event) => {
if (event.key === "Escape" && !view.hidden) {
event.stopPropagation();
event.stopImmediatePropagation();
close();
}
},
@@ -980,6 +1008,209 @@ function releaseRow(
return item;
}
/* ---- library view (§4.2, issue #32) ----------------------------------- */
interface LibraryView {
hide: () => void;
}
interface LibraryGroup {
section: HTMLElement;
count: HTMLElement;
rows: HTMLUListElement;
}
function libraryMain(board: HTMLElement, releases: ReleasesView): LibraryView {
const view = must<HTMLElement>("#library");
const deck = must<HTMLElement>("#deck");
const nav = must<HTMLButtonElement>("#nav-library");
const summary = must<HTMLElement>("#library-summary");
const toggle = must<HTMLButtonElement>("#library-toggle");
const status = must<HTMLElement>("#library-status");
const groups: { series: LibraryGroup; movies: LibraryGroup } = {
series: {
section: must<HTMLElement>("#library-series"),
count: must<HTMLElement>("#count-lib-series"),
rows: must<HTMLUListElement>("#rows-lib-series"),
},
movies: {
section: must<HTMLElement>("#library-movies"),
count: must<HTMLElement>("#count-lib-movies"),
rows: must<HTMLUListElement>("#rows-lib-movies"),
},
};
// §4.2: ONE toggle for everything the default view leaves out
let showAll = false;
let data: { series: LibrarySeries[]; movies: LibraryMovie[] } | null = null;
let roots: Root[] = [];
// guards a stale fetch from painting over a newer view
let sequence = 0;
function setStatus(text: string | null, tone?: "fault") {
status.hidden = text === null;
status.textContent = text ?? "";
if (tone) {
status.dataset.tone = tone;
} else {
delete status.dataset.tone;
}
}
function clearGroups() {
for (const group of [groups.series, groups.movies]) {
group.section.hidden = true;
group.rows.replaceChildren();
}
summary.textContent = "";
toggle.hidden = true;
}
function renderGroup<T>(group: LibraryGroup, items: T[], build: (item: T) => HTMLLIElement) {
group.rows.replaceChildren();
group.section.hidden = items.length === 0;
group.count.textContent = String(items.length);
for (const item of items) {
group.rows.append(build(item));
}
}
function render() {
const library = data;
if (!library) {
return;
}
const total = library.series.length + library.movies.length;
const satisfied =
library.series.filter((series) => !seriesNeedsAttention(series)).length +
library.movies.filter((movie) => !movieNeedsAttention(movie)).length;
summary.textContent = total === 0 ? "" : `${total} ${total === 1 ? "title" : "titles"}`;
toggle.hidden = satisfied === 0;
toggle.textContent = `${showAll ? "hide" : "show"} ${satisfied} satisfied`;
toggle.setAttribute("aria-expanded", String(showAll));
const seriesShown = showAll ? library.series : library.series.filter(seriesNeedsAttention);
const moviesShown = showAll ? library.movies : library.movies.filter(movieNeedsAttention);
renderGroup(groups.series, seriesShown, (series) => seriesRow(series, roots));
renderGroup(groups.movies, moviesShown, (movie) =>
libraryRow(movie, roots, (target, origin) => {
releases.open(target, origin, view);
}),
);
if (total === 0) {
setStatus("library is empty — the search box above adds titles");
} else if (seriesShown.length + moviesShown.length === 0) {
setStatus("nothing needs attention");
} else {
setStatus(null);
}
}
async function load() {
sequence += 1;
const ticket = sequence;
setStatus("reading library…");
const [outcome, fetchedRoots] = await Promise.all([
fetchLibrary(),
allRoots().catch(() => roots),
]);
if (ticket !== sequence) {
return;
}
roots = fetchedRoots;
if (outcome.kind === "error") {
clearGroups();
setStatus(`library unavailable — ${outcome.detail}`, "fault");
return;
}
data = { series: outcome.series, movies: outcome.movies };
render();
}
function open() {
releases.hide();
board.hidden = true;
deck.hidden = true;
view.hidden = false;
nav.setAttribute("aria-pressed", "true");
void load();
}
function hide() {
view.hidden = true;
nav.setAttribute("aria-pressed", "false");
sequence += 1;
}
function close() {
hide();
board.hidden = false;
nav.focus();
}
nav.addEventListener("click", () => {
if (view.hidden) {
open();
} else {
close();
}
});
toggle.addEventListener("click", () => {
showAll = !showAll;
render();
});
// capture, like the release deck: one Esc steps back one layer
window.addEventListener(
"keydown",
(event) => {
if (event.key === "Escape" && !view.hidden) {
event.stopImmediatePropagation();
close();
}
},
true,
);
return { hide };
}
/**
* One series with its §4.2 derived status: displayed, never editable.
* No release deck yet — per-season and per-episode actions are issue #39.
*/
function seriesRow(series: LibrarySeries, roots: Root[]): HTMLLIElement {
const item = document.createElement("li");
item.className = "row";
const chips = document.createElement("span");
chips.className = "row-chips";
const root = roots.find((candidate) => candidate.id === series.root_id);
chips.append(chip(root ? root.audience : `root ${series.root_id}`));
if (series.wanted_episodes > 0) {
chips.append(
chip(`${series.available_episodes}/${series.wanted_episodes} eps`, (span) => {
span.setAttribute(
"aria-label",
`${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`,
);
}),
);
}
if (series.blocked) {
chips.append(chip("blocked"));
}
chips.append(
chip(series.status, (span) => {
span.dataset.status = series.status;
}),
);
item.append(rowTitle(series.title, series.year), chips);
return item;
}
function renderManual(intake: HTMLElement, kind: "magnet" | "torrent_url", raw: string) {
const parsed = parseManualInput(kind, raw);
intake.replaceChildren();
+11 -5
View File
@@ -12,8 +12,10 @@ export interface LibraryMovie {
original_language: string | null;
root_id: number;
wanted: boolean;
state: "missing" | "grabbed" | "imported";
state: "missing" | "downloading" | "available";
blocked: boolean;
/** The §5.7 rule relaxed to allow an import, when a file carries one. */
waiver?: unknown;
}
export interface TmdbMovie {
@@ -64,8 +66,8 @@ export async function searchTitles(query: string, signal: AbortSignal): Promise<
let rootsCache: Root[] | null = null;
/** The movie roots, fetched once. The add flow pre-fills from these. */
export async function movieRoots(): Promise<Root[]> {
/** Every root, fetched once. Audience chips resolve root ids through these. */
export async function allRoots(): Promise<Root[]> {
if (rootsCache) {
return rootsCache;
}
@@ -73,11 +75,15 @@ export async function movieRoots(): Promise<Root[]> {
if (!response.ok) {
throw new Error(await errorDetail(response));
}
const roots = (await response.json()) as Root[];
rootsCache = roots.filter((root) => root.kind === "movie");
rootsCache = (await response.json()) as Root[];
return rootsCache;
}
/** The movie roots. The add flow pre-fills from these. */
export async function movieRoots(): Promise<Root[]> {
return (await allRoots()).filter((root) => root.kind === "movie");
}
export type AddOutcome =
| { kind: "added"; movie: LibraryMovie }
| { kind: "conflict" }
+64 -1
View File
@@ -97,6 +97,11 @@ body {
padding: 0;
}
/* a scrollbar appearing must not reflow the rail's wrap */
html {
scrollbar-gutter: stable;
}
body {
min-height: 100dvh;
display: flex;
@@ -467,6 +472,12 @@ body {
cursor: default;
}
/* the engaged view control stays lit while its surface is up */
.control[aria-pressed="true"] {
border-color: var(--accent);
color: var(--accent-bright);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
@@ -641,10 +652,34 @@ body {
color: var(--verdict-rejected);
}
.chip[data-movie-state="imported"] {
.chip[data-movie-state="available"] {
color: var(--ink);
}
/* library status (§4.2): informational, never a severity ramp — activity in
the channel-violet family, satisfaction in neutrals; the word is always
present, so the state survives with colour removed */
.chip[data-status="airing"] {
color: var(--status-airing);
border-color: oklch(from var(--status-airing) l c h / 45%);
}
.chip[data-status="incomplete"] {
color: var(--status-incomplete);
}
.chip[data-status="waiting"] {
color: var(--status-waiting);
}
.chip[data-status="complete"] {
color: var(--status-complete);
}
.chip[data-status="ended"] {
color: var(--status-ended);
}
/* a TMDB row is the affordance: the whole row opens the add flow */
.row-tmdb {
font: inherit;
@@ -952,6 +987,23 @@ body {
font-size: var(--text-2xs);
}
/* ---- library view (§4.2) ---------------------------------------------- */
.rail-nav {
flex: none;
}
/* the library's master line: total readout left, the one §4.2 toggle right */
.library-bar {
margin: 0 0 var(--space-6);
}
/* the toggle's own padding would inset its text past the chip column's
right rag; drop it so SATISFIED sits flush with the chips below */
.library-bar .bucket-toggle {
padding: 0;
}
/* ---- manual intake ---------------------------------------------------- */
.intake .module-detail {
@@ -977,6 +1029,17 @@ body {
max-width: none;
}
/* deterministic first row: identity, views, then meta pushed right —
never re-wrapped by content or scrollbar changes */
.rail-nav {
order: 2;
}
.rail-meta {
order: 2;
margin-left: auto;
}
.deck {
padding: var(--space-6) var(--space-4) var(--space-8);
}