Files
arr/web/src/main.ts

2996 lines
90 KiB
TypeScript

import { type CheckStatus, type Probe, probeHealth } from "./health";
import {
fetchLibrary,
type LibrarySeries,
movieNeedsAttention,
seriesNeedsAttention,
waiverLabel,
} from "./library";
import {
type AttentionMovie,
type AttentionQueues,
allowEnglishAudio,
attemptsLabel,
attentionTotal,
fetchAttention,
type SeriesAttention,
} from "./queues";
import {
bucketOf,
formatAudio,
formatHdr,
formatResolution,
formatScore,
formatSeeders,
formatSize,
formatSource,
formatSweepAge,
grabRelease,
libraryFolder,
type MovieFile,
type MovieRelease,
movieFiles,
movieReleases,
movieSearchState,
queueSearch,
removeMovie,
ruleLabel,
sweepExpected,
totalSize,
type WaiveOutcome,
waiveAndGrab,
waiverOverride,
} from "./releases";
import { currentRoute, navigate, type Route } from "./router";
import {
addMovie,
addSeries,
allRoots,
fetchMovie,
type LibraryEpisodeHit,
type LibraryMovie,
type LibrarySeriesHit,
parseManualInput,
type Root,
type SearchResponse,
searchTitles,
type TmdbMovie,
type TmdbSeries,
} from "./search";
import {
type ApiEpisode,
type ApiSeason,
type ApiSeries,
type EpisodeFile,
episodeTarget,
fetchEpisode,
fetchSeasons,
fetchSeries,
fetchSeriesFiles,
fileAttributeTags,
formatAirDate,
isUnaired,
seasonCounts,
seasonTarget,
setEpisodeWanted,
setSeasonTracked,
type TvTarget,
waiveAndGrabTv,
} from "./series";
import { settingsMain } from "./settings";
import "./style.css";
const POLL_MS = 15_000;
type Tone = "ok" | "warn" | "fault" | "idle";
const TONE_BY_STATUS: Record<CheckStatus, Tone> = {
ok: "ok",
unconfigured: "warn",
unreachable: "fault",
};
interface ModuleRefs {
lamp: HTMLElement;
status: HTMLElement;
detail: HTMLElement;
defaultDetail: string;
}
function moduleRefs(name: string): ModuleRefs {
const root = document.querySelector(`[data-check="${name}"]`);
const lamp = root?.querySelector<HTMLElement>(".lamp");
const status = root?.querySelector<HTMLElement>('[data-role="status"]');
const detail = root?.querySelector<HTMLElement>('[data-role="detail"]');
if (!lamp || !status || !detail) {
throw new Error(`board is missing the ${name} module`);
}
return { lamp, status, detail, defaultDetail: detail.textContent ?? "" };
}
function setModule(refs: ModuleRefs, state: string, tone: Tone, word: string, detail?: string) {
refs.lamp.dataset.state = state;
refs.status.dataset.tone = tone;
refs.status.textContent = word;
refs.detail.textContent = detail ?? refs.defaultDetail;
}
function must<T extends Element>(selector: string): T {
const element = document.querySelector<T>(selector);
if (!element) {
throw new Error(`board markup is missing ${selector}`);
}
return element;
}
function main() {
const board = must<HTMLElement>(".board");
const masterLamp = must<HTMLElement>("#master-lamp");
const verdict = must<HTMLElement>("#master-verdict");
const version = must<HTMLElement>("#version");
const master = moduleRefs("master");
const checks = {
tmdb: moduleRefs("tmdb"),
prowlarr: moduleRefs("prowlarr"),
transmission: moduleRefs("transmission"),
} as const;
const traces = {
tmdb: document.querySelector<SVGElement>('[data-trace="tmdb"]'),
prowlarr: document.querySelector<SVGElement>('[data-trace="prowlarr"]'),
master: document.querySelector<SVGElement>('[data-trace="master"]'),
} as const;
function setTrace(name: keyof typeof traces, tone: Tone | null) {
const trace = traces[name];
if (!trace) {
return;
}
if (tone === null || tone === "idle") {
delete trace.dataset.tone;
} else {
trace.dataset.tone = tone;
}
}
// stagger order for the one power-up animation, upstream to downstream
const strikeOrder = [checks.tmdb, checks.prowlarr, master, checks.transmission];
strikeOrder.forEach((refs, index) => {
refs.lamp.style.setProperty("--strike", String(index));
});
masterLamp.style.setProperty("--strike", "0");
let powered = false;
function render(probe: Probe) {
if (!powered) {
powered = true;
board.classList.add("powered");
}
if (probe.kind === "unreachable") {
masterLamp.dataset.state = "unreachable";
verdict.dataset.tone = "fault";
verdict.textContent = `daemon unreachable — ${probe.detail}`;
version.textContent = "v —";
setModule(master, "unreachable", "fault", "down");
for (const refs of Object.values(checks)) {
setModule(refs, "off", "idle", "no signal");
}
for (const name of ["tmdb", "prowlarr", "master"] as const) {
setTrace(name, null);
}
return;
}
const { report } = probe;
const degraded = report.status === "degraded";
masterLamp.dataset.state = degraded ? "degraded" : "ok";
verdict.dataset.tone = degraded ? "warn" : "ok";
verdict.textContent = degraded ? "chain degraded" : "all signals nominal";
version.textContent = `v${report.version}`;
setModule(master, degraded ? "degraded" : "ok", degraded ? "warn" : "ok", report.status);
setTrace("master", degraded ? "warn" : "ok");
for (const name of ["tmdb", "prowlarr", "transmission"] as const) {
const check = report[name];
setModule(
checks[name],
check.status,
TONE_BY_STATUS[check.status],
check.status,
check.detail,
);
if (name !== "transmission") {
setTrace(name, TONE_BY_STATUS[check.status]);
}
}
}
let inFlight = false;
// assigned once the queues view exists; the poll keeps the badge honest
let refreshQueuesBadge: (() => void) | null = null;
async function probe() {
if (inFlight) {
return;
}
inFlight = true;
render(await probeHealth());
inFlight = false;
refreshQueuesBadge?.();
}
window.setInterval(() => {
void probe();
}, POLL_MS);
void probe();
// views hide each other on open; the array is shared and filled once
const views: HideableView[] = [];
const releases = releasesMain();
// the tv deck registers its Escape handler before the series view does,
// so one Esc always steps back from the innermost layer first
const tvDeck = tvReleasesMain();
const seriesDetail = seriesMain(board, tvDeck, views);
const library = libraryMain(board, releases, seriesDetail, views);
const queues = queuesMain(board, releases, views);
const settings = settingsMain(board, views);
views.push(tvDeck, seriesDetail, library, queues, settings);
const search = searchMain(board, releases, seriesDetail, views);
// a removed title must not survive on the surface the deck opened over
releases.setRemoved((parent) => {
switch (parent.kind) {
case "library":
library.open();
break;
case "queues":
queues.open();
break;
case "search":
search.restore(parent.query);
break;
default:
search.showIdle();
break;
}
});
refreshQueuesBadge = () => {
void queues.refreshBadge();
};
void queues.refreshBadge();
// popstate and the initial load both dispatch through here, so refresh
// and back/forward reach the same view a click would have.
async function applyRoute(route: Route) {
switch (route.kind) {
case "board":
search.showIdle();
break;
case "library":
library.open();
break;
case "queues":
queues.open();
break;
case "settings":
settings.open();
break;
case "search":
search.restore(route.query);
break;
case "releases": {
const movie = await fetchMovie(route.movieId);
if (!movie) {
navigate({ kind: "library" }, { replace: true });
library.open();
return;
}
// no origin click to restore focus to on a deep link — the library
// rail button is the closest stand-in
library.open();
releases.open(movie, must<HTMLElement>("#nav-library"), must<HTMLElement>("#library"), {
kind: "library",
});
break;
}
case "series": {
const detail = await fetchSeries(route.seriesId);
if (!detail) {
navigate({ kind: "library" }, { replace: true });
library.open();
return;
}
// the surface underneath must be live before the detail view covers
// it, so back and Esc land somewhere real (same as a movie deep link)
library.open();
await seriesDetail.open(
route.seriesId,
must<HTMLElement>("#nav-library"),
must<HTMLElement>("#library"),
{
kind: "library",
},
);
break;
}
case "seasonReleases":
case "episodeReleases": {
// both deck routes live over the series detail view, exactly as a
// click on a deck button does; resolve what that click would have
let title: string;
let sub: string;
let seriesId: number;
let target: TvTarget;
if (route.kind === "seasonReleases") {
seriesId = route.seriesId;
target = seasonTarget(route.seriesId, route.seasonNumber);
sub = `season ${PAD_TWO(route.seasonNumber)} · packs`;
title = (await fetchSeries(route.seriesId))?.title ?? "";
if (title === "") {
navigate({ kind: "library" }, { replace: true });
library.open();
return;
}
} else {
const episode = await fetchEpisode(route.episodeId);
if (!episode) {
navigate({ kind: "library" }, { replace: true });
library.open();
return;
}
seriesId = episode.series_id;
target = episodeTarget(episode.id);
sub = `S${PAD_TWO(episode.season_number)}E${PAD_TWO(episode.number)} · ${episode.title}`;
title = (await fetchSeries(seriesId))?.title ?? "";
if (title === "") {
navigate({ kind: "library" }, { replace: true });
library.open();
return;
}
}
library.open();
await seriesDetail.open(
seriesId,
must<HTMLElement>("#nav-library"),
must<HTMLElement>("#library"),
{ kind: "library" },
);
// no origin click to restore focus to on a deep link — the series
// view's back control is the closest stand-in
tvDeck.open({
title,
sub,
seriesId,
target,
origin: must<HTMLButtonElement>("#series-back"),
returnTo: must<HTMLElement>("#series"),
parentRoute: { kind: "series", seriesId },
});
break;
}
}
}
window.addEventListener("popstate", () => {
void applyRoute(currentRoute());
});
void applyRoute(currentRoute());
}
interface HideableView {
hide: () => void;
}
const DEBOUNCE_MS = 250;
interface DeckRefs {
deck: HTMLElement;
status: HTMLElement;
groups: {
library: { section: HTMLElement; count: HTMLElement; rows: HTMLUListElement };
tmdb: { section: HTMLElement; count: HTMLElement; rows: HTMLUListElement };
};
manualSection: HTMLElement;
manualIntake: HTMLElement;
}
interface SearchView {
/** Restores the board with the search box empty — the "/" route. */
showIdle: () => void;
/** Restores the deck for `query` — the "/search?q=" route. */
restore: (query: string) => void;
}
function searchMain(
board: HTMLElement,
releases: ReleasesView,
seriesDetail: SeriesView,
views: HideableView[],
): SearchView {
const input = must<HTMLInputElement>("#search");
const hint = must<HTMLElement>("#search-hint");
const refs: DeckRefs = {
deck: must<HTMLElement>("#deck"),
status: must<HTMLElement>("#deck-status"),
groups: {
library: {
section: must<HTMLElement>("#group-library"),
count: must<HTMLElement>("#count-library"),
rows: must<HTMLUListElement>("#rows-library"),
},
tmdb: {
section: must<HTMLElement>("#group-tmdb"),
count: must<HTMLElement>("#count-tmdb"),
rows: must<HTMLUListElement>("#rows-tmdb"),
},
},
manualSection: must<HTMLElement>("#group-manual"),
manualIntake: must<HTMLElement>("#manual-intake"),
};
// The audience chip on a library row needs id → root. allRoots() caches
// success, so a failed boot fetch is retried whenever an add panel opens.
let roots: Root[] = [];
const fetchRoots = () =>
allRoots().then(
(fetched) => {
roots = fetched;
return fetched;
},
() => roots,
);
void fetchRoots();
/** A series or episode hit opens the series detail view (issue 129). */
const openSeries = (id: number, origin: HTMLElement) => {
navigate({ kind: "series", seriesId: id });
void seriesDetail.open(id, origin, refs.deck, {
kind: "search",
query: input.value.trim(),
});
};
let timer: number | undefined;
let controller: AbortController | null = null;
function showBoard() {
controller?.abort();
controller = null;
releases.hide();
for (const view of views) {
view.hide();
}
refs.deck.hidden = true;
board.hidden = false;
}
function showDeck() {
releases.hide();
for (const view of views) {
view.hide();
}
board.hidden = true;
refs.deck.hidden = false;
}
function setStatus(text: string | null, tone?: "fault") {
refs.status.hidden = text === null;
refs.status.textContent = text ?? "";
if (tone) {
refs.status.dataset.tone = tone;
} else {
delete refs.status.dataset.tone;
}
}
function clearGroups() {
for (const group of [refs.groups.library, refs.groups.tmdb]) {
group.section.hidden = true;
group.rows.replaceChildren();
}
refs.manualSection.hidden = true;
refs.manualIntake.replaceChildren();
}
async function run(query: string) {
controller?.abort();
controller = new AbortController();
showDeck();
setStatus("searching…");
const outcome = await searchTitles(query, controller.signal);
if (outcome.kind === "aborted") {
return;
}
if (outcome.kind === "error") {
clearGroups();
setStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
render(outcome.response);
}
function render(response: SearchResponse) {
clearGroups();
if (
response.manual !== null &&
(response.kind === "magnet" || response.kind === "torrent_url")
) {
setStatus(null);
refs.manualSection.hidden = false;
renderManual(refs.manualIntake, response.kind, response.manual);
return;
}
// §9.2: in-library hits read as whatever they are — a movie, a series,
// or an episode with its series and SxxEyy for context
const libraryMovies = response.library.filter(
(hit): hit is LibraryMovie => hit.kind === "movie",
);
const librarySeries = response.library.filter(
(hit): hit is LibrarySeriesHit => hit.kind === "series",
);
const libraryEpisodes = response.library.filter(
(hit): hit is LibraryEpisodeHit => hit.kind === "episode",
);
const inLibrary = new Set([...libraryMovies, ...librarySeries].map((title) => title.tmdb_id));
if (response.library.length === 0 && response.tmdb.length === 0) {
setStatus("no matches in library or on tmdb");
return;
}
setStatus(null);
if (response.library.length > 0) {
refs.groups.library.section.hidden = false;
refs.groups.library.count.textContent = String(response.library.length);
for (const movie of libraryMovies) {
refs.groups.library.rows.append(
libraryRow(movie, roots, (target, origin) => {
navigate({ kind: "releases", movieId: target.id });
releases.open(target, origin, refs.deck, { kind: "search", query: input.value.trim() });
}),
);
}
for (const series of librarySeries) {
refs.groups.library.rows.append(librarySeriesRow(series, roots, openSeries));
}
for (const episode of libraryEpisodes) {
refs.groups.library.rows.append(episodeRow(episode, openSeries));
}
}
if (response.tmdb.length > 0) {
refs.groups.tmdb.section.hidden = false;
refs.groups.tmdb.count.textContent = String(response.tmdb.length);
for (const hit of response.tmdb) {
refs.groups.tmdb.rows.append(tmdbRow(hit, inLibrary.has(hit.tmdb_id), fetchRoots));
}
}
}
input.addEventListener("input", () => {
window.clearTimeout(timer);
const query = input.value.trim();
if (query === "") {
navigate({ kind: "board" });
showBoard();
clearGroups();
setStatus(null);
return;
}
timer = window.setTimeout(() => {
navigate({ kind: "search", query }, { replace: true });
void run(query);
}, DEBOUNCE_MS);
});
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
window.clearTimeout(timer);
const query = input.value.trim();
if (query !== "") {
navigate({ kind: "search", query }, { replace: true });
void run(query);
}
} else if (event.key === "ArrowDown") {
const first = refs.deck.querySelector<HTMLElement>(".row-tmdb:not(:disabled)");
if (first) {
event.preventDefault();
first.focus();
}
} else if (event.key === "Escape") {
input.value = "";
navigate({ kind: "board" });
showBoard();
clearGroups();
setStatus(null);
}
});
input.addEventListener("focus", () => {
hint.hidden = true;
});
input.addEventListener("blur", () => {
hint.hidden = false;
});
window.addEventListener("keydown", (event) => {
if (
event.key === "/" &&
document.activeElement !== input &&
!(document.activeElement instanceof HTMLInputElement) &&
!(document.activeElement instanceof HTMLTextAreaElement)
) {
event.preventDefault();
input.focus();
input.select();
} else if (event.key === "Escape" && !refs.deck.hidden) {
input.value = "";
navigate({ kind: "board" });
showBoard();
clearGroups();
setStatus(null);
input.focus();
}
});
refs.deck.addEventListener("keydown", (event) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
return;
}
const target = event.target;
if (!(target instanceof HTMLElement) || !target.classList.contains("row-tmdb")) {
return;
}
event.preventDefault();
const rows = [...refs.deck.querySelectorAll<HTMLElement>(".row-tmdb:not(:disabled)")];
const index = rows.indexOf(target);
if (event.key === "ArrowUp" && index === 0) {
input.focus();
return;
}
rows[index + (event.key === "ArrowDown" ? 1 : -1)]?.focus();
});
function showIdle() {
input.value = "";
showBoard();
clearGroups();
setStatus(null);
}
function restore(query: string) {
input.value = query;
void run(query);
}
return { showIdle, restore };
}
function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanElement {
const span = document.createElement("span");
span.className = "chip readout";
span.textContent = text;
extra?.(span);
return span;
}
function rowTitle(title: string, year: number | null): HTMLElement {
const wrap = document.createElement("span");
wrap.className = "row-id";
const name = document.createElement("span");
name.className = "row-title";
name.textContent = title;
const when = document.createElement("span");
when.className = "row-year readout dim";
when.textContent = year === null ? "—" : String(year);
wrap.append(name, when);
return wrap;
}
function seasonTag(seasonNumber: number): string {
return `S${two(seasonNumber)}`;
}
function episodeTag(seasonNumber: number, episodeNumber: number): string {
return `${seasonTag(seasonNumber)}E${two(episodeNumber)}`;
}
function two(value: number): string {
return value < 10 ? `0${value}` : String(value);
}
function libraryRow(
movie: LibraryMovie,
roots: Root[],
open: (movie: LibraryMovie, origin: HTMLElement) => void,
): HTMLLIElement {
const item = document.createElement("li");
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb";
const chips = document.createElement("span");
chips.className = "row-chips";
const root = roots.find((candidate) => candidate.id === movie.root_id);
chips.append(chip(root ? root.audience : `root ${movie.root_id}`));
chips.append(
chip(movie.state, (span) => {
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"));
}
if (movie.blocked) {
chips.append(chip("blocked"));
}
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "releases";
chips.append(affordance);
row.append(rowTitle(movie.title, movie.year), chips);
row.addEventListener("click", () => {
open(movie, row);
});
item.append(row);
return item;
}
/**
* An in-library series hit: the whole row opens the series detail view,
* which is where its seasons, episodes and decks live (issue 129).
*/
function librarySeriesRow(
series: LibrarySeriesHit,
roots: Root[],
open: (id: number, origin: HTMLElement) => void,
): HTMLLIElement {
const item = document.createElement("li");
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb";
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.blocked) {
chips.append(chip("blocked"));
}
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "episodes";
chips.append(affordance);
row.append(rowTitle(series.title, series.year), chips);
row.addEventListener("click", () => {
open(series.id, row);
});
item.append(row);
return item;
}
/** An in-library episode hit: its series, the `SxxEyy` tag, then the title. */
function episodeRow(
episode: LibraryEpisodeHit,
open: (id: number, origin: HTMLElement) => void,
): HTMLLIElement {
const item = document.createElement("li");
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb";
const chips = document.createElement("span");
chips.className = "row-chips";
chips.append(
chip(episode.tag, (span) => {
span.setAttribute("aria-label", `${episode.series_title} ${episode.tag}, ${episode.title}`);
}),
);
// the episode title is a name, not a readout: sans, never mono
const title = document.createElement("span");
title.className = "row-sub";
title.textContent = episode.title;
chips.append(title);
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "episodes";
chips.append(affordance);
row.append(rowTitle(episode.series_title, null), chips);
row.addEventListener("click", () => {
open(episode.series_id, row);
});
item.append(row);
return item;
}
/** A TMDB hit of either kind — the row opens the add flow (§9.2). */
function tmdbRow(
hit: TmdbMovie | TmdbSeries,
inLibrary: boolean,
fetchRoots: () => Promise<Root[]>,
): HTMLLIElement {
const item = document.createElement("li");
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb";
const chips = document.createElement("span");
chips.className = "row-chips";
chips.append(chip(hit.original_language));
if (inLibrary) {
chips.append(
chip("in library", (span) => {
span.dataset.verdict = "eligible";
}),
);
} else {
const add = document.createElement("span");
add.className = "row-add readout";
add.textContent = "add";
chips.append(add);
}
row.append(rowTitle(hit.title, hit.year), chips);
if (hit.overview) {
const overview = document.createElement("span");
overview.className = "row-overview";
overview.textContent = hit.overview;
row.append(overview);
}
item.append(row);
if (inLibrary) {
row.disabled = true;
return item;
}
row.setAttribute("aria-expanded", "false");
row.addEventListener("click", () => {
const open = item.querySelector(".add-panel");
if (open) {
open.remove();
row.setAttribute("aria-expanded", "false");
row.focus();
return;
}
void fetchRoots().then((available) => {
if (item.querySelector(".add-panel")) {
return;
}
const panel = addPanel(hit, available, row);
item.append(panel);
row.setAttribute("aria-expanded", "true");
panel.querySelector<HTMLElement>(".control")?.focus();
});
});
return item;
}
/**
* The §9.2 add flow: inline, root and policy pre-filled, one confirm. A
* series asks the one series-level question at add time — `auto_track`
* (§4.1) — and states what it does in one plain line.
*/
function addPanel(
title: TmdbMovie | TmdbSeries,
available: Root[],
row: HTMLButtonElement,
): HTMLElement {
const isSeries = title.kind === "series";
const kind = isSeries ? "tv" : "movie";
const roots = available.filter((root) => root.kind === kind);
const panel = document.createElement("div");
panel.className = "add-panel";
let selected = roots.find((root) => root.audience === "main") ?? roots[0] ?? null;
const options = document.createElement("div");
options.className = "add-roots";
options.setAttribute("role", "group");
options.setAttribute("aria-label", "root folder");
const optionButtons: HTMLButtonElement[] = [];
for (const root of roots) {
const option = document.createElement("button");
option.type = "button";
option.className = "root-option";
option.setAttribute("aria-pressed", String(root === selected));
const audience = document.createElement("span");
audience.className = "root-audience";
audience.textContent = root.audience;
const policy = document.createElement("span");
policy.className = "root-policy readout dim";
policy.textContent = root.policy_name;
const path = document.createElement("span");
path.className = "root-path readout dim";
path.textContent = root.path;
option.append(audience, policy, path);
option.addEventListener("click", () => {
selected = root;
for (const other of optionButtons) {
other.setAttribute("aria-pressed", String(other === option));
}
});
optionButtons.push(option);
options.append(option);
}
// §4.1: auto_track is a rule about seasons metadata reveals, not intent.
// Future seasons are picked up by themselves; past seasons never are.
let autoTrack = true;
let autotrack: HTMLElement | null = null;
if (isSeries) {
const track = document.createElement("button");
track.type = "button";
track.className = "control control-quiet";
track.setAttribute("aria-pressed", "true");
const trackState = () => {
track.textContent = `auto-track future seasons: ${autoTrack ? "on" : "off"}`;
};
trackState();
track.addEventListener("click", () => {
autoTrack = !autoTrack;
track.setAttribute("aria-pressed", String(autoTrack));
trackState();
});
const trackNote = document.createElement("p");
trackNote.className = "add-note readout dim";
trackNote.textContent = "future seasons are tracked automatically; past seasons are not";
autotrack = document.createElement("div");
autotrack.className = "add-autotrack";
autotrack.append(track, trackNote);
}
const confirm = document.createElement("button");
confirm.type = "button";
confirm.className = "control";
confirm.textContent = "add to library";
const note = document.createElement("p");
note.className = "add-note readout";
note.setAttribute("role", "status");
note.hidden = true;
if (roots.length === 0) {
confirm.disabled = true;
note.hidden = false;
// a failed fetch and an absent kind are different problems
note.textContent =
available.length === 0
? "roots unavailable — daemon down or database missing"
: `no ${isSeries ? "tv" : "movie"} roots configured — create one under settings`;
note.dataset.tone = "fault";
}
confirm.addEventListener("click", () => {
const root = selected;
if (!root) {
return;
}
confirm.disabled = true;
note.hidden = false;
delete note.dataset.tone;
note.textContent = "adding…";
const adding =
title.kind === "movie" ? addMovie(title, root.id) : addSeries(title, root.id, autoTrack);
void adding.then((outcome) => {
if (outcome.kind === "error") {
confirm.disabled = false;
note.textContent = `add failed — ${outcome.detail}`;
note.dataset.tone = "fault";
return;
}
const already = outcome.kind === "conflict";
const done = document.createElement("p");
done.className = "add-done readout";
done.setAttribute("role", "status");
done.tabIndex = -1;
done.textContent = already
? "already in library"
: title.kind === "movie"
? `added to ${root.audience} — wanted, search follows`
: `added to ${root.audience} — future seasons ${autoTrack ? "" : "not "}tracked automatically`;
panel.replaceChildren(done);
done.focus();
row.disabled = true;
row.setAttribute("aria-expanded", "true");
row.querySelector(".row-add")?.replaceWith(
chip("in library", (span) => {
span.dataset.verdict = "eligible";
}),
);
});
});
const actions = document.createElement("div");
actions.className = "add-actions";
actions.append(confirm, note);
if (autotrack) {
panel.append(options, autotrack, actions);
} else {
panel.append(options, actions);
}
return panel;
}
/* ---- release deck (§9.3, issue #31) ---------------------------------- */
interface ReleasesView {
/** Opens over `returnTo`, which back and Esc restore; `parentRoute` is where they navigate. */
open: (
movie: LibraryMovie,
origin: HTMLElement,
returnTo: HTMLElement,
parentRoute: Route,
) => void;
hide: () => void;
/**
* Called with the deck's parent route once a title is removed, so the
* surface underneath repaints instead of listing something that is gone.
*/
setRemoved: (handler: (parent: Route) => void) => void;
}
/** The eligible bucket always shows; waived and rejected collapse to counts. */
interface CollapsedBucketDom {
section: HTMLElement;
toggle: HTMLButtonElement;
wrap: HTMLElement;
rows: HTMLUListElement;
}
/**
* The §9.3 three-bucket structure. The movie deck builds it from static
* markup in index.html; the tv deck builds it with [`buildBucketDom`] —
* both paint through [`paintBuckets`].
*/
interface BucketsDom {
eligible: { section: HTMLElement; count: HTMLElement; rows: HTMLUListElement };
waived: CollapsedBucketDom;
rejected: CollapsedBucketDom;
}
function clearBuckets(dom: BucketsDom) {
dom.eligible.section.hidden = true;
dom.eligible.rows.replaceChildren();
for (const bucket of [dom.waived, dom.rejected]) {
bucket.section.hidden = true;
bucket.wrap.hidden = true;
bucket.toggle.setAttribute("aria-expanded", "false");
bucket.rows.replaceChildren();
}
}
function setToggle(bucket: CollapsedBucketDom, count: number) {
const open = !bucket.wrap.hidden;
bucket.toggle.textContent = open ? `hide ${count}` : `show ${count}`;
bucket.toggle.setAttribute("aria-expanded", String(open));
}
function wireCollapsedToggle(bucket: CollapsedBucketDom) {
bucket.toggle.addEventListener("click", () => {
bucket.wrap.hidden = !bucket.wrap.hidden;
setToggle(bucket, bucket.rows.childElementCount);
});
}
function releasesMain(): ReleasesView {
const view = must<HTMLElement>("#releases");
const deck = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#releases-back");
const title = must<HTMLElement>("#releases-title");
const year = must<HTMLElement>("#releases-year");
const sweep = must<HTMLButtonElement>("#releases-sweep");
const remove = must<HTMLButtonElement>("#releases-remove");
const removeWrap = must<HTMLElement>("#remove-panel");
const status = must<HTMLElement>("#releases-status");
const eligible: BucketsDom["eligible"] = {
section: must<HTMLElement>("#bucket-eligible"),
count: must<HTMLElement>("#count-eligible"),
rows: must<HTMLUListElement>("#rows-eligible"),
};
const collapsed = {
waived: {
section: must<HTMLElement>("#bucket-waived"),
toggle: must<HTMLButtonElement>("#toggle-waived"),
wrap: must<HTMLElement>("#wrap-waived"),
rows: must<HTMLUListElement>("#rows-waived"),
},
rejected: {
section: must<HTMLElement>("#bucket-rejected"),
toggle: must<HTMLButtonElement>("#toggle-rejected"),
wrap: must<HTMLElement>("#wrap-rejected"),
rows: must<HTMLUListElement>("#rows-rejected"),
},
} as const;
let current: LibraryMovie | null = null;
let removed: ((parent: Route) => void) | null = null;
let origin: HTMLElement | null = null;
let returnTo: HTMLElement = deck;
let parentRoute: Route = { kind: "board" };
const dom: BucketsDom = { eligible, waived: collapsed.waived, rejected: collapsed.rejected };
const actions: ReleaseActions = {
reload: () => load(),
notify: (text, tone) => setStatus(text, tone),
grab: async (release, bucket) => {
const movie = current;
if (!movie) {
return { kind: "error", detail: "deck closed", overrideWritten: false };
}
if (bucket === "waived") {
return waiveAndGrab(movie.id, release.id, release.rejected_rule);
}
const outcome = await grabRelease(movie.id, release.id);
return outcome.kind === "done"
? { kind: "done", overrideWritten: false }
: { kind: "error", detail: outcome.detail, overrideWritten: false };
},
};
// guards a stale fetch from painting over a newer view
let sequence = 0;
let pollTimer: number | undefined;
// §6.2: the sweep runs on the daemon's own cadence. Poll modestly, and
// stop promising once the backoff window is plausibly in charge.
const SWEEP_POLL_MS = 5000;
const SWEEP_WAIT_MS = 150_000;
function setStatus(text: string | null, tone?: "fault", busy = false) {
status.hidden = text === null;
if (busy && text !== null) {
const lamp = document.createElement("span");
lamp.className = "lamp";
lamp.dataset.state = "probing";
status.replaceChildren(lamp, document.createTextNode(text));
} else {
status.textContent = text ?? "";
}
if (tone) {
status.dataset.tone = tone;
} else {
delete status.dataset.tone;
}
}
function render(releases: MovieRelease[]) {
const movie = current;
if (!movie) {
return;
}
if (!paintBuckets(dom, releases, actions)) {
return;
}
setStatus(null);
}
async function load() {
const movie = current;
if (!movie) {
return;
}
sequence += 1;
const ticket = sequence;
window.clearTimeout(pollTimer);
setStatus("reading releases…");
const outcome = await movieReleases(movie.id);
if (ticket !== sequence || current !== movie) {
return;
}
if (outcome.kind === "error") {
clearBuckets(dom);
setStatus(`releases unavailable — ${outcome.detail}`, "fault");
return;
}
if (outcome.releases.length === 0) {
clearBuckets(dom);
await emptyVerdict(movie, ticket);
return;
}
render(outcome.releases);
}
/**
* The empty deck is two different truths (§9.3, issue 101): a sweep that
* has not landed yet, or a sweep that landed and found nothing. Only the
* movie's `last_searched_at` can tell them apart.
*/
async function emptyVerdict(movie: LibraryMovie, ticket: number) {
const outcome = await movieSearchState(movie.id);
if (ticket !== sequence || current !== movie) {
return;
}
if (outcome.kind === "state" && outcome.movie.last_searched_at !== null) {
setStatus(
`no releases found — sweep finished ${formatSweepAge(outcome.movie.last_searched_at)}; search indexers runs a new one`,
);
return;
}
if (outcome.kind === "state" && sweepExpected(outcome.movie)) {
// first sweep still in flight — visibly different from "nothing found"
watchSweep(movie, null, ticket);
return;
}
setStatus("no releases indexed for this title — search indexers runs a sweep now");
}
/** Poll the movie until `last_searched_at` moves off `baseline`. */
function watchSweep(movie: LibraryMovie, baseline: string | null, ticket: number) {
sweep.disabled = true;
setStatus("sweeping indexers…", undefined, true);
const deadline = Date.now() + SWEEP_WAIT_MS;
const tick = async () => {
if (ticket !== sequence || current !== movie) {
return;
}
const outcome = await movieSearchState(movie.id);
if (ticket !== sequence || current !== movie) {
return;
}
if (outcome.kind === "state" && outcome.movie.last_searched_at !== baseline) {
sweep.disabled = false;
void load();
return;
}
if (Date.now() >= deadline) {
sweep.disabled = false;
setStatus(
"sweep has not landed yet — it may be waiting out its backoff; results appear here once it runs",
);
return;
}
pollTimer = window.setTimeout(() => {
void tick();
}, SWEEP_POLL_MS);
};
pollTimer = window.setTimeout(() => {
void tick();
}, SWEEP_POLL_MS);
}
/** Tears the confirmation down without stealing focus from a caller. */
function clearRemove() {
removeWrap.hidden = true;
removeWrap.replaceChildren();
remove.setAttribute("aria-expanded", "false");
}
function closeRemove() {
clearRemove();
remove.focus();
}
function openRemove() {
const movie = current;
if (!movie) {
return;
}
const panel = removePanel(movie, {
cancel: closeRemove,
removed: () => {
const parent = parentRoute;
clearRemove();
close();
removed?.(parent);
},
});
removeWrap.replaceChildren(panel);
removeWrap.hidden = false;
remove.setAttribute("aria-expanded", "true");
// cancel takes focus, not the destructive action: no toggle is left to arm first
panel.querySelector<HTMLElement>(".remove-actions .control:last-child")?.focus();
}
remove.addEventListener("click", () => {
if (removeWrap.hidden) {
openRemove();
} else {
closeRemove();
}
});
function open(movie: LibraryMovie, from: HTMLElement, container: HTMLElement, parent: Route) {
current = movie;
origin = from;
returnTo = container;
parentRoute = parent;
title.textContent = movie.title;
year.textContent = movie.year === null ? "—" : String(movie.year);
container.hidden = true;
view.hidden = false;
clearBuckets(dom);
clearRemove();
sweep.disabled = false;
back.focus();
void load();
}
function hide() {
view.hidden = true;
current = null;
clearRemove();
sequence += 1;
window.clearTimeout(pollTimer);
}
function close() {
const target = origin;
navigate(parentRoute);
hide();
returnTo.hidden = false;
target?.focus();
}
back.addEventListener("click", close);
// 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) {
return;
}
event.stopImmediatePropagation();
// the confirmation is the innermost layer: Esc abandons it, not the deck
if (removeWrap.hidden) {
close();
} else {
closeRemove();
}
},
true,
);
sweep.addEventListener("click", () => {
const movie = current;
if (!movie) {
return;
}
sweep.disabled = true;
void (async () => {
// snapshot the last sweep first: its change is the completion signal
const before = await movieSearchState(movie.id);
if (current !== movie) {
return;
}
const baseline = before.kind === "state" ? before.movie.last_searched_at : null;
const outcome = await queueSearch(movie.id);
if (current !== movie) {
return;
}
if (outcome.kind === "error") {
sweep.disabled = false;
setStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
sequence += 1;
window.clearTimeout(pollTimer);
watchSweep(movie, baseline, sequence);
})();
});
return {
open,
hide,
setRemoved: (handler: (parent: Route) => void) => {
removed = handler;
},
};
}
interface RemoveActions {
cancel: () => void;
removed: () => void;
}
/**
* The removal confirmation (issue 104, simplified by 110): removing a title
* always unlinks its §7.4 folder, so there is one decision, not two.
*
* It names the §7.4 folder it would unlink rather than promising in the
* abstract — the service knows only what it wrote (§2), so the file list is
* the whole truth about what disappears. And it says what does not happen:
* the torrent keeps seeding under its own rule (§7.3).
*/
function removePanel(movie: LibraryMovie, actions: RemoveActions): HTMLElement {
const panel = document.createElement("div");
panel.className = "remove-body";
panel.setAttribute("role", "group");
panel.setAttribute("aria-label", `remove ${movie.title}`);
let files: MovieFile[] | null = null;
const evidence = document.createElement("p");
evidence.className = "remove-evidence readout dim";
evidence.textContent = "reading files…";
const path = document.createElement("p");
path.className = "remove-path readout";
path.hidden = true;
const note = document.createElement("p");
note.className = "remove-note readout";
note.id = "remove-note";
note.setAttribute("role", "status");
const confirm = document.createElement("button");
confirm.type = "button";
confirm.className = "control";
confirm.textContent = "remove and delete files";
confirm.setAttribute("aria-describedby", note.id);
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "control";
cancel.textContent = "keep";
cancel.addEventListener("click", actions.cancel);
function paint() {
const hasFiles = files !== null && files.length > 0;
panel.dataset.armed = String(hasFiles);
if (hasFiles) {
note.textContent =
"deletes the folder above. the torrent keeps seeding until its tracker rule clears.";
note.dataset.tone = "warn";
return;
}
delete note.dataset.tone;
note.textContent = "the library entry goes. nothing was imported for this title.";
}
confirm.addEventListener("click", () => {
confirm.disabled = true;
cancel.disabled = true;
delete note.dataset.tone;
note.textContent = "removing title and files…";
void removeMovie(movie.id).then((outcome) => {
if (outcome.kind === "done") {
actions.removed();
return;
}
confirm.disabled = false;
cancel.disabled = false;
// the row is still there on a failed unlink, so retrying is the fix
note.textContent = `remove failed — ${outcome.detail}`;
note.dataset.tone = "fault";
});
});
void movieFiles(movie.id).then((outcome) => {
if (outcome.kind === "error") {
evidence.textContent = `files unreadable — ${outcome.detail}`;
paint();
return;
}
files = outcome.files;
if (files.length === 0) {
evidence.textContent = "nothing on disk";
paint();
return;
}
const count = `${files.length} ${files.length === 1 ? "file" : "files"}`;
evidence.textContent = `${count} · ${formatSize(totalSize(files))}`;
const folder = libraryFolder(files);
path.hidden = false;
path.textContent = folder ?? files.map((file) => file.path).join("\n");
paint();
});
const controls = document.createElement("div");
controls.className = "remove-actions";
controls.append(confirm, cancel);
paint();
// evidence, then what removing it costs
panel.append(evidence, path, note, controls);
return panel;
}
interface ReleaseActions {
reload: () => Promise<void>;
notify: (text: string, tone?: "fault") => void;
/** One click on grab — a plain grab, or waive-then-grab on a waived row. */
grab: (
release: MovieRelease,
bucket: "eligible" | "waived" | "rejected",
) => Promise<WaiveOutcome>;
}
/** Stops in the score heat scale — keep in step with the --score-* tokens. */
const SCORE_STOPS = 6;
type ScoreStopper = (score: number | null) => number | null;
/**
* Score colour is relative to one list (issue 111): highest green, lowest
* red, seven interpolated stops between.
*/
function scoreStopper(releases: MovieRelease[]): ScoreStopper {
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const release of releases) {
if (release.score !== null) {
if (release.score < min) {
min = release.score;
}
if (release.score > max) {
max = release.score;
}
}
}
return (score: number | null): number | null => {
if (score === null || min > max) {
return null;
}
// all-equal scores are jointly the best the list offers
return max === min ? SCORE_STOPS : Math.round(((score - min) / (max - min)) * SCORE_STOPS);
};
}
/**
* Splits classified releases into the three buckets and paints them. The
* API pre-sorts by bucket then score; splitting preserves order. Returns
* false when there was nothing to paint — the caller owns the empty state,
* which differs per deck.
*/
function paintBuckets(dom: BucketsDom, releases: MovieRelease[], actions: ReleaseActions): boolean {
clearBuckets(dom);
if (releases.length === 0) {
return false;
}
const scoreStop = scoreStopper(releases);
const buckets = {
eligible: releases.filter((release) => bucketOf(release) === "eligible"),
waived: releases.filter((release) => bucketOf(release) === "waived"),
rejected: releases.filter((release) => bucketOf(release) === "rejected"),
};
dom.eligible.section.hidden = false;
dom.eligible.count.textContent = String(buckets.eligible.length);
if (buckets.eligible.length > 0) {
for (const release of buckets.eligible) {
dom.eligible.rows.append(releaseRow(release, "eligible", scoreStop, actions));
}
} else {
// §9.3: over-strict filters must be visible, not silently absent
const none = document.createElement("li");
none.className = "rel rel-none readout dim";
none.textContent = "none — every candidate was waived or rejected by policy";
dom.eligible.rows.append(none);
}
for (const name of ["waived", "rejected"] as const) {
const rows = buckets[name];
if (rows.length === 0) {
continue;
}
const bucket = dom[name];
bucket.section.hidden = false;
for (const release of rows) {
bucket.rows.append(releaseRow(release, name, scoreStop, actions));
}
setToggle(bucket, rows.length);
}
return true;
}
/** Builds the §9.3 bucket structure for a deck whose markup is dynamic. */
function buildBucketDom(root: HTMLElement): BucketsDom {
function colhead(): HTMLElement {
const head = document.createElement("div");
head.className = "colhead";
head.setAttribute("aria-hidden", "true");
for (const [width, label] of [
["cw-score", "score"],
["cw-res", "res"],
["cw-src", "source"],
["cw-hdr", "hdr"],
["cw-aud", "audio"],
["cw-size", "size"],
["cw-seed", "seed"],
] as const) {
const cell = document.createElement("span");
cell.className = `cw ${width}`;
cell.textContent = label;
head.append(cell);
}
return head;
}
function rows(): HTMLUListElement {
const list = document.createElement("ul");
list.className = "deck-rows";
return list;
}
const eligibleSection = document.createElement("section");
eligibleSection.className = "deck-group";
eligibleSection.hidden = true;
const eligibleHead = document.createElement("header");
eligibleHead.className = "deck-head";
const eligibleName = document.createElement("h3");
eligibleName.className = "deck-label";
eligibleName.textContent = "eligible";
const eligibleCount = document.createElement("span");
eligibleCount.className = "deck-count readout";
eligibleHead.append(eligibleName, eligibleCount);
const eligibleRows = rows();
eligibleSection.append(eligibleHead, colhead(), eligibleRows);
function collapsed(label: string): CollapsedBucketDom {
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "bucket-toggle readout";
toggle.setAttribute("aria-expanded", "false");
const sectionEl = document.createElement("section");
sectionEl.className = "deck-group";
sectionEl.hidden = true;
const head = document.createElement("header");
head.className = "deck-head bucket-head";
const name = document.createElement("h3");
name.className = "deck-label";
name.textContent = label;
head.append(name, toggle);
const wrap = document.createElement("div");
wrap.hidden = true;
const list = rows();
wrap.append(colhead(), list);
sectionEl.append(head, wrap);
const bucket: CollapsedBucketDom = { section: sectionEl, toggle, wrap, rows: list };
wireCollapsedToggle(bucket);
return bucket;
}
const dom: BucketsDom = {
eligible: { section: eligibleSection, count: eligibleCount, rows: eligibleRows },
waived: collapsed("waived"),
rejected: collapsed("rejected"),
};
root.append(dom.eligible.section, dom.waived.section, dom.rejected.section);
return dom;
}
/**
* One classified release: chips lead, the name is secondary (§9.3). The
* score chip is coloured on the list-relative heat scale (issue 111);
* nothing on the line is clickable — grab is its own control.
*/
function releaseRow(
release: MovieRelease,
bucket: "eligible" | "waived" | "rejected",
scoreStop: ScoreStopper,
actions: ReleaseActions,
): HTMLLIElement {
const item = document.createElement("li");
item.className = "rel";
const line = document.createElement("div");
line.className = "rel-line";
const stop = scoreStop(release.score);
const columns: [string, string, string][] = [
["cw-score", "score", formatScore(release.score)],
["cw-res", "resolution", formatResolution(release.parsed)],
["cw-src", "source", formatSource(release.parsed)],
["cw-hdr", "hdr", formatHdr(release.parsed)],
["cw-aud", "audio", formatAudio(release.parsed)],
["cw-size", "size", formatSize(release.size)],
["cw-seed", "seeders", formatSeeders(release.seeders)],
];
for (const [width, label, value] of columns) {
line.append(
chip(value, (span) => {
span.classList.add("cw", width);
span.setAttribute("aria-label", value === "—" ? `${label} unclaimed` : `${label} ${value}`);
if (value === "—") {
span.classList.add("dim");
}
// the heat scale replaces the flat eligible green on this cell
if (width === "cw-score" && stop !== null) {
span.dataset.score = String(stop);
}
}),
);
}
if (bucket !== "eligible") {
line.append(
chip(`${bucket} · ${ruleLabel(release.rejected_rule)}`, (span) => {
span.dataset.verdict = bucket;
}),
);
}
const name = document.createElement("span");
name.className = "rel-name readout";
name.textContent = release.name;
line.append(name);
item.append(line);
const note = document.createElement("span");
note.className = "rel-note readout";
note.setAttribute("role", "status");
note.hidden = true;
if (bucket !== "rejected") {
const grab = document.createElement("button");
grab.type = "button";
grab.className = "control rel-grab";
const writesOverride = bucket === "waived" && waiverOverride(release.rejected_rule) !== null;
grab.textContent = writesOverride ? "waive + grab" : "grab";
grab.addEventListener("click", () => {
grab.disabled = true;
note.hidden = false;
delete note.dataset.tone;
note.textContent = "grabbing…";
void actions.grab(release, bucket).then((outcome) => {
const text =
outcome.kind === "error"
? `${outcome.overrideWritten ? "override written · " : ""}grab failed — ${outcome.detail}`
: `${outcome.overrideWritten ? "override written · " : ""}grab sent`;
if (outcome.kind === "error") {
grab.disabled = false;
note.textContent = text;
note.dataset.tone = "fault";
} else {
note.textContent = text;
}
if (outcome.overrideWritten) {
// the override changed classification; reread the verdicts and
// keep the outcome visible past the re-render
void actions.reload().then(() => {
actions.notify(text, outcome.kind === "error" ? "fault" : undefined);
});
}
});
});
item.append(grab);
}
item.append(note);
return item;
}
/* ---- library view (§4.2, issue #32) ----------------------------------- */
interface LibraryView {
hide: () => void;
open: () => void;
}
interface LibraryGroup {
section: HTMLElement;
count: HTMLElement;
rows: HTMLUListElement;
}
function libraryMain(
board: HTMLElement,
releases: ReleasesView,
seriesDetail: SeriesView,
views: HideableView[],
): 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, (id, origin) => {
navigate({ kind: "series", seriesId: id });
void seriesDetail.open(id, origin, view, { kind: "library" });
}),
);
renderGroup(groups.movies, moviesShown, (movie) =>
libraryRow(movie, roots, (target, origin) => {
navigate({ kind: "releases", movieId: target.id });
releases.open(target, origin, view, { kind: "library" });
}),
);
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();
for (const sibling of views) {
sibling.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) {
navigate({ kind: "library" });
open();
} else {
navigate({ kind: "board" });
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();
navigate({ kind: "board" });
close();
}
},
true,
);
return { hide, open };
}
/**
* One series with its §4.2 derived status: displayed, never editable. The
* row opens the detail view — seasons, episodes and their decks (issue 129).
*/
function seriesRow(
series: LibrarySeries,
roots: Root[],
open: (id: number, origin: HTMLElement) => void,
): HTMLLIElement {
const item = document.createElement("li");
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb";
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;
}),
);
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "episodes";
chips.append(affordance);
row.append(rowTitle(series.title, series.year), chips);
row.addEventListener("click", () => {
open(series.id, row);
});
item.append(row);
return item;
}
/* ---- tv release deck (§9.3 for season packs and single episodes) ------- */
interface TvDeckRequest {
title: string;
sub: string | null;
seriesId: number;
target: TvTarget;
origin: HTMLElement;
returnTo: HTMLElement;
parentRoute: Route;
}
interface TvReleasesView {
open: (request: TvDeckRequest) => void;
hide: () => void;
}
/** §6.2: the sweep runs on the daemon's cadence. Poll modestly, give up softly. */
const TV_SWEEP_POLL_MS = 5000;
const TV_SWEEP_WAIT_MS = 150_000;
function tvReleasesMain(): TvReleasesView {
const view = must<HTMLElement>("#tv-releases");
const back = must<HTMLButtonElement>("#tv-releases-back");
const title = must<HTMLElement>("#tv-releases-title");
const sub = must<HTMLElement>("#tv-releases-sub");
const sweep = must<HTMLButtonElement>("#tv-releases-sweep");
const statusEl = must<HTMLElement>("#tv-releases-status");
const dom = buildBucketDom(must<HTMLElement>("#tv-buckets"));
let request: TvDeckRequest | null = null;
// guards a stale fetch from painting over a newer view
let sequence = 0;
let pollTimer: number | undefined;
function setStatus(text: string | null, tone?: "fault", busy = false) {
statusEl.hidden = text === null;
if (busy && text !== null) {
const lamp = document.createElement("span");
lamp.className = "lamp";
lamp.dataset.state = "probing";
statusEl.replaceChildren(lamp, document.createTextNode(text));
} else {
statusEl.textContent = text ?? "";
}
if (tone) {
statusEl.dataset.tone = tone;
} else {
delete statusEl.dataset.tone;
}
}
const actions: ReleaseActions = {
reload: () => load(),
notify: (text, tone) => setStatus(text, tone),
grab: (release, bucket) => {
const current = request;
if (!current) {
return Promise.resolve({ kind: "error", detail: "deck closed", overrideWritten: false });
}
if (bucket === "waived") {
return waiveAndGrabTv(current.seriesId, current.target, release.id, release.rejected_rule);
}
return current.target
.grab(release.id)
.then((outcome) =>
outcome.kind === "done"
? { kind: "done" as const, overrideWritten: false }
: { kind: "error" as const, detail: outcome.detail, overrideWritten: false },
);
},
};
async function load() {
const current = request;
if (!current) {
return;
}
sequence += 1;
const ticket = sequence;
window.clearTimeout(pollTimer);
setStatus("reading releases…");
const outcome = await current.target.releases();
if (ticket !== sequence || request !== current) {
return;
}
if (outcome.kind === "error") {
clearBuckets(dom);
setStatus(`releases unavailable — ${outcome.detail}`, "fault");
return;
}
if (!paintBuckets(dom, outcome.releases, actions)) {
setStatus("no releases indexed yet — search indexers queues a targeted sweep");
return;
}
setStatus(null);
}
/**
* A queued sweep is done when its releases appear in the table. Nothing
* found is indistinguishable from still running, so the wait says so
* honestly instead of promising either way.
*/
function watchSweep(current: TvDeckRequest, ticket: number) {
sweep.disabled = true;
setStatus("sweeping indexers…", undefined, true);
const deadline = Date.now() + TV_SWEEP_WAIT_MS;
const tick = async () => {
if (ticket !== sequence || request !== current) {
return;
}
const outcome = await current.target.releases();
if (ticket !== sequence || request !== current) {
return;
}
if (outcome.kind === "results" && outcome.releases.length > 0) {
sweep.disabled = false;
paintBuckets(dom, outcome.releases, actions);
setStatus(null);
return;
}
if (Date.now() >= deadline) {
sweep.disabled = false;
setStatus(
"sweep has not landed yet — it may be waiting out its backoff; results appear here once it runs",
);
return;
}
pollTimer = window.setTimeout(() => {
void tick();
}, TV_SWEEP_POLL_MS);
};
pollTimer = window.setTimeout(() => {
void tick();
}, TV_SWEEP_POLL_MS);
}
sweep.addEventListener("click", () => {
const current = request;
if (!current) {
return;
}
sweep.disabled = true;
void (async () => {
const outcome = await current.target.search();
if (request !== current) {
return;
}
if (outcome.kind === "error") {
sweep.disabled = false;
setStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
sequence += 1;
window.clearTimeout(pollTimer);
watchSweep(current, sequence);
})();
});
function open(next: TvDeckRequest) {
request = next;
title.textContent = next.title;
sub.textContent = next.sub ?? "";
next.returnTo.hidden = true;
view.hidden = false;
clearBuckets(dom);
sweep.disabled = false;
back.focus();
void load();
}
function hide() {
view.hidden = true;
request = null;
sequence += 1;
window.clearTimeout(pollTimer);
}
function close() {
const current = request;
navigate(current?.parentRoute ?? { kind: "board" });
hide();
if (current) {
current.returnTo.hidden = false;
current.origin.focus();
}
}
back.addEventListener("click", close);
// capture + stopImmediatePropagation: registered before the series view's
// own Escape listener, so one Esc steps back one layer — deck first
window.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape" || view.hidden) {
return;
}
event.stopImmediatePropagation();
close();
},
true,
);
return { open, hide };
}
/* ---- series detail view (§4.1, §4.2, issue issue 129) ----------------------- */
interface SeriesView {
hide: () => void;
open: (
seriesId: number,
origin: HTMLElement,
returnTo: HTMLElement,
parentRoute: Route,
) => Promise<void>;
}
const PAD_TWO = (value: number): string => String(value).padStart(2, "0");
function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const view = must<HTMLElement>("#series");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#series-back");
const titleEl = must<HTMLElement>("#series-title");
const yearEl = must<HTMLElement>("#series-year");
const chipsEl = must<HTMLElement>("#series-chips");
const statusEl = must<HTMLElement>("#series-status");
const seasonsList = must<HTMLUListElement>("#rows-seasons");
let roots: Root[] = [];
let series: ApiSeries | null = null;
let seasons: ApiSeason[] | null = null;
let filesByEpisode = new Map<number, EpisodeFile>();
// which seasons stand open survives the refetch every action triggers
let expanded = new Set<number>();
let origin: HTMLElement | null = null;
let returnTo: HTMLElement = deckEl;
let parentRoute: Route = { kind: "library" };
let seriesId: number | null = null;
// the control to refocus once the refetch a click triggered repainted
let focusKey: string | null = null;
// guards a stale fetch from painting over a newer view
let sequence = 0;
function setStatus(text: string | null, tone?: "fault") {
statusEl.hidden = text === null;
statusEl.textContent = text ?? "";
if (tone) {
statusEl.dataset.tone = tone;
} else {
delete statusEl.dataset.tone;
}
}
function paintHeader() {
const current = series;
if (!current) {
return;
}
titleEl.textContent = current.title;
yearEl.textContent = current.year === null ? "" : String(current.year);
chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === current.root_id);
chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`));
chipsEl.append(
chip(`${current.available_episodes}/${current.wanted_episodes} eps`, (span) => {
span.setAttribute(
"aria-label",
`${current.available_episodes} of ${current.wanted_episodes} wanted episodes on disk`,
);
}),
);
chipsEl.append(
chip(current.status, (span) => {
span.dataset.status = current.status;
}),
);
if (current.blocked) {
chipsEl.append(chip("blocked"));
}
}
function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
const counts = seasonCounts(season);
return chip(`${counts.available}/${counts.wanted} on disk`, (span) => {
span.setAttribute(
"aria-label",
`${counts.available} of ${counts.wanted} wanted episodes on disk`,
);
});
}
function seasonRow(season: ApiSeason): HTMLLIElement {
const item = document.createElement("li");
item.className = "season";
const line = document.createElement("div");
line.className = "season-line";
const isOpen = expanded.has(season.number);
const disclose = document.createElement("button");
disclose.type = "button";
disclose.className = "season-disclose";
disclose.setAttribute("aria-expanded", String(isOpen));
disclose.setAttribute("aria-controls", `episodes-${season.number}`);
disclose.setAttribute("aria-label", `season ${PAD_TWO(season.number)} episodes`);
disclose.addEventListener("click", () => {
const nowOpen = !expanded.has(season.number);
if (nowOpen) {
expanded.add(season.number);
} else {
expanded.delete(season.number);
}
disclose.setAttribute("aria-expanded", String(nowOpen));
const list = item.querySelector<HTMLElement>(`#episodes-${season.number}`);
if (list) {
list.hidden = !nowOpen;
}
disclose.focus();
});
// season 0 never counts toward tracking or totals (§4.1, §4.2); say so
const name = document.createElement("span");
name.className = "season-name";
name.textContent = season.number === 0 ? "specials · s00" : `season ${PAD_TWO(season.number)}`;
const track = document.createElement("button");
track.type = "button";
track.className = "control control-quiet";
track.dataset.focusId = `track-${season.number}`;
track.setAttribute("aria-pressed", String(season.tracked));
track.textContent = "tracked";
track.addEventListener("click", () => {
if (seriesId === null) {
return;
}
track.disabled = true;
const wantedNext = !season.tracked;
void setSeasonTracked(seriesId, season.number, wantedNext).then((outcome) => {
if (outcome.kind === "error") {
track.disabled = false;
setStatus(`tracking failed — ${outcome.detail}`, "fault");
return;
}
// §4.1: turning tracking on marks revealed episodes wanted; turning
// it off withdraws nothing. The refetch paints both truths.
focusKey = `track-${season.number}`;
void load();
});
});
const space = document.createElement("span");
space.className = "season-space";
const searchBtn = document.createElement("button");
searchBtn.type = "button";
searchBtn.className = "control";
searchBtn.textContent = "search";
searchBtn.addEventListener("click", () => {
const currentId = seriesId;
if (currentId === null) {
return;
}
searchBtn.disabled = true;
void (async () => {
const outcome = await seasonTarget(currentId, season.number).search();
searchBtn.disabled = false;
if (outcome.kind === "error") {
setStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
setStatus(`season ${PAD_TWO(season.number)} sweep queued — results land in its deck`);
})();
});
const deckBtn = document.createElement("button");
deckBtn.type = "button";
deckBtn.className = "control";
deckBtn.textContent = "deck";
deckBtn.addEventListener("click", () => {
const currentId = seriesId;
const currentTitle = series?.title ?? "";
if (currentId === null) {
return;
}
navigate({ kind: "seasonReleases", seriesId: currentId, seasonNumber: season.number });
tvDeck.open({
title: currentTitle,
sub: `season ${PAD_TWO(season.number)} · packs`,
seriesId: currentId,
target: seasonTarget(currentId, season.number),
origin: deckBtn,
returnTo: view,
parentRoute: { kind: "series", seriesId: currentId },
});
});
line.append(disclose, name, track, seasonCountsChip(season));
// The season-level twin of the episode flag above: gone upstream while
// files under it remained.
if (season.vanished) {
line.append(
chip("vanished", (span) => {
span.dataset.flag = "vanished";
}),
);
}
line.append(space, searchBtn, deckBtn);
item.append(line);
const episodes = document.createElement("ul");
episodes.className = "episodes";
episodes.id = `episodes-${season.number}`;
episodes.hidden = !isOpen;
for (const episode of season.episodes) {
episodes.append(detailEpisodeRow(season.number, episode));
}
item.append(episodes);
return item;
}
function detailEpisodeRow(seasonNumber: number, episode: ApiEpisode): HTMLLIElement {
const item = document.createElement("li");
item.className = "episode";
const tag = document.createElement("span");
tag.className = "ep-tag readout dim";
tag.textContent = `E${PAD_TWO(episode.number)}`;
const name = document.createElement("span");
name.className = "ep-title";
name.textContent = episode.title === "" ? "—" : episode.title;
const air = document.createElement("span");
air.className = "ep-air readout dim";
air.textContent = formatAirDate(episode.air_date);
const chips = document.createElement("span");
chips.className = "row-chips ep-chips";
chips.append(
chip(episode.state, (span) => {
span.dataset.movieState = episode.state;
}),
);
// issue 122: gone upstream while its file remained — a conflict, not a state
if (episode.vanished) {
chips.append(
chip("vanished", (span) => {
span.dataset.flag = "vanished";
}),
);
}
const file = filesByEpisode.get(episode.id);
if (file) {
// §7.4: attribute tags come from ffprobe, so they are true
for (const attribute of fileAttributeTags(file)) {
chips.append(chip(attribute));
}
const waiver = waiverLabel(file.waiver);
if (waiver !== null) {
chips.append(
chip(waiver, (span) => {
span.dataset.verdict = "waived";
}),
);
}
}
const actions = document.createElement("span");
actions.className = "ep-actions";
const aired = !isUnaired(episode.air_date);
const onDisk = episode.state === "available";
if (aired && onDisk) {
// present and correct: nothing to decide here
} else if (!aired) {
const note = document.createElement("span");
note.className = "readout dim ep-unaired";
note.textContent = "unaired";
actions.append(note);
} else {
const want = document.createElement("button");
want.type = "button";
want.className = "control control-quiet";
want.dataset.focusId = `want-${episode.id}`;
want.setAttribute("aria-pressed", String(episode.wanted));
want.textContent = "want";
want.addEventListener("click", () => {
want.disabled = true;
void setEpisodeWanted(episode.id, !episode.wanted).then((outcome) => {
if (outcome.kind === "error") {
want.disabled = false;
setStatus(`want failed — ${outcome.detail}`, "fault");
return;
}
focusKey = `want-${episode.id}`;
void load();
});
});
actions.append(want);
const searchBtn = document.createElement("button");
searchBtn.type = "button";
searchBtn.className = "control";
searchBtn.textContent = "search";
searchBtn.addEventListener("click", () => {
searchBtn.disabled = true;
void (async () => {
const outcome = await episodeTarget(episode.id).search();
searchBtn.disabled = false;
if (outcome.kind === "error") {
setStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
setStatus(`S${PAD_TWO(seasonNumber)}E${PAD_TWO(episode.number)} sweep queued`);
})();
});
actions.append(searchBtn);
const deckBtn = document.createElement("button");
deckBtn.type = "button";
deckBtn.className = "control";
deckBtn.textContent = "deck";
deckBtn.addEventListener("click", () => {
const currentId = seriesId;
const currentTitle = series?.title ?? "";
if (currentId === null) {
return;
}
navigate({ kind: "episodeReleases", episodeId: episode.id });
tvDeck.open({
title: currentTitle,
sub: `S${PAD_TWO(seasonNumber)}E${PAD_TWO(episode.number)} · ${episode.title}`,
seriesId: currentId,
target: episodeTarget(episode.id),
origin: deckBtn,
returnTo: view,
parentRoute: { kind: "series", seriesId: currentId },
});
});
actions.append(deckBtn);
}
item.append(tag, name, air, chips, actions);
return item;
}
function renderSeasons() {
seasonsList.replaceChildren();
if (!seasons) {
return;
}
for (const season of seasons) {
seasonsList.append(seasonRow(season));
}
if (seasons.length === 0) {
const none = document.createElement("li");
none.className = "rel rel-none readout dim";
none.textContent = "no seasons revealed yet — a metadata refresh fills this in";
seasonsList.append(none);
}
}
async function load() {
const id = seriesId;
if (id === null) {
return;
}
sequence += 1;
const ticket = sequence;
setStatus("reading series…");
const [detail, seasonsOutcome, filesOutcome, fetchedRoots] = await Promise.all([
fetchSeries(id),
fetchSeasons(id),
fetchSeriesFiles(id),
allRoots().catch(() => roots),
]);
if (ticket !== sequence || seriesId !== id) {
return;
}
roots = fetchedRoots;
if (!detail) {
setStatus("series unavailable", "fault");
return;
}
if (seasonsOutcome.kind === "error") {
setStatus(`seasons unavailable — ${seasonsOutcome.detail}`, "fault");
return;
}
series = detail;
seasons = seasonsOutcome.seasons;
filesByEpisode = new Map(
filesOutcome.kind === "files"
? filesOutcome.files.map((file) => [file.episode_id, file])
: [],
);
paintHeader();
renderSeasons();
setStatus(null);
if (focusKey !== null) {
view.querySelector<HTMLElement>(`[data-focus-id="${focusKey}"]`)?.focus();
focusKey = null;
}
}
async function open(id: number, from: HTMLElement, container: HTMLElement, parent: Route) {
seriesId = id;
origin = from;
returnTo = container;
parentRoute = parent;
expanded = new Set();
focusKey = null;
tvDeck.hide();
for (const sibling of views) {
sibling.hide();
}
board.hidden = true;
deckEl.hidden = true;
view.hidden = false;
back.focus();
await load();
}
function hide() {
view.hidden = true;
seriesId = null;
seasons = null;
sequence += 1;
}
function close() {
navigate(parentRoute);
hide();
returnTo.hidden = false;
origin?.focus();
}
back.addEventListener("click", close);
// capture, like every other layer: the tv deck's listener is registered
// first, so one Esc steps back one layer
window.addEventListener(
"keydown",
(event) => {
if (event.key === "Escape" && !view.hidden) {
event.stopImmediatePropagation();
navigate(parentRoute);
close();
}
},
true,
);
return { hide, open };
}
/* ---- attention queues (§5.2 + §5.7, issue #33) ------------------------ */
interface QueuesView {
hide: () => void;
open: () => void;
/** Refetches both queues and repaints the rail badge. */
refreshBadge: () => Promise<void>;
}
interface QueueGroup {
section: HTMLElement;
count: HTMLElement;
rows: HTMLUListElement;
}
function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableView[]): QueuesView {
const view = must<HTMLElement>("#queues");
const deck = must<HTMLElement>("#deck");
const nav = must<HTMLButtonElement>("#nav-queues");
const badge = must<HTMLElement>("#queues-count");
const summary = must<HTMLElement>("#queues-summary");
const status = must<HTMLElement>("#queues-status");
const groups: { noPt: QueueGroup; decision: QueueGroup } = {
noPt: {
section: must<HTMLElement>("#queue-no-pt"),
count: must<HTMLElement>("#count-no-pt"),
rows: must<HTMLUListElement>("#rows-no-pt"),
},
decision: {
section: must<HTMLElement>("#queue-decision"),
count: must<HTMLElement>("#count-decision"),
rows: must<HTMLUListElement>("#rows-decision"),
},
};
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.noPt, groups.decision]) {
group.section.hidden = true;
group.rows.replaceChildren();
}
summary.textContent = "";
}
function paintBadge(total: number) {
badge.hidden = total === 0;
badge.textContent = total === 0 ? "" : String(total);
}
function emptyLine(group: QueueGroup) {
if (group.rows.childElementCount === 0) {
const none = document.createElement("li");
none.className = "queue-empty readout dim";
none.textContent = "queue empty";
group.rows.append(none);
}
}
function entryCount(group: QueueGroup): number {
return [...group.rows.children].filter((child) => !child.classList.contains("queue-empty"))
.length;
}
function paintCounts() {
let total = 0;
for (const group of [groups.noPt, groups.decision]) {
const entries = entryCount(group);
group.count.textContent = String(entries);
total += entries;
}
paintBadge(total);
summary.textContent = total === 0 ? "" : `${total} waiting`;
}
const openReleases = (movie: LibraryMovie, origin: HTMLElement) => {
navigate({ kind: "releases", movieId: movie.id });
releases.open(movie, origin, view, { kind: "queues" });
};
/** Drops an emptied item without a refetch: the override IS written. */
function settle(item: HTMLLIElement, group: QueueGroup, text: string) {
item.remove();
emptyLine(group);
paintCounts();
setStatus(text);
}
function render(queues: AttentionQueues) {
clearGroups();
// both sections always render, even empty — this is the one surface
// whose good news is an absence, and the operator visits to see it
for (const group of [groups.noPt, groups.decision]) {
group.section.hidden = false;
}
for (const movie of queues.no_pt_source) {
groups.noPt.rows.append(
noPtRow(movie, roots, openReleases, (item, text) => {
settle(item, groups.noPt, text);
}),
);
}
for (const movie of queues.needs_decision) {
groups.decision.rows.append(libraryRow(movie, roots, openReleases));
}
// the TV lanes share the two sections: one queue per reason, whatever
// the kind. Series entries are display-only — their release deck and
// overrides arrive with the series detail view (issues 129 and 39).
for (const series of queues.tv_no_pt_source) {
groups.noPt.rows.append(seriesAttentionRow(series));
}
for (const series of queues.tv_needs_decision) {
groups.decision.rows.append(seriesAttentionRow(series));
}
for (const group of [groups.noPt, groups.decision]) {
emptyLine(group);
}
paintCounts();
setStatus(attentionTotal(queues) === 0 ? "nothing needs attention" : null);
}
async function load() {
sequence += 1;
const ticket = sequence;
setStatus("reading queues…");
const [outcome, fetchedRoots] = await Promise.all([
fetchAttention(),
allRoots().catch(() => roots),
]);
if (ticket !== sequence) {
return;
}
roots = fetchedRoots;
if (outcome.kind === "error") {
clearGroups();
setStatus(`queues unavailable — ${outcome.detail}`, "fault");
return;
}
render(outcome.queues);
}
function open() {
releases.hide();
for (const sibling of views) {
sibling.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) {
navigate({ kind: "queues" });
open();
} else {
navigate({ kind: "board" });
close();
}
});
// capture, like the release deck: one Esc steps back one layer
window.addEventListener(
"keydown",
(event) => {
if (event.key === "Escape" && !view.hidden) {
event.stopImmediatePropagation();
navigate({ kind: "board" });
close();
}
},
true,
);
async function refreshBadge() {
const outcome = await fetchAttention();
if (outcome.kind === "error") {
return;
}
paintBadge(attentionTotal(outcome.queues));
}
return { hide, open, refreshBadge };
}
/**
* One TV lane entry: the series plus one chip per episode or season that put
* it there, named `SxxEyy` so the operator knows what to act on.
* Inert — the release deck and overrides arrive with the series detail view
* (issues 129 and 39).
*/
function seriesAttentionRow(entry: SeriesAttention): HTMLLIElement {
const item = document.createElement("li");
const row = document.createElement("div");
row.className = "row";
const chips = document.createElement("span");
chips.className = "row-chips";
for (const episode of entry.episodes) {
const tag = episodeTag(episode.season_number, episode.episode_number);
chips.append(
chip(tag, (span) => {
span.setAttribute("aria-label", `${tag} waiting`);
}),
);
}
for (const season of entry.seasons) {
chips.append(chip(seasonTag(season.number)));
}
row.append(rowTitle(entry.title, entry.year), chips);
item.append(row);
return item;
}
/**
* One no-PT-source entry: the line opens the release deck as evidence; the
* §5.2 one click sits beside it and empties the item.
*/
function noPtRow(
movie: AttentionMovie,
roots: Root[],
open: (movie: LibraryMovie, origin: HTMLElement) => void,
settled: (item: HTMLLIElement, text: string) => void,
): HTMLLIElement {
const item = document.createElement("li");
item.className = "queue-item";
const line = document.createElement("button");
line.type = "button";
line.className = "row row-tmdb queue-line";
const chips = document.createElement("span");
chips.className = "row-chips";
const root = roots.find((candidate) => candidate.id === movie.root_id);
chips.append(chip(root ? root.audience : `root ${movie.root_id}`));
const attempts = attemptsLabel(movie.search_attempts);
if (attempts !== null) {
chips.append(
chip(attempts, (span) => {
span.setAttribute("aria-label", `${movie.search_attempts} searches found no pt source`);
}),
);
}
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "releases";
chips.append(affordance);
line.append(rowTitle(movie.title, movie.year), chips);
line.addEventListener("click", () => {
open(movie, line);
});
const allow = document.createElement("button");
allow.type = "button";
allow.className = "control queue-allow";
allow.textContent = "allow english";
const note = document.createElement("span");
note.className = "queue-feedback readout";
note.setAttribute("role", "status");
note.hidden = true;
allow.addEventListener("click", () => {
allow.disabled = true;
note.hidden = false;
delete note.dataset.tone;
note.textContent = "writing override…";
void allowEnglishAudio(movie.id).then((outcome) => {
if (outcome.kind === "error") {
allow.disabled = false;
note.textContent = `${outcome.overrideWritten ? "override written · " : ""}${outcome.detail}`;
note.dataset.tone = "fault";
return;
}
settled(item, `${movie.title} — english allowed, search queued`);
});
});
item.append(line, allow, note);
return item;
}
function renderManual(intake: HTMLElement, kind: "magnet" | "torrent_url", raw: string) {
const parsed = parseManualInput(kind, raw);
intake.replaceChildren();
const head = document.createElement("header");
head.className = "module-head";
const name = document.createElement("h3");
name.className = "module-name";
name.textContent = kind === "magnet" ? "magnet" : "torrent";
const status = document.createElement("span");
status.className = "module-status readout";
status.textContent = "recognized";
head.append(name, status);
const detail = document.createElement("p");
detail.className = "module-detail readout";
detail.textContent = parsed.name ?? parsed.raw;
intake.append(head, detail);
if (parsed.infohash) {
const hash = document.createElement("p");
hash.className = "module-detail readout dim";
hash.textContent = `btih ${parsed.infohash}`;
intake.append(hash);
}
const note = document.createElement("p");
note.className = "module-detail readout dim";
note.textContent = "manual grab lands with the release buckets — nothing sent yet";
intake.append(note);
}
main();