Files
arr/web/src/main.ts
T
Miguel Palhas 9fee07f080 feat: let a size rejection be waived
A release below §5.5's floor was rejected with no way through, so a
policy wrong about one title left three Rick and Morty S09 packs
visible and none grabbable.

`allow_below_floor` relaxes the floor for one title into a soft fail,
never a pass: the release is waived, so automatic grabbing still skips
it and the import records a §5.7 waiver. The deck offers the one click
on a rejected row where the rule has an override, which is exactly what
§9.3's override is for.

Stored verdicts are re-derived when a title's overrides change — the
deck and the daemon's grab gate both read that column, so without it
the row the operator just acted on would keep reading `rejected`.

Closes #210

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 22:15:14 +01:00

4184 lines
130 KiB
TypeScript

import { type CheckStatus, type Probe, probeHealth } from "./health";
import {
fetchLibrary,
type LibrarySeries,
movieNeedsAttention,
seriesNeedsAttention,
waiverLabel,
} from "./library";
import {
fileName,
formatMetaLine,
formatRating,
formatVoteCount,
imdbLink,
movieMetadata,
resolveTrailer,
rottenTomatoesSearch,
tmdbImage,
tmdbMovieLink,
tmdbSeriesLink,
tvdbLink,
updateMovie,
youtubeLink,
} from "./movie";
import {
type AttentionMovie,
type AttentionQueues,
allowEnglishAudio,
attemptsLabel,
attentionTotal,
fetchAttention,
type SeriesAttention,
} from "./queues";
import {
type ActionOutcome,
bucketOf,
type FilesOutcome,
formatAudio,
formatHdr,
formatResolution,
formatRetryWait,
formatScore,
formatSeeders,
formatSize,
formatSource,
formatSweepAge,
grabRelease,
libraryFolder,
type MovieRelease,
movieFiles,
movieReleases,
movieSearchState,
overridable,
probedAttributeTags,
queueSearch,
removeMovie,
ruleLabel,
type SeasonPackState,
sweepExpected,
totalSize,
type WaiveOutcome,
waiveAndGrab,
} from "./releases";
import { currentRoute, navigate, type Route } from "./router";
import {
addMovie,
addSeries,
allRoots,
fetchMovie,
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,
refreshSeriesMetadata,
removeEpisodeFiles,
removeSeasonFiles,
removeSeries,
type SeriesMetadata,
seasonCounts,
seasonTarget,
seriesFolder,
seriesMetadata,
setEpisodeWanted,
setSeasonTracked,
type TvTarget,
waiveAndGrabTv,
} from "./series";
import { armedDelete, 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(`signal chain 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(`markup is missing ${selector}`);
}
return element;
}
function main() {
const chain = must<HTMLElement>(".chain");
const masterLamp = must<HTMLElement>("#master-lamp");
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;
chain.classList.add("powered");
}
if (probe.kind === "unreachable") {
masterLamp.dataset.state = "unreachable";
version.textContent = "v —";
// the outage detail lives on the chain's arr module — the header has no verdict line
setModule(master, "unreachable", "fault", "down", probe.detail);
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";
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[] = [];
// 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(tvDeck, views);
const movieDetail = movieMain(views);
const library = libraryMain(movieDetail, seriesDetail, views);
const goHome = () => library.open();
const queues = queuesMain(movieDetail, views, goHome);
const settings = settingsMain(views, goHome);
views.push(tvDeck, seriesDetail, movieDetail, library, queues, settings);
const search = searchMain(movieDetail, seriesDetail, views, goHome);
// a removed title must not survive on the surface the page opened over
const openAfterRemoval = (parent: Route) => {
switch (parent.kind) {
case "library":
library.open();
break;
case "queues":
queues.open();
break;
case "search":
search.restore(parent.query);
break;
default:
library.open();
break;
}
};
movieDetail.setRemoved(openAfterRemoval);
seriesDetail.setRemoved(openAfterRemoval);
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 "library":
// §9.7: "/" is the library — clear any stale query so the rail
// input matches the route on a cold load or back-navigation
search.showIdle();
break;
case "queues":
queues.open();
break;
case "settings":
settings.open();
break;
case "search":
search.restore(route.query);
break;
case "movie":
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();
await movieDetail.open(
route.movieId,
must<HTMLElement>("#nav-library"),
must<HTMLElement>("#library"),
{
kind: "library",
},
);
// `/movies/{id}/releases` keeps resolving (#149): same page, deck
// section in view
if (route.kind === "releases") {
must<HTMLElement>("#movie-releases").scrollIntoView({ block: "start" });
}
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;
}
}
}
// §9.7: the wordmark is a link home — intercepted so it navigates the
// SPA instead of reloading, but still a real anchor for the keyboard
const wordmark = must<HTMLAnchorElement>("#wordmark");
wordmark.addEventListener("click", (event) => {
event.preventDefault();
navigate({ kind: "library" });
search.showIdle();
});
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 library with the search box empty — the "/" route. */
showIdle: () => void;
/** Restores the deck for `query` — the "/search?q=" route. */
restore: (query: string) => void;
}
function searchMain(
movieDetail: MovieView,
seriesDetail: SeriesView,
views: HideableView[],
goHome: () => void,
): 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(),
});
};
/** A movie hit opens its §9.6 page (#149); the deck section is on it. */
const openMovie = (movie: LibraryMovie, origin: HTMLElement) => {
navigate({ kind: "movie", movieId: movie.id });
void movieDetail.open(movie.id, origin, refs.deck, {
kind: "search",
query: input.value.trim(),
});
};
let timer: number | undefined;
let controller: AbortController | null = null;
/** A cleared search lands on the library — the "/" route (§9.7). */
function showHome() {
controller?.abort();
controller = null;
goHome();
}
function showDeck() {
for (const view of views) {
view.hide();
}
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 or a series.
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 inLibrary = new Set([...libraryMovies, ...librarySeries].map((title) => title.tmdb_id));
// Issue #168: an unreachable TMDB degrades only its own group — the
// operator sees the library half plus why TMDB is missing, and can tell
// that apart from a clean "nothing found".
const tmdbDown = response.tmdb_status === "unavailable";
if (response.library.length === 0 && response.tmdb.length === 0) {
setStatus(
tmdbDown ? "no matches in library — tmdb unavailable" : "no matches in library or on tmdb",
);
return;
}
if (tmdbDown) {
setStatus("tmdb unavailable — showing library matches only", "fault");
} else {
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, openMovie));
}
for (const series of librarySeries) {
refs.groups.library.rows.append(librarySeriesRow(series, roots, 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: "library" });
showHome();
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):not([aria-disabled="true"])',
);
if (first) {
event.preventDefault();
first.focus();
}
} else if (event.key === "Escape") {
input.value = "";
navigate({ kind: "library" });
showHome();
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: "library" });
showHome();
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):not([aria-disabled="true"])',
),
];
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 = "";
showHome();
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;
}
/** §4.2 state ramp on a media-state chip; the word carries, colour accelerates. */
function mediaStateChip(state: string, wanted: boolean): HTMLSpanElement {
return chip(state, (span) => {
span.dataset.movieState = state;
span.dataset.wanted = String(wanted);
});
}
/** The ramp read off N/M itself: green when full, amber while a gap remains. */
function countsTone(available: number, wanted: number): "complete" | "partial" | null {
if (wanted === 0) {
return null;
}
return available >= wanted ? "complete" : "partial";
}
function countsChip(
label: string,
available: number,
wanted: number,
ariaLabel: string,
): HTMLSpanElement {
return chip(label, (span) => {
const tone = countsTone(available, wanted);
if (tone !== null) {
span.dataset.counts = tone;
}
span.setAttribute("aria-label", ariaLabel);
});
}
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)}`;
}
/* ---- §9.2 rich-row parts (#148) ---------------------------------------- */
/** A search row's poster at w92, or a same-size blank so the edge never jitters. */
function rowPoster(posterPath: string | null, title: string): HTMLElement {
if (posterPath === null) {
const blank = document.createElement("span");
blank.className = "row-poster-blank";
blank.setAttribute("aria-hidden", "true");
return blank;
}
const img = document.createElement("img");
img.className = "row-poster";
img.loading = "lazy";
img.src = tmdbImage(posterPath, "w92") ?? "";
img.alt = `${title} poster`;
// hotlinked art can 404; fall back to the blank rather than alt-text spill
img.addEventListener("error", () => {
const blank = document.createElement("span");
blank.className = "row-poster-blank";
blank.setAttribute("aria-hidden", "true");
img.replaceWith(blank);
});
return img;
}
/**
* TMDB's rating as a chip (#148). No votes means no chip — never a 0.0.
* `vote_count` rides along as the title when the response carries one.
*/
function ratingChip(voteAverage: number | null, voteCount?: number): HTMLSpanElement | null {
if (voteAverage === null) {
return null;
}
const span = document.createElement("span");
span.className = "chip";
span.append(starIcon(), document.createTextNode(formatRating(voteAverage)));
if (voteCount !== undefined) {
span.title = `${formatVoteCount(voteCount)} votes`;
}
return span;
}
/**
* The §9.6 trailer chip: one call, fired from the click and never before,
* so a page of results costs zero trailer requests. The tab opens inside
* the handler — an async `window.open` is what popup blockers eat — and is
* navigated when the key arrives; no video closes it again quietly.
*/
function trailerChip(kind: "movie" | "tv", tmdbId: number): HTMLSpanElement {
const chipEl = document.createElement("span");
chipEl.className = "chip row-trailer";
chipEl.textContent = "trailer";
chipEl.setAttribute("role", "button");
chipEl.tabIndex = 0;
let busy = false;
const resolve = () => {
if (busy || chipEl.dataset.state !== undefined) {
return;
}
busy = true;
const tab = window.open("", "_blank");
chipEl.dataset.state = "pending";
chipEl.textContent = "trailer…";
void resolveTrailer(kind, tmdbId).then((outcome) => {
busy = false;
if (outcome.kind === "trailer") {
if (tab) {
tab.location.href = youtubeLink(outcome.youtubeKey);
delete chipEl.dataset.state;
chipEl.textContent = "trailer";
} else {
// visibly blocked beats silently swallowed
chipEl.dataset.state = "blocked";
chipEl.textContent = "popups blocked";
}
return;
}
tab?.close();
if (outcome.kind === "none") {
// TMDB having no video is an ordinary outcome, not a failure
chipEl.dataset.state = "none";
chipEl.textContent = "no trailer";
chipEl.removeAttribute("role");
chipEl.removeAttribute("tabindex");
} else {
delete chipEl.dataset.state;
chipEl.title = outcome.detail;
chipEl.textContent = "trailer";
}
});
};
chipEl.addEventListener("click", (event) => {
event.stopPropagation();
event.preventDefault();
resolve();
});
chipEl.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.stopPropagation();
event.preventDefault();
resolve();
}
});
return chipEl;
}
/** Poster left, then title / chips / overview in a wrapping column (#148). */
function richRow(): {
item: HTMLLIElement;
row: HTMLButtonElement;
body: HTMLElement;
chips: HTMLElement;
} {
const item = document.createElement("li");
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb row-rich";
const body = document.createElement("span");
body.className = "row-main";
const chips = document.createElement("span");
chips.className = "row-chips";
return { item, row, body, chips };
}
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, row, body, chips } = richRow();
const root = roots.find((candidate) => candidate.id === movie.root_id);
chips.append(chip(root ? root.audience : `root ${movie.root_id}`));
chips.append(mediaStateChip(movie.state, movie.wanted));
// §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 rating = ratingChip(movie.vote_average);
if (rating !== null) {
chips.append(rating);
}
chips.append(trailerChip("movie", movie.tmdb_id));
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "open";
chips.append(affordance);
body.append(rowTitle(movie.title, movie.year), chips);
row.append(rowPoster(movie.poster_path, movie.title), body);
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, row, body, chips } = richRow();
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 rating = ratingChip(series.vote_average);
if (rating !== null) {
chips.append(rating);
}
chips.append(trailerChip("tv", series.tmdb_id));
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "episodes";
chips.append(affordance);
body.append(rowTitle(series.title, series.year), chips);
row.append(rowPoster(series.poster_path, series.title), body);
row.addEventListener("click", () => {
open(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, row, body, chips } = richRow();
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);
}
const rating = ratingChip(hit.vote_average, hit.vote_count);
if (rating !== null) {
chips.append(rating);
}
chips.append(trailerChip(hit.kind === "series" ? "tv" : "movie", hit.tmdb_id));
body.append(rowTitle(hit.title, hit.year), chips);
if (hit.overview) {
const overview = document.createElement("span");
overview.className = "row-overview";
overview.textContent = hit.overview;
body.append(overview);
}
row.append(rowPoster(hit.poster_path, hit.title), body);
item.append(row);
if (inLibrary) {
// aria-disabled, not disabled: a dead button would also kill the
// trailer chip's clicks, and that chip stays live on every row
row.setAttribute("aria-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.setAttribute("aria-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;
}
/* ---- movie page (§9.6, issue #149) ------------------------------------ */
interface MovieView {
hide: () => void;
/**
* Opens `/movies/{id}` over `returnTo`, which back and Esc restore;
* `parentRoute` is where they navigate. The deep link passes the library
* surface, exactly as a series deep link does.
*/
open: (
movieId: number,
origin: HTMLElement,
returnTo: HTMLElement,
parentRoute: Route,
) => Promise<void>;
/**
* Called with the page'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);
});
}
/* ---- §9.6 painters shared by both detail pages ------------------------- */
/** The drawn star before a TMDB rating — no glyph standing in for an icon. */
function starIcon(): SVGSVGElement {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 12 12");
svg.setAttribute("class", "star");
svg.setAttribute("aria-hidden", "true");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute(
"d",
"M6 0.8 L7.5 4.2 L11.2 4.6 L8.4 7.1 L9.2 10.8 L6 8.9 L2.8 10.8 L3.6 7.1 L0.8 4.6 L4.5 4.2 Z",
);
svg.append(path);
return svg;
}
/** External links read as quiet controls; they leave the app entirely. */
function externalLink(label: string, href: string): HTMLAnchorElement {
const link = document.createElement("a");
link.className = "control control-quiet";
link.href = href;
link.target = "_blank";
link.rel = "noreferrer";
link.textContent = label;
return link;
}
function movieMain(views: HideableView[]): MovieView {
const view = must<HTMLElement>("#movie");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#movie-back");
const statusEl = must<HTMLElement>("#movie-status");
const hero = must<HTMLElement>("#movie-hero");
const poster = must<HTMLImageElement>("#movie-poster");
const titleEl = must<HTMLElement>("#movie-title");
const yearEl = must<HTMLElement>("#movie-year");
const chipsEl = must<HTMLElement>("#movie-chips");
const ratingEl = must<HTMLElement>("#movie-rating");
const metaEl = must<HTMLElement>("#movie-meta");
const taglineEl = must<HTMLElement>("#movie-tagline");
const overviewEl = must<HTMLElement>("#movie-overview");
const actionsEl = must<HTMLElement>("#movie-actions");
const wanted = must<HTMLButtonElement>("#movie-wanted");
const blocked = must<HTMLButtonElement>("#movie-blocked");
const rootSelect = must<HTMLSelectElement>("#movie-root");
const sweep = must<HTMLButtonElement>("#movie-sweep");
const remove = must<HTMLButtonElement>("#movie-remove");
const removeWrap = must<HTMLElement>("#remove-panel");
const filesSection = must<HTMLElement>("#movie-files");
const diskCount = must<HTMLElement>("#count-disk");
const diskRows = must<HTMLUListElement>("#rows-disk");
const releaseStatus = 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 movieId: number | null = null;
let current: LibraryMovie | null = null;
let roots: Root[] = [];
let removed: ((parent: Route) => void) | null = null;
let origin: HTMLElement | null = null;
let returnTo: HTMLElement | null = null;
let parentRoute: Route = { kind: "library" };
const dom: BucketsDom = { eligible, waived: collapsed.waived, rejected: collapsed.rejected };
const actions: ReleaseActions = {
reload: () => reloadReleases(),
notify: (text, tone) => setReleaseStatus(text, tone),
grab: async (release, bucket) => {
const movie = current;
if (!movie) {
return { kind: "error", detail: "page closed", overrideWritten: false };
}
if (bucket !== "eligible") {
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) {
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;
}
}
/** The releases section speaks beside its own buckets, not from the page top. */
function setReleaseStatus(text: string | null, tone?: "fault", busy = false) {
releaseStatus.hidden = text === null;
if (busy && text !== null) {
const lamp = document.createElement("span");
lamp.className = "lamp";
lamp.dataset.state = "probing";
releaseStatus.replaceChildren(lamp, document.createTextNode(text));
} else {
releaseStatus.textContent = text ?? "";
}
if (tone) {
releaseStatus.dataset.tone = tone;
} else {
delete releaseStatus.dataset.tone;
}
}
/* ---- identity: stored fields first, rich detail on top ---- */
function paintIdentity() {
const movie = current;
if (!movie) {
return;
}
titleEl.textContent = movie.title;
yearEl.textContent = movie.year === null ? "" : String(movie.year);
chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === movie.root_id);
chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`));
chipsEl.append(mediaStateChip(movie.state, movie.wanted));
// §5.7 honesty: a waived import is never presented as a clean match
const waiver = waiverLabel(movie.waiver);
if (waiver !== null) {
chipsEl.append(
chip(waiver, (span) => {
span.dataset.verdict = "waived";
}),
);
}
if (movie.blocked) {
chipsEl.append(chip("blocked"));
}
}
function paintRootOptions() {
rootSelect.replaceChildren();
for (const root of roots.filter((candidate) => candidate.kind === "movie")) {
const option = document.createElement("option");
option.value = String(root.id);
option.textContent = `${root.audience} · ${root.policy_name}`;
rootSelect.append(option);
}
}
function paintControls() {
const movie = current;
if (!movie) {
return;
}
wanted.setAttribute("aria-pressed", String(movie.wanted));
blocked.setAttribute("aria-pressed", String(movie.blocked));
paintRootOptions();
// a root list that does not carry the title's own root still shows it —
// a select lying by omission would make the next change move it blindly
if (rootSelect.value !== String(movie.root_id)) {
const option = document.createElement("option");
option.value = String(movie.root_id);
option.textContent = `root ${movie.root_id}`;
rootSelect.append(option);
}
rootSelect.value = String(movie.root_id);
sweep.disabled = false;
}
async function refreshMovie() {
const id = movieId;
if (id === null) {
return;
}
const fresh = await fetchMovie(id);
if (fresh === null || movieId !== id) {
return;
}
current = fresh;
paintIdentity();
paintControls();
}
wanted.addEventListener("click", () => {
const movie = current;
if (!movie) {
return;
}
wanted.disabled = true;
void updateMovie(movie.id, { wanted: !movie.wanted }).then((outcome) => {
if (outcome.kind === "error") {
wanted.disabled = false;
setStatus(`wanted failed — ${outcome.detail}`, "fault");
return;
}
void refreshMovie().then(() => {
wanted.disabled = false;
});
});
});
blocked.addEventListener("click", () => {
const movie = current;
if (!movie) {
return;
}
blocked.disabled = true;
void updateMovie(movie.id, { blocked: !movie.blocked }).then((outcome) => {
if (outcome.kind === "error") {
blocked.disabled = false;
setStatus(`blocked failed — ${outcome.detail}`, "fault");
return;
}
void refreshMovie().then(() => {
blocked.disabled = false;
});
});
});
rootSelect.addEventListener("change", () => {
const movie = current;
if (!movie) {
return;
}
const next = Number(rootSelect.value);
if (!Number.isInteger(next) || next === movie.root_id) {
return;
}
rootSelect.disabled = true;
void updateMovie(movie.id, { root_id: next }).then((outcome) => {
rootSelect.disabled = false;
if (outcome.kind === "error") {
rootSelect.value = String(movie.root_id);
setStatus(`root change failed — ${outcome.detail}`, "fault");
return;
}
void refreshMovie();
});
});
/* ---- §9.6 rich detail: one request, images hotlinked ---- */
function clearRichDetail() {
hero.classList.remove("has-backdrop");
hero.style.removeProperty("--backdrop");
poster.hidden = true;
poster.removeAttribute("src");
ratingEl.hidden = true;
ratingEl.replaceChildren();
metaEl.hidden = true;
taglineEl.hidden = true;
overviewEl.hidden = true;
actionsEl.replaceChildren();
}
async function loadMetadata(id: number, ticket: number) {
const outcome = await movieMetadata(id);
if (ticket !== sequence || movieId !== id) {
return;
}
if (outcome.kind === "error") {
// the stored identity stands; the actions row says why the rest is absent
const note = document.createElement("p");
note.className = "add-note readout";
note.dataset.tone = "fault";
note.textContent = `metadata unavailable — ${outcome.detail}`;
actionsEl.append(note);
return;
}
const detail = outcome.metadata;
const posterUrl = tmdbImage(detail.poster_path, "w342");
if (posterUrl !== null && current !== null) {
poster.src = posterUrl;
poster.alt = `${current.title} poster`;
poster.hidden = false;
}
const backdropUrl = tmdbImage(detail.backdrop_path, "w1280");
if (backdropUrl !== null) {
hero.classList.add("has-backdrop");
hero.style.setProperty("--backdrop", `url("${backdropUrl}")`);
}
if (detail.vote_average !== null) {
ratingEl.hidden = false;
ratingEl.append(starIcon(), document.createTextNode(`${formatRating(detail.vote_average)} `));
const votes = document.createElement("span");
votes.className = "dim";
votes.textContent = `(${formatVoteCount(detail.vote_count)})`;
ratingEl.append(votes);
}
const metaLine = formatMetaLine(detail.runtime, detail.genres);
if (metaLine !== "") {
metaEl.hidden = false;
metaEl.textContent = metaLine;
}
if (detail.tagline !== null && detail.tagline !== "") {
taglineEl.hidden = false;
taglineEl.textContent = `${detail.tagline}`;
}
if (detail.overview !== null && detail.overview !== "") {
overviewEl.hidden = false;
overviewEl.textContent = detail.overview;
}
// the trailer resolves from this response's own key — no second call.
// Hidden when absent rather than shown dead (§9.6).
if (detail.trailer !== null) {
const trailer = document.createElement("a");
trailer.className = "control";
trailer.href = youtubeLink(detail.trailer.youtube_key);
trailer.target = "_blank";
trailer.rel = "noreferrer";
trailer.textContent = "trailer";
actionsEl.append(trailer);
}
actionsEl.append(externalLink("tmdb", tmdbMovieLink(detail.tmdb_id)));
if (detail.imdb_id !== null) {
actionsEl.append(externalLink("imdb", imdbLink(detail.imdb_id)));
}
if (current !== null) {
actionsEl.append(
externalLink("rotten tomatoes", rottenTomatoesSearch(current.title, current.year)),
);
}
}
/* ---- on disk: ffprobe truth and honest waivers (§5.6, §5.7) ---- */
function paintFiles(outcome: FilesOutcome) {
diskRows.replaceChildren();
filesSection.hidden = false;
if (outcome.kind === "error") {
diskCount.textContent = "";
const row = document.createElement("li");
row.className = "rel rel-none readout";
row.dataset.tone = "fault";
row.textContent = `files unreadable — ${outcome.detail}`;
diskRows.append(row);
return;
}
const files = outcome.files;
diskCount.textContent = String(files.length);
if (files.length === 0) {
const row = document.createElement("li");
row.className = "rel rel-none readout dim";
row.textContent = "nothing on disk yet — imports land here";
diskRows.append(row);
return;
}
for (const file of files) {
const item = document.createElement("li");
item.className = "rel disk-row";
const line = document.createElement("div");
line.className = "rel-line";
const name = document.createElement("span");
name.className = "disk-name readout";
name.textContent = fileName(file.path);
name.title = file.path;
line.append(name);
for (const attribute of probedAttributeTags(file.probed)) {
line.append(chip(attribute));
}
line.append(chip(formatSize(file.size)));
const waiver = waiverLabel(file.waiver);
if (waiver !== null) {
line.append(
chip(waiver, (span) => {
span.dataset.verdict = "waived";
}),
);
}
item.append(line);
diskRows.append(item);
}
}
/* ---- releases: the §9.3 deck, unchanged, as the closing section ---- */
function render(releases: MovieRelease[]) {
if (!current) {
return;
}
if (!paintBuckets(dom, releases, actions)) {
return;
}
setReleaseStatus(null);
}
async function loadReleases(id: number, ticket: number) {
window.clearTimeout(pollTimer);
setReleaseStatus("reading releases…");
const outcome = await movieReleases(id);
if (ticket !== sequence || movieId !== id) {
return;
}
if (outcome.kind === "error") {
clearBuckets(dom);
setReleaseStatus(`releases unavailable — ${outcome.detail}`, "fault");
return;
}
if (outcome.releases.length === 0) {
clearBuckets(dom);
await emptyVerdict(id, ticket);
return;
}
render(outcome.releases);
}
/** Refetch verdicts only — the reload an override-written grab triggers. */
async function reloadReleases() {
const id = movieId;
if (id === null) {
return;
}
sequence += 1;
const ticket = sequence;
window.clearTimeout(pollTimer);
const outcome = await movieReleases(id);
if (ticket !== sequence || movieId !== id) {
return;
}
if (outcome.kind === "results" && !paintBuckets(dom, outcome.releases, actions)) {
clearBuckets(dom);
}
}
/**
* 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(id: number, ticket: number) {
const outcome = await movieSearchState(id);
if (ticket !== sequence || movieId !== id) {
return;
}
if (outcome.kind === "state" && outcome.movie.last_searched_at !== null) {
setReleaseStatus(
`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(id, null, ticket);
return;
}
setReleaseStatus("no releases indexed for this title — search indexers runs a sweep now");
}
/** Poll the movie until `last_searched_at` moves off `baseline`. */
function watchSweep(id: number, baseline: string | null, ticket: number) {
sweep.disabled = true;
setReleaseStatus("sweeping indexers…", undefined, true);
const deadline = Date.now() + SWEEP_WAIT_MS;
const tick = async () => {
if (ticket !== sequence || movieId !== id) {
return;
}
const outcome = await movieSearchState(id);
if (ticket !== sequence || movieId !== id) {
return;
}
if (outcome.kind === "state" && outcome.movie.last_searched_at !== baseline) {
sweep.disabled = false;
await loadReleases(id, ticket);
return;
}
if (Date.now() >= deadline) {
sweep.disabled = false;
setReleaseStatus(
"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);
}
/* ---- removal confirmation, unchanged from the deck ---- */
/** 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(
{
title: movie.title,
files: () => movieFiles(movie.id),
folder: libraryFolder,
remove: () => removeMovie(movie.id),
seedLine: "the torrent keeps seeding until its tracker rule clears.",
},
{
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();
}
});
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;
setReleaseStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
sequence += 1;
window.clearTimeout(pollTimer);
watchSweep(movie.id, baseline, sequence);
})();
});
/* ---- open, close, layers ---- */
async function load() {
const id = movieId;
if (id === null) {
return;
}
sequence += 1;
const ticket = sequence;
window.clearTimeout(pollTimer);
const [movie, filesOutcome, fetchedRoots] = await Promise.all([
fetchMovie(id),
movieFiles(id),
allRoots().catch(() => roots),
]);
if (ticket !== sequence || movieId !== id) {
return;
}
roots = fetchedRoots;
if (movie === null) {
setStatus("movie unavailable", "fault");
return;
}
current = movie;
clearRichDetail();
paintIdentity();
paintControls();
paintFiles(filesOutcome);
void loadMetadata(id, ticket);
await loadReleases(id, ticket);
}
async function open(id: number, from: HTMLElement, container: HTMLElement, parent: Route) {
// this page is one of `views`, so the loop below calls its own hide()
// and would wipe the state assigned after it — hide first, then take state
for (const sibling of views) {
sibling.hide();
}
movieId = id;
current = null;
origin = from;
returnTo = container;
parentRoute = parent;
deckEl.hidden = true;
view.hidden = false;
clearBuckets(dom);
clearRemove();
clearRichDetail();
filesSection.hidden = true;
diskRows.replaceChildren();
sweep.disabled = false;
back.focus();
await load();
}
function hide() {
view.hidden = true;
movieId = null;
current = null;
sequence += 1;
window.clearTimeout(pollTimer);
clearRemove();
}
function close() {
const target = origin;
navigate(parentRoute);
hide();
if (returnTo) {
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 page
if (removeWrap.hidden) {
close();
} else {
closeRemove();
}
},
true,
);
return {
hide,
open,
setRemoved: (handler: (parent: Route) => void) => {
removed = handler;
},
};
}
interface RemoveActions {
cancel: () => void;
removed: () => void;
}
/** The slice of a file the removal panel reads: evidence, nothing else. */
interface RemoveFile {
path: string;
size: number;
}
/**
* What the panel needs to know about a title (issue 175): a movie and a
* series differ only in which endpoints they call, how their files roll up
* to one folder, and how many torrents the §7.3 warning speaks of.
*/
interface RemoveSubject {
title: string;
files: () => Promise<{ kind: "files"; files: RemoveFile[] } | { kind: "error"; detail: string }>;
/** The one folder the delete takes, when the files agree on it. */
folder: (files: RemoveFile[]) => string | null;
remove: () => Promise<ActionOutcome>;
/** What does not happen: seeding continues under its own rule (§7.3). */
seedLine: string;
}
/**
* 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:
* seeding continues under its own rule (§7.3).
*/
function removePanel(subject: RemoveSubject, actions: RemoveActions): HTMLElement {
const panel = document.createElement("div");
panel.className = "remove-body";
panel.setAttribute("role", "group");
panel.setAttribute("aria-label", `remove ${subject.title}`);
let files: RemoveFile[] | null = null;
let folderNamed = false;
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 = `${folderNamed ? "deletes the folder above." : "deletes the files above."} ${subject.seedLine}`;
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 subject.remove().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 subject.files().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 = subject.folder(files);
folderNamed = folder !== null;
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 where the rule
* that failed has an override (§9.3). */
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 — and
// where a rule can be waived, the count says so rather than leaving the
// way out folded inside a collapsed bucket.
const waivable = releases.filter(overridable).length;
const none = document.createElement("li");
none.className = "rel rel-none readout dim";
none.textContent =
waivable > 0
? `none — every candidate was waived or rejected by policy; ${waivable} can be waived`
: "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") {
// A rejected row always names its rule; a waived one cannot — the
// `releases` CHECK allows `rejected_rule` only on a rejection. Say
// `waived` plainly rather than calling a classified row unclassified.
const verdict = release.rejected_rule
? `${bucket} · ${ruleLabel(release.rejected_rule)}`
: bucket;
line.append(
chip(verdict, (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;
// A rejected row is normally inert, but §9.3's one click is exactly for
// the rule the operator disagrees with: where an override exists, the row
// offers it and the grab that follows stays a waiver.
const writesOverride = bucket !== "eligible" && overridable(release);
if (bucket !== "rejected" || writesOverride) {
const grab = document.createElement("button");
grab.type = "button";
grab.className = "control rel-grab";
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;
}
/* ---- §9.6 poster grid (#151) -------------------------------------------- */
type LibraryShape = "grid" | "list";
const LIBRARY_SHAPE_KEY = "arr.libraryView";
/**
* The grid is default and the choice is per-person — no server state. A
* corrupt or foreign value falls back to the grid rather than breaking open.
*/
function loadLibraryShape(): LibraryShape {
try {
const stored = window.localStorage.getItem(LIBRARY_SHAPE_KEY);
return stored === "list" ? "list" : "grid";
} catch {
return "grid";
}
}
function libraryMain(
movieDetail: MovieView,
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 gridBtn = must<HTMLButtonElement>("#library-view-grid");
const listBtn = must<HTMLButtonElement>("#library-view-list");
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;
// §9.6 library view: the poster grid is default, per-person, localStorage
let shape = loadLibraryShape();
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 setShape(next: LibraryShape) {
shape = next;
try {
window.localStorage.setItem(LIBRARY_SHAPE_KEY, next);
} catch {
// private mode or full storage: the choice just does not persist
}
gridBtn.setAttribute("aria-pressed", String(shape === "grid"));
listBtn.setAttribute("aria-pressed", String(shape === "list"));
render();
}
gridBtn.addEventListener("click", () => setShape("grid"));
listBtn.addEventListener("click", () => setShape("list"));
gridBtn.setAttribute("aria-pressed", String(shape === "grid"));
listBtn.setAttribute("aria-pressed", String(shape === "list"));
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.className = shape === "grid" ? "deck-rows grid-rows" : "deck-rows";
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);
const openSeries = (id: number, origin: HTMLElement) => {
navigate({ kind: "series", seriesId: id });
void seriesDetail.open(id, origin, view, { kind: "library" });
};
const openMovie = (movie: LibraryMovie, origin: HTMLElement) => {
navigate({ kind: "movie", movieId: movie.id });
void movieDetail.open(movie.id, origin, view, { kind: "library" });
};
if (shape === "grid") {
renderGroup(groups.series, seriesShown, (series) => seriesCard(series, roots, openSeries));
renderGroup(groups.movies, moviesShown, (movie) => movieCard(movie, roots, openMovie));
} else {
renderGroup(groups.series, seriesShown, (series) => seriesRow(series, roots, openSeries));
renderGroup(groups.movies, moviesShown, (movie) => libraryRow(movie, roots, openMovie));
}
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() {
for (const sibling of views) {
sibling.hide();
}
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;
}
// §9.7: the library is the homepage — the bottom layer. There is nothing
// beneath it, so the nav control never toggles off and Esc has no handler.
nav.addEventListener("click", () => {
if (view.hidden) {
navigate({ kind: "library" });
open();
}
});
toggle.addEventListener("click", () => {
showAll = !showAll;
render();
});
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(
countsChip(
`${series.available_episodes}/${series.wanted_episodes} eps`,
series.available_episodes,
series.wanted_episodes,
`${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;
}
/**
* One §9.6 grid tile (#151): poster at w342, title and year beneath, then
* the chips the row view carries — a card is legible before it is pretty.
* A missing poster becomes a same-ratio placeholder carrying its title,
* so the grid never reflows and never has a hole in it.
*/
function libraryCard(
posterPath: string | null,
title: string,
year: number | null,
chips: HTMLSpanElement[],
open: (origin: HTMLElement) => void,
): HTMLLIElement {
const item = document.createElement("li");
const card = document.createElement("button");
card.type = "button";
card.className = "card";
const art = document.createElement("span");
art.className = "card-art";
if (posterPath === null) {
const blank = document.createElement("span");
blank.className = "card-blank";
const blankTitle = document.createElement("span");
blankTitle.className = "card-blank-title";
blankTitle.textContent = title;
blank.append(blankTitle);
art.append(blank);
} else {
const img = document.createElement("img");
img.className = "card-poster";
img.loading = "lazy";
img.src = tmdbImage(posterPath, "w342") ?? "";
img.alt = `${title} poster`;
// hotlinked art can 404; fall back to the titled blank rather than a hole
img.addEventListener("error", () => {
const blankTitle = document.createElement("span");
blankTitle.className = "card-blank-title";
blankTitle.textContent = title;
const blank = document.createElement("span");
blank.className = "card-blank";
blank.append(blankTitle);
img.replaceWith(blank);
});
art.append(img);
}
const id = document.createElement("span");
id.className = "card-id";
const name = document.createElement("span");
name.className = "card-name";
name.textContent = title;
const when = document.createElement("span");
when.className = "card-year readout dim";
when.textContent = year === null ? "—" : String(year);
id.append(name, when);
const chipLine = document.createElement("span");
chipLine.className = "card-chips";
for (const chipEl of chips) {
chipLine.append(chipEl);
}
const body = document.createElement("span");
body.className = "card-body";
body.append(id, chipLine);
card.append(art, body);
card.addEventListener("click", () => {
open(card);
});
item.append(card);
return item;
}
function seriesCard(
series: LibrarySeries,
roots: Root[],
open: (id: number, origin: HTMLElement) => void,
): HTMLLIElement {
const root = roots.find((candidate) => candidate.id === series.root_id);
const chips = [chip(root ? root.audience : `root ${series.root_id}`)];
if (series.wanted_episodes > 0) {
chips.push(
countsChip(
`${series.available_episodes}/${series.wanted_episodes} eps`,
series.available_episodes,
series.wanted_episodes,
`${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`,
),
);
}
if (series.blocked) {
chips.push(chip("blocked"));
}
chips.push(
chip(series.status, (span) => {
span.dataset.status = series.status;
}),
);
const rating = ratingChip(series.vote_average);
if (rating !== null) {
chips.push(rating);
}
return libraryCard(series.poster_path, series.title, series.year, chips, (origin) =>
open(series.id, origin),
);
}
function movieCard(
movie: LibraryMovie,
roots: Root[],
open: (movie: LibraryMovie, origin: HTMLElement) => void,
): HTMLLIElement {
const root = roots.find((candidate) => candidate.id === movie.root_id);
const chips = [chip(root ? root.audience : `root ${movie.root_id}`)];
chips.push(mediaStateChip(movie.state, movie.wanted));
// §5.7 honesty: a waived import is never presented as a clean match
const waiver = waiverLabel(movie.waiver);
if (waiver !== null) {
chips.push(
chip(waiver, (span) => {
span.dataset.verdict = "waived";
}),
);
}
if (!movie.wanted) {
chips.push(chip("not wanted"));
}
if (movie.blocked) {
chips.push(chip("blocked"));
}
const rating = ratingChip(movie.vote_average);
if (rating !== null) {
chips.push(rating);
}
return libraryCard(movie.poster_path, movie.title, movie.year, chips, (origin) =>
open(movie, origin),
);
}
/* ---- 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;
}
delete statusEl.dataset.action;
}
/**
* A status that carries its own way out. A season held off the pack lane
* cannot be helped by the head's re-search alone (#181, #182), so the
* sentence that explains the wait also offers the retry that ends it.
*/
function setStatusAction(text: string, label: string, run: () => void) {
const action = document.createElement("button");
action.type = "button";
action.className = "control";
action.textContent = label;
action.addEventListener("click", run);
statusEl.hidden = false;
statusEl.replaceChildren(document.createTextNode(text), action);
delete statusEl.dataset.tone;
statusEl.dataset.action = "";
}
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 !== "eligible") {
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(sweepIfEmpty = false) {
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)) {
clearBuckets(dom);
await emptyVerdict(current, ticket, sweepIfEmpty);
return;
}
setStatus(null);
}
/**
* An empty season deck is three different truths (#182), and until the
* season could say which, it blamed backoff for all three: a pack sweep
* still running, a pack sweep that ran and found nothing, and a season on
* the per-episode lane, where no pack sweep is coming at all. The lane
* answers the third; `last_pack_search_at` separates the first two, the
* same way a movie's `last_searched_at` does (#177).
*/
async function emptyVerdict(current: TvDeckRequest, ticket: number, sweepIfEmpty: boolean) {
const outcome = await current.target.packState?.();
if (ticket !== sequence || request !== current) {
return;
}
if (outcome?.kind === "state" && outcome.state.lane === "per_episode") {
sweep.disabled = false;
describePerEpisode(current, outcome.state);
return;
}
if (outcome?.kind === "state" && outcome.state.last_pack_search_at !== null) {
sweep.disabled = false;
setStatus(
`no season pack found — indexers last swept ${formatSweepAge(outcome.state.last_pack_search_at)}; re-search runs a new one`,
);
return;
}
// issue 167: an empty deck sweeps on open instead of describing a sweep
if (sweepIfEmpty) {
void startSweep(current, outcome?.kind === "state" ? outcome.state : null);
return;
}
setStatus("no releases indexed yet — re-search queues a targeted sweep");
}
/**
* Name the lane instead of the backoff. A season grabbing episode by
* episode has no pack to show and never will while the reason holds, so
* the deck says which reason it is and where the releases actually are.
*/
function describePerEpisode(current: TvDeckRequest, state: SeasonPackState) {
const elsewhere = "open an episode for its releases";
if (state.reason === "no_episodes") {
setStatus(
"no episodes known for this season yet — a metadata refresh has to find them before anything can be searched",
);
return;
}
if (state.reason === "still_airing") {
setStatus(
`season still airing — a pack is only searched once every episode has aired, so this one is grabbed episode by episode; ${elsewhere}`,
);
return;
}
if (state.reason === "episodes_on_disk") {
setStatus(
`episodes already on disk — a pack would re-import them, so the rest is grabbed episode by episode; ${elsewhere}`,
);
return;
}
const failures =
state.pack_failures === 1 ? "1 failed pack grab" : `${state.pack_failures} failed pack grabs`;
const quiet =
state.pack_retry_at === null
? "pack search is quiet until its backoff elapses"
: `pack search is quiet for another ${formatRetryWait(state.pack_retry_at)}, then retries on its own`;
setStatusAction(
`${failures}${quiet}. episodes are grabbed one at a time meanwhile.`,
"retry the pack now",
() => {
void startSweep(current, state);
},
);
}
/**
* A sweep is done when its releases appear, or when the season stamps the
* pack search it just finished. `baseline` is that stamp as it read before
* the sweep was queued: once it moves, an empty deck is a settled answer
* rather than a pending one, and `load` says so.
*/
function watchSweep(current: TvDeckRequest, baseline: string | null, 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;
}
const state = await current.target.packState?.();
if (ticket !== sequence || request !== current) {
return;
}
if (state?.kind === "state" && state.state.last_pack_search_at !== baseline) {
sweep.disabled = false;
await emptyVerdict(current, ticket, false);
return;
}
if (Date.now() >= deadline) {
sweep.disabled = false;
setStatus(
"sweep has not landed yet — nothing has come back from the indexers; results appear here when it does",
);
return;
}
pollTimer = window.setTimeout(() => {
void tick();
}, TV_SWEEP_POLL_MS);
};
pollTimer = window.setTimeout(() => {
void tick();
}, TV_SWEEP_POLL_MS);
}
/**
* Queue a targeted sweep, then watch for it to land (§6.2). `known` is the
* lane as it read a moment ago, so the watch is only entered when a pack
* sweep is actually expected: on the per-episode lane the sweep searches
* episodes, and waiting for a pack that is not coming is the lie #182 is
* about. A failed pack is the exception — a manual search waives its
* window and does try a pack (#181).
*/
async function startSweep(current: TvDeckRequest, known?: SeasonPackState | null) {
sweep.disabled = true;
const before =
known === undefined
? await current.target.packState?.().then((it) => (it.kind === "state" ? it.state : null))
: known;
if (request !== current) {
return;
}
const outcome = await current.target.search();
if (request !== current) {
return;
}
sequence += 1;
window.clearTimeout(pollTimer);
if (outcome.kind === "error") {
sweep.disabled = false;
setStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
if (before && before.lane === "per_episode" && before.reason !== "pack_backoff") {
sweep.disabled = false;
setStatus("searching the season's episodes — open an episode for its releases");
return;
}
watchSweep(current, before?.last_pack_search_at ?? null, sequence);
}
sweep.addEventListener("click", () => {
const current = request;
if (!current) {
return;
}
void startSweep(current);
});
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(true);
}
function hide() {
view.hidden = true;
request = null;
sequence += 1;
window.clearTimeout(pollTimer);
}
function close() {
const current = request;
navigate(current?.parentRoute ?? { kind: "library" });
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>;
/** Where to land once the title is gone — same contract as a movie. */
setRemoved: (handler: (parent: Route) => void) => void;
}
const PAD_TWO = (value: number): string => String(value).padStart(2, "0");
/**
* Issue #177: the on-demand refresh a new series' add sends (#176) lands
* within seconds. Poll modestly and give up bounded so a page left open
* does not spin forever.
*/
const SERIES_REFRESH_POLL_MS = 2000;
const SERIES_REFRESH_WAIT_MS = 30_000;
function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const view = must<HTMLElement>("#series");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#series-back");
const hero = must<HTMLElement>("#series-hero");
const poster = must<HTMLImageElement>("#series-poster");
const titleEl = must<HTMLElement>("#series-title");
const yearEl = must<HTMLElement>("#series-year");
const chipsEl = must<HTMLElement>("#series-chips");
const ratingEl = must<HTMLElement>("#series-rating");
const metaEl = must<HTMLElement>("#series-meta");
const taglineEl = must<HTMLElement>("#series-tagline");
const overviewEl = must<HTMLElement>("#series-overview");
const actionsEl = must<HTMLElement>("#series-actions");
const statusEl = must<HTMLElement>("#series-status");
const seasonsList = must<HTMLUListElement>("#rows-seasons");
const remove = must<HTMLButtonElement>("#series-remove");
const removeWrap = must<HTMLElement>("#series-remove-panel");
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;
let removed: ((parent: Route) => void) | null = null;
let refreshPollTimer: number | undefined;
// set once the bounded poll for a pending metadata refresh times out
let refreshGaveUp = false;
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;
}
}
/* ---- removal confirmation: the movie panel, generalised (issue 175) ---- */
/** 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 current = series;
const id = seriesId;
if (!current || id === null) {
return;
}
const panel = removePanel(
{
title: current.title,
files: () => fetchSeriesFiles(id),
folder: seriesFolder,
remove: () => removeSeries(id),
seedLine: "torrents keep seeding until their tracker rules clear.",
},
{
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 (same as a movie)
panel.querySelector<HTMLElement>(".remove-actions .control:last-child")?.focus();
}
remove.addEventListener("click", () => {
if (removeWrap.hidden) {
openRemove();
} else {
closeRemove();
}
});
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(
countsChip(
`${current.available_episodes}/${current.wanted_episodes} eps`,
current.available_episodes,
current.wanted_episodes,
`${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"));
}
}
/* ---- §9.6 rich detail: one request, images hotlinked ---- */
function clearRichDetail() {
hero.classList.remove("has-backdrop");
hero.style.removeProperty("--backdrop");
poster.hidden = true;
poster.removeAttribute("src");
ratingEl.hidden = true;
ratingEl.replaceChildren();
metaEl.hidden = true;
taglineEl.hidden = true;
overviewEl.hidden = true;
actionsEl.replaceChildren();
}
async function loadMetadata(id: number, ticket: number) {
const outcome = await seriesMetadata(id);
if (ticket !== sequence || seriesId !== id) {
return;
}
if (outcome.kind === "error") {
// the operational header stands; the actions row says why the rest is absent
const note = document.createElement("p");
note.className = "add-note readout";
note.dataset.tone = "fault";
note.textContent = `metadata unavailable — ${outcome.detail}`;
actionsEl.append(note);
return;
}
paintMetadata(outcome.metadata);
}
/**
* What the show IS (#150): art, rating, overview above the season tree.
* The operational chips — audience, counts, derived status — stay from
* [`paintHeader`] and outrank all of it.
*/
function paintMetadata(detail: SeriesMetadata) {
const posterUrl = tmdbImage(detail.poster_path, "w342");
if (posterUrl !== null && series !== null) {
poster.src = posterUrl;
poster.alt = `${series.title} poster`;
poster.hidden = false;
}
const backdropUrl = tmdbImage(detail.backdrop_path, "w1280");
if (backdropUrl !== null) {
hero.classList.add("has-backdrop");
hero.style.setProperty("--backdrop", `url("${backdropUrl}")`);
}
if (detail.vote_average !== null) {
ratingEl.hidden = false;
ratingEl.append(starIcon(), document.createTextNode(`${formatRating(detail.vote_average)} `));
const votes = document.createElement("span");
votes.className = "dim";
votes.textContent = `(${formatVoteCount(detail.vote_count)})`;
ratingEl.append(votes);
}
const metaLine = formatMetaLine(detail.runtime, detail.genres);
if (metaLine !== "") {
metaEl.hidden = false;
metaEl.textContent = metaLine;
}
if (detail.tagline !== null && detail.tagline !== "") {
taglineEl.hidden = false;
taglineEl.textContent = `${detail.tagline}`;
}
if (detail.overview !== null && detail.overview !== "") {
overviewEl.hidden = false;
overviewEl.textContent = detail.overview;
}
// the trailer resolves from this response's own key — no second call.
// Hidden when absent rather than shown dead (§9.6).
if (detail.trailer !== null) {
const trailer = document.createElement("a");
trailer.className = "control";
trailer.href = youtubeLink(detail.trailer.youtube_key);
trailer.target = "_blank";
trailer.rel = "noreferrer";
trailer.textContent = "trailer";
actionsEl.append(trailer);
}
actionsEl.append(externalLink("tmdb", tmdbSeriesLink(detail.tmdb_id)));
// a series has no imdb_id in this app; TVDB is its second id (§9.6)
if (detail.tvdb_id !== null) {
actionsEl.append(externalLink("tvdb", tvdbLink(detail.tvdb_id)));
}
if (series !== null) {
actionsEl.append(
externalLink("rotten tomatoes", rottenTomatoesSearch(series.title, series.year)),
);
}
}
function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
const counts = seasonCounts(season);
return countsChip(
`${counts.available}/${counts.wanted} on disk`,
counts.available,
counts.wanted,
`${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 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, deckBtn);
// #174: files go, episodes stop being wanted, the season stays listed.
// Only offered with files on disk — intent alone is the tracked toggle.
if (season.episodes.some((episode) => filesByEpisode.has(episode.id))) {
const clear = armedDelete("remove files", () => {
const currentId = seriesId;
if (currentId === null) {
return;
}
void removeSeasonFiles(currentId, season.number).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
return;
}
// the control itself disappears with the files; the tracked
// toggle is the season's control that survives the repaint
focusKey = `track-${season.number}`;
void load();
});
});
clear.setAttribute(
"aria-label",
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
);
line.append(clear);
}
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(mediaStateChip(episode.state, episode.wanted));
// 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) {
// #174: the file goes and the episode stops being wanted; the row
// stays listed. Same arm-then-confirm as a settings row.
const clear = armedDelete("remove file", () => {
void removeEpisodeFiles(episode.id).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
return;
}
// once missing, the row's want control is what remains to focus
focusKey = `want-${episode.id}`;
void load();
});
});
clear.setAttribute(
"aria-label",
`remove the ${episodeTag(seasonNumber, episode.number)} file from disk and stop wanting the episode — it stays listed`,
);
actions.append(clear);
} 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 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;
}
/**
* A never-refreshed series (`metadata_refreshed_at` null) reads as a
* refresh under way, since #176 queues one on add and it lands within
* seconds. A refreshed series with no seasons is a settled, if rare,
* empty state upstream — the two must not share a message (#177).
*/
function seasonsEmptyRow(): HTMLLIElement {
const item = document.createElement("li");
item.className = "rel rel-none readout dim";
if (series !== null && series.metadata_refreshed_at === null) {
if (refreshGaveUp) {
item.append(document.createTextNode("metadata refresh has not landed yet — "));
const retry = document.createElement("button");
retry.type = "button";
retry.className = "control control-quiet";
retry.textContent = "retry";
retry.addEventListener("click", () => {
retry.disabled = true;
void retryRefresh();
});
item.append(retry);
} else {
const lamp = document.createElement("span");
lamp.className = "lamp";
lamp.dataset.state = "probing";
item.append(
lamp,
document.createTextNode(" refreshing metadata — seasons will appear here"),
);
}
} else {
item.textContent = "no seasons listed upstream";
}
return item;
}
function renderSeasons() {
seasonsList.replaceChildren();
if (!seasons) {
return;
}
for (const season of seasons) {
seasonsList.append(seasonRow(season));
}
if (seasons.length === 0) {
seasonsList.append(seasonsEmptyRow());
}
}
/**
* Poll while a refresh is pending, painting seasons as soon as they land
* with no manual reload. Stops on arrival, on the refresh being recorded
* done with none, or once `SERIES_REFRESH_WAIT_MS` passes — a page left
* open must not poll forever (#177).
*/
function syncRefreshWatch(ticket: number) {
window.clearTimeout(refreshPollTimer);
const id = seriesId;
if (id === null || series === null || seasons === null) {
return;
}
const pending = series.metadata_refreshed_at === null && seasons.length === 0;
if (!pending || refreshGaveUp) {
return;
}
const deadline = Date.now() + SERIES_REFRESH_WAIT_MS;
const tick = async () => {
if (ticket !== sequence || seriesId !== id) {
return;
}
const [detail, seasonsOutcome] = await Promise.all([fetchSeries(id), fetchSeasons(id)]);
if (ticket !== sequence || seriesId !== id) {
return;
}
if (detail && seasonsOutcome.kind === "seasons") {
series = detail;
seasons = seasonsOutcome.seasons;
paintHeader();
if (series.metadata_refreshed_at !== null || seasons.length > 0) {
renderSeasons();
return;
}
}
if (Date.now() >= deadline) {
refreshGaveUp = true;
renderSeasons();
return;
}
refreshPollTimer = window.setTimeout(() => {
void tick();
}, SERIES_REFRESH_POLL_MS);
};
refreshPollTimer = window.setTimeout(() => {
void tick();
}, SERIES_REFRESH_POLL_MS);
}
/** The empty state's retry control: resend the on-demand command by hand. */
async function retryRefresh() {
const id = seriesId;
if (id === null) {
return;
}
const ticket = sequence;
refreshGaveUp = false;
renderSeasons();
const outcome = await refreshSeriesMetadata(id);
if (ticket !== sequence || seriesId !== id) {
return;
}
if (outcome.kind === "error") {
refreshGaveUp = true;
setStatus(`refresh failed — ${outcome.detail}`, "fault");
renderSeasons();
return;
}
syncRefreshWatch(ticket);
}
async function load() {
const id = seriesId;
if (id === null) {
return;
}
sequence += 1;
const ticket = sequence;
window.clearTimeout(refreshPollTimer);
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();
clearRichDetail();
renderSeasons();
syncRefreshWatch(ticket);
setStatus(null);
void loadMetadata(id, ticket);
if (focusKey !== null) {
view.querySelector<HTMLElement>(`[data-focus-id="${focusKey}"]`)?.focus();
focusKey = null;
}
}
async function open(id: number, from: HTMLElement, container: HTMLElement, parent: Route) {
// this page is one of `views`, so the loop below calls its own hide(),
// which nulls seriesId — hide first, then take state (same as a movie)
tvDeck.hide();
for (const sibling of views) {
sibling.hide();
}
seriesId = id;
origin = from;
returnTo = container;
parentRoute = parent;
expanded = new Set();
focusKey = null;
refreshGaveUp = false;
deckEl.hidden = true;
view.hidden = false;
clearRemove();
clearRichDetail();
back.focus();
await load();
}
function hide() {
view.hidden = true;
seriesId = null;
seasons = null;
sequence += 1;
window.clearTimeout(refreshPollTimer);
clearRemove();
clearRichDetail();
}
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();
// the confirmation is the innermost layer: Esc abandons it first
if (!removeWrap.hidden) {
closeRemove();
return;
}
navigate(parentRoute);
close();
}
},
true,
);
return {
hide,
open,
setRemoved: (handler: (parent: Route) => void) => {
removed = handler;
},
};
}
/* ---- 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(movieDetail: MovieView, views: HideableView[], goHome: () => void): 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: "movie", movieId: movie.id });
void movieDetail.open(movie.id, 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() {
for (const sibling of views) {
sibling.hide();
}
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();
goHome();
nav.focus();
}
nav.addEventListener("click", () => {
if (view.hidden) {
navigate({ kind: "queues" });
open();
} else {
navigate({ kind: "library" });
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: "library" });
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 = "open";
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();