Files
arr/web/src/main.ts
T
Miguel Palhas a780b49ab3 feat(web): missing-subtitles queue alongside no-pt-source
A third lane in the attention queues view: one row per movie or
series with a gap chip per language and why (#186's reasons, plus a
sync alass flagged). Reuses the existing deck-group markup and
missingChipLabel wording so it reads like its two siblings; each
row opens the title's own page, where the #203 fetch/translate
panel already carries the resolving actions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 03:11:34 +01:00

5038 lines
159 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,
fetchSubtitleQueue,
type MovieSubtitleGaps,
type SeriesAttention,
type SeriesSubtitleGaps,
type SubtitleQueue,
subtitleQueueTotal,
} from "./queues";
import {
type ActionOutcome,
bucketOf,
type FilesOutcome,
formatAudio,
formatHdr,
formatResolution,
formatRetryWait,
formatScore,
formatSeeders,
formatSize,
formatSource,
formatSweepAge,
grabRelease,
libraryFolder,
type MovieRelease,
movieFiles,
movieReleases,
movieSearchState,
probedAttributeTags,
queueSearch,
removeMovie,
ruleLabel,
type SeasonPackState,
sweepExpected,
totalSize,
type WaiveOutcome,
waiveAndGrab,
waiverOverride,
} 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";
import {
type EpisodeSubtitleStatus,
formatCandidateFlags,
formatDownloads,
formatHashMatch,
formatReleaseMatch,
formatUploaderRating,
grabSubtitle,
languageChoices,
type MissingSubtitle,
missingChipLabel,
movieSubtitleStatus,
type Subtitle,
type SubtitleCandidate,
type SubtitleOptions,
type SubtitleSearchResults,
type SubtitleStatus,
searchSubtitles,
seriesSubtitleStatus,
subtitleChipLabel,
subtitleOptions,
subtitleRuleLabel,
translateSubtitle,
translationSources,
} from "./subtitles";
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, seriesDetail, 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);
});
}
/* ---- subtitle status (§15, §9.6, issue #201) --------------------------- */
/**
* One present-subtitle chip. Green like an eligible release (the want is
* met); a forced track never satisfies a want (§15) so it stays neutral
* instead. `data-mt` marks a machine translation with the same dashed
* border a waived release already carries — a translation is a relaxed
* case of "has a subtitle", not the clean one, and §15 wants it readable at
* a glance rather than by hovering for the origin word.
*/
function subtitleChip(subtitle: Subtitle): HTMLSpanElement {
return chip(subtitleChipLabel(subtitle), (span) => {
if (!subtitle.forced) {
span.dataset.verdict = "eligible";
}
if (subtitle.origin === "translated") {
span.dataset.mt = "true";
}
});
}
/** One missing-language chip: the same amber "wanted, not yet on disk" ramp §4.2 already uses. */
function missingSubtitleChip(missing: MissingSubtitle): HTMLSpanElement {
return chip(missingChipLabel(missing), (span) => {
span.dataset.movieState = "missing";
span.dataset.wanted = "true";
if (missing.reason === "failed" && missing.detail !== null) {
span.title = missing.detail;
}
});
}
/* ---- manual subtitle deck (§9.3, §15, issue #203) ---------------------- */
/**
* The subtitle status row for one media file and the manual panel it opens.
* `update` repaints the chips after a manual fetch or translation changed
* what is on disk, without rebuilding the panel underneath — an operator
* mid-search does not lose the search.
*/
interface SubtitleSection {
line: HTMLElement;
panel: HTMLElement;
update: (status: SubtitleStatus) => void;
}
/**
* The candidate deck's columns: §9.3's fixed widths carrying the facts
* §15's ranking actually decides on, in ranking order — a `moviehash` match
* wins outright, then the release name, then rating and download count as
* tiebreakers.
*/
const SUBTITLE_COLUMNS = [
["cw-prov", "provider"],
["cw-hash", "hash"],
["cw-match", "release"],
["cw-rate", "rating"],
["cw-dl", "downloads"],
["cw-flag", "flags"],
] as const;
function subtitleColhead(): HTMLElement {
const head = document.createElement("div");
head.className = "colhead";
head.setAttribute("aria-hidden", "true");
for (const [width, label] of SUBTITLE_COLUMNS) {
const cell = document.createElement("span");
cell.className = `cw ${width}`;
cell.textContent = label;
head.append(cell);
}
return head;
}
/**
* §9.3's bucket structure with the middle bucket left out: `arr_core::subs`
* has no `waived` verdict, because nothing about a subtitle is worth
* overriding by hand.
*/
interface SubtitleBucketsDom {
root: HTMLElement;
eligible: { section: HTMLElement; count: HTMLElement; rows: HTMLUListElement };
rejected: CollapsedBucketDom;
}
function buildSubtitleBuckets(): SubtitleBucketsDom {
const root = document.createElement("div");
root.className = "subs-buckets";
root.hidden = true;
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("h4");
eligibleName.className = "deck-label";
eligibleName.textContent = "eligible";
const eligibleCount = document.createElement("span");
eligibleCount.className = "deck-count readout";
eligibleHead.append(eligibleName, eligibleCount);
const eligibleRows = document.createElement("ul");
eligibleRows.className = "deck-rows";
eligibleSection.append(eligibleHead, subtitleColhead(), eligibleRows);
const rejectedSection = document.createElement("section");
rejectedSection.className = "deck-group";
rejectedSection.hidden = true;
const rejectedHead = document.createElement("header");
rejectedHead.className = "deck-head bucket-head";
const rejectedName = document.createElement("h4");
rejectedName.className = "deck-label";
rejectedName.textContent = "rejected";
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "bucket-toggle readout";
toggle.setAttribute("aria-expanded", "false");
rejectedHead.append(rejectedName, toggle);
const wrap = document.createElement("div");
wrap.hidden = true;
const rejectedRows = document.createElement("ul");
rejectedRows.className = "deck-rows";
wrap.append(subtitleColhead(), rejectedRows);
rejectedSection.append(rejectedHead, wrap);
const rejected: CollapsedBucketDom = {
section: rejectedSection,
toggle,
wrap,
rows: rejectedRows,
};
wireCollapsedToggle(rejected);
root.append(eligibleSection, rejectedSection);
return {
root,
eligible: { section: eligibleSection, count: eligibleCount, rows: eligibleRows },
rejected,
};
}
/** A note line that carries one sentence of state, in the deck's own voice. */
function subtitleNote(): HTMLParagraphElement {
const note = document.createElement("p");
note.className = "rel-note subs-note";
note.setAttribute("role", "status");
return note;
}
function say(note: HTMLElement, text: string, tone?: "fault") {
note.textContent = text;
if (tone === undefined) {
delete note.dataset.tone;
} else {
note.dataset.tone = tone;
}
}
/**
* A language picker as pressed controls rather than a select: three or four
* options, all worth seeing at once, and the same affordance the root picker
* and the library view toggles already use.
*/
interface LanguagePicker {
element: HTMLElement;
selected: () => string | null;
/** Bars one language — the translate lane cannot target its own source. */
bar: (language: string | null) => void;
paint: (languages: string[]) => void;
}
function languagePicker(label: string): LanguagePicker {
const group = document.createElement("div");
group.className = "subs-langs";
group.setAttribute("role", "group");
group.setAttribute("aria-label", label);
let choice: string | null = null;
let barred: string | null = null;
let buttons: HTMLButtonElement[] = [];
function repaint() {
for (const button of buttons) {
const language = button.dataset.language ?? "";
button.setAttribute("aria-pressed", String(language === choice));
button.disabled = language === barred;
}
}
function paint(languages: string[]) {
buttons = languages.map((language) => {
const button = document.createElement("button");
button.type = "button";
button.className = "control control-quiet";
button.dataset.language = language;
button.textContent = language;
button.addEventListener("click", () => {
choice = language;
repaint();
});
return button;
});
choice = languages.find((language) => language !== barred) ?? null;
group.replaceChildren(...buttons);
repaint();
}
return {
element: group,
selected: () => choice,
bar: (language) => {
barred = language;
if (choice === barred) {
choice = buttons.map((b) => b.dataset.language ?? "").find((l) => l !== barred) ?? null;
}
repaint();
},
paint,
};
}
/** A labelled select, the same field vocabulary `/settings` already uses. */
function subtitleField(
label: string,
narrow = false,
): { element: HTMLElement; select: HTMLSelectElement } {
const field = document.createElement("label");
field.className = narrow ? "field subs-field subs-field-narrow" : "field subs-field";
const caption = document.createElement("span");
caption.className = "field-label readout dim";
caption.textContent = label;
const select = document.createElement("select");
select.className = "form-input";
field.append(caption, select);
return { element: field, select };
}
/**
* One candidate row: chips lead, the release name is secondary evidence
* (§9.3). A `moviehash` match is the only chip that earns the eligible
* green — it is the one fact that wins outright (§15), and colouring the
* tiebreakers too would flatten the ranking the row is trying to show.
*/
function candidateRow(
candidate: SubtitleCandidate,
bucket: "eligible" | "rejected",
fetchCandidate: (candidate: SubtitleCandidate, note: HTMLElement) => Promise<boolean>,
): HTMLLIElement {
const item = document.createElement("li");
item.className = "rel";
const line = document.createElement("div");
line.className = "rel-line";
const columns: [string, string, string][] = [
["cw-prov", candidate.provider, `offered by ${candidate.provider}`],
[
"cw-hash",
formatHashMatch(candidate),
candidate.hash_match
? "matched to this exact file by moviehash"
: "no moviehash match against this file",
],
[
"cw-match",
formatReleaseMatch(candidate),
candidate.release_match
? "same release name as the file on disk"
: "a different release name from the file on disk",
],
[
"cw-rate",
formatUploaderRating(candidate.rating),
candidate.rating === null
? "no uploader rating from this provider"
: `uploader rating ${formatUploaderRating(candidate.rating)} out of 10`,
],
[
"cw-dl",
formatDownloads(candidate.download_count),
candidate.download_count === null
? "no download count from this provider"
: `downloaded ${candidate.download_count} times`,
],
[
"cw-flag",
formatCandidateFlags(candidate),
formatCandidateFlags(candidate) === "—"
? "a plain subtitle, neither SDH nor forced"
: `flagged ${formatCandidateFlags(candidate)}`,
],
];
for (const [width, value, description] of columns) {
line.append(
chip(value, (span) => {
span.classList.add("cw", width);
span.setAttribute("aria-label", description);
span.title = description;
if (value === "—") {
span.classList.add("dim");
}
if (width === "cw-hash" && candidate.hash_match) {
span.dataset.verdict = "eligible";
}
}),
);
}
if (bucket === "rejected") {
line.append(
chip(`rejected · ${subtitleRuleLabel(candidate.rejected_rule)}`, (span) => {
span.dataset.verdict = "rejected";
}),
);
}
const name = document.createElement("span");
name.className = "rel-name readout";
if (candidate.release_name === null) {
name.classList.add("dim");
name.textContent = "no release name given";
} else {
name.textContent = candidate.release_name;
name.title = candidate.release_name;
}
line.append(name);
item.append(line);
const note = subtitleNote();
note.hidden = true;
if (bucket === "eligible") {
const grab = document.createElement("button");
grab.type = "button";
grab.className = "control rel-grab";
grab.textContent = "grab";
grab.addEventListener("click", () => {
grab.disabled = true;
note.hidden = false;
void fetchCandidate(candidate, note).then((ok) => {
grab.disabled = ok;
});
});
item.append(grab);
}
item.append(note);
return item;
}
/**
* The manual subtitle surface for one media file (§9.3, §15): a fetch lane
* over the providers and a translate lane over what is already on the file.
* It is an inline panel rather than a route or a modal — the decision it
* supports is about one file, and the file's own row is where that decision
* is made.
*/
function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleSection {
const mediaFileId = status.media_file_id;
let subtitles = status.subtitles;
let missing = status.missing;
const line = document.createElement("div");
line.className = "rel-line subtitle-line";
const panel = document.createElement("div");
panel.className = "subs-panel";
panel.id = `subs-panel-${mediaFileId}`;
panel.hidden = true;
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "control control-quiet subs-toggle";
toggle.textContent = "subtitles";
toggle.setAttribute("aria-expanded", "false");
toggle.setAttribute("aria-controls", panel.id);
function paintChips() {
line.replaceChildren();
for (const subtitle of subtitles) {
line.append(subtitleChip(subtitle));
}
for (const gap of missing) {
line.append(missingSubtitleChip(gap));
}
line.append(toggle);
}
/* ---- fetch lane ---- */
const fetchLane = document.createElement("section");
fetchLane.className = "deck-group subs-lane";
const fetchHead = document.createElement("header");
fetchHead.className = "deck-head";
const fetchLabel = document.createElement("h3");
fetchLabel.className = "deck-label";
fetchLabel.textContent = "fetch";
const fetchCount = document.createElement("span");
fetchCount.className = "deck-count readout";
fetchHead.append(fetchLabel, fetchCount);
const searchLanguages = languagePicker("language to search for");
const searchButton = document.createElement("button");
searchButton.type = "button";
searchButton.className = "control";
searchButton.textContent = "search";
const searchControls = document.createElement("div");
searchControls.className = "subs-controls";
searchControls.append(searchLanguages.element, searchButton);
const searchNote = subtitleNote();
say(searchNote, "nothing is asked of a provider until you search.");
const buckets = buildSubtitleBuckets();
fetchLane.append(fetchHead, searchControls, searchNote, buckets.root);
/* ---- translate lane ---- */
const translateLane = document.createElement("section");
translateLane.className = "deck-group subs-lane";
const translateHead = document.createElement("header");
translateHead.className = "deck-head";
const translateLabel = document.createElement("h3");
translateLabel.className = "deck-label";
translateLabel.textContent = "translate";
translateHead.append(translateLabel);
const source = subtitleField("source");
const targetLanguages = languagePicker("language to translate into");
const engine = subtitleField("engine", true);
const translateButton = document.createElement("button");
translateButton.type = "button";
translateButton.className = "control";
translateButton.textContent = "translate";
const translateControls = document.createElement("div");
translateControls.className = "subs-controls";
translateControls.append(
source.element,
targetLanguages.element,
engine.element,
translateButton,
);
const translateHint = subtitleNote();
const translateNote = subtitleNote();
translateNote.hidden = true;
translateLane.append(translateHead, translateControls, translateHint, translateNote);
panel.append(fetchLane, translateLane);
/** Keeps the target picker off the source's own language — a same-language
* translation is refused by the API and is never what was meant. */
function paintSources() {
const sources = translationSources(subtitles);
source.select.replaceChildren(
...sources.map((subtitle) => {
const option = document.createElement("option");
option.value = String(subtitle.id);
option.textContent = subtitleChipLabel(subtitle);
return option;
}),
);
const empty = sources.length === 0;
source.select.disabled = empty;
translateButton.disabled = empty || engine.select.disabled;
if (empty) {
const option = document.createElement("option");
option.textContent = "nothing to translate from";
source.select.append(option);
say(
translateHint,
"no subtitle with text on this file yet — fetch one above, or wait for an embedded text track to be extracted. An image-based track carries bitmaps, not text, and is never a source.",
);
} else if (translateHint.dataset.tone === undefined) {
say(
translateHint,
"any subtitle already on the file is a legal source — a fetched one, an extracted embedded track, even another machine translation.",
);
}
targetLanguages.bar(
sources.find((s) => String(s.id) === source.select.value)?.language ?? null,
);
}
source.select.addEventListener("change", () => paintSources());
let optionsLoaded = false;
async function loadOptions() {
if (optionsLoaded) {
return;
}
optionsLoaded = true;
const outcome = await subtitleOptions();
const options: SubtitleOptions =
outcome.kind === "options"
? outcome.options
: { wanted_languages: [], translation_engine: null, available_engines: [] };
const languages = languageChoices(options.wanted_languages);
searchLanguages.paint(languages);
targetLanguages.paint(languages);
engine.select.replaceChildren(
...options.available_engines.map((name) => {
const option = document.createElement("option");
option.value = name;
option.textContent = name;
return option;
}),
);
if (options.available_engines.length === 0) {
const option = document.createElement("option");
option.textContent = "none compiled in";
engine.select.append(option);
engine.select.disabled = true;
} else if (
options.translation_engine !== null &&
options.available_engines.includes(options.translation_engine)
) {
engine.select.value = options.translation_engine;
}
paintSources();
if (outcome.kind === "error") {
say(
translateHint,
`subtitle settings unreadable — ${outcome.detail}. The language choices fall back to Portuguese and English.`,
"fault",
);
} else if (options.available_engines.length === 0) {
say(
translateHint,
"no translation engine is compiled into this binary — each backend is its own cargo feature.",
"fault",
);
}
}
/** One click grabs, syncs and writes (§15) — then the chips repaint. */
async function fetchCandidate(candidate: SubtitleCandidate, note: HTMLElement): Promise<boolean> {
say(note, "fetching, syncing, writing…");
const outcome = await grabSubtitle(mediaFileId, candidate);
if (outcome.kind === "error") {
const stale = /not found/i.test(outcome.detail)
? " — search again, a candidate id does not outlive its search"
: "";
say(note, `grab failed — ${outcome.detail}${stale}`, "fault");
return false;
}
say(
note,
outcome.subtitle.sync === "rejected"
? "written — alass was implausible, so the unsynced original was kept and the file is flagged"
: "fetched, synced and written",
);
refresh();
return true;
}
function paintCandidates(results: SubtitleSearchResults) {
buckets.root.hidden = false;
buckets.eligible.rows.replaceChildren();
buckets.rejected.rows.replaceChildren();
buckets.rejected.wrap.hidden = true;
const eligible = results.candidates.filter((c) => c.verdict === "eligible");
const rejected = results.candidates.filter((c) => c.verdict !== "eligible");
buckets.eligible.section.hidden = false;
buckets.eligible.count.textContent = String(eligible.length);
if (eligible.length === 0) {
const none = document.createElement("li");
none.className = "rel rel-none readout dim";
none.textContent = "none — every candidate names the rule that rejected it below";
buckets.eligible.rows.append(none);
}
for (const candidate of eligible) {
buckets.eligible.rows.append(candidateRow(candidate, "eligible", fetchCandidate));
}
buckets.rejected.section.hidden = rejected.length === 0;
for (const candidate of rejected) {
buckets.rejected.rows.append(candidateRow(candidate, "rejected", fetchCandidate));
}
if (rejected.length > 0) {
setToggle(buckets.rejected, rejected.length);
}
}
/** Providers that could not answer are named, so "no candidates" and
* "nobody could be asked" never read the same. */
function searchSummary(results: SubtitleSearchResults, language: string): string {
const failures = results.provider_errors
.map((failure) => `${failure.provider} could not answer — ${failure.error}`)
.join(" · ");
if (results.candidates.length === 0) {
const nothing = `no ${language} candidate from any provider that answered`;
return failures === "" ? nothing : `${nothing} · ${failures}`;
}
return failures === "" ? "" : failures;
}
searchButton.addEventListener("click", () => {
const language = searchLanguages.selected();
if (language === null) {
return;
}
searchButton.disabled = true;
fetchCount.textContent = "";
say(searchNote, `searching providers for ${language}`);
void searchSubtitles(mediaFileId, language).then((outcome) => {
searchButton.disabled = false;
if (outcome.kind === "error") {
buckets.root.hidden = true;
say(searchNote, `search failed — ${outcome.detail}`, "fault");
return;
}
paintCandidates(outcome.results);
fetchCount.textContent = String(outcome.results.candidates.length);
const summary = searchSummary(outcome.results, language);
say(searchNote, summary, outcome.results.provider_errors.length > 0 ? "fault" : undefined);
searchNote.hidden = summary === "";
});
});
translateButton.addEventListener("click", () => {
const target = targetLanguages.selected();
const sourceId = Number(source.select.value);
if (target === null || !Number.isFinite(sourceId) || source.select.disabled) {
return;
}
translateButton.disabled = true;
translateNote.hidden = false;
say(translateNote, `translating into ${target}`);
void translateSubtitle(mediaFileId, {
source_subtitle_id: sourceId,
target_language: target,
engine: engine.select.value,
}).then((outcome) => {
translateButton.disabled = false;
translateNote.hidden = false;
if (outcome.kind === "error") {
say(translateNote, `translation failed — ${outcome.detail}`, "fault");
return;
}
say(translateNote, `written next to the video as a machine translation into ${target}`);
refresh();
});
});
toggle.addEventListener("click", () => {
panel.hidden = !panel.hidden;
toggle.setAttribute("aria-expanded", String(!panel.hidden));
if (!panel.hidden) {
void loadOptions();
}
});
paintChips();
return {
line,
panel,
update: (next: SubtitleStatus) => {
subtitles = next.subtitles;
missing = next.missing;
paintChips();
paintSources();
},
};
}
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 subtitlesByFile = new Map<number, SubtitleStatus>();
const subtitleSections = new Map<number, SubtitleSection>();
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 === "waived") {
return waiveAndGrab(movie.id, release.id, release.rejected_rule);
}
const outcome = await grabRelease(movie.id, release.id);
return outcome.kind === "done"
? { kind: "done", overrideWritten: false }
: { kind: "error", detail: outcome.detail, overrideWritten: false };
},
};
// guards a stale fetch from painting over a newer view
let sequence = 0;
let pollTimer: number | undefined;
// §6.2: the sweep runs on the daemon's own cadence. Poll modestly, and
// stop promising once the backoff window is plausibly in charge.
const SWEEP_POLL_MS = 5000;
const SWEEP_WAIT_MS = 150_000;
function setStatus(text: string | null, tone?: "fault", busy = false) {
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) ---- */
/**
* Re-read this movie's subtitle status after a manual fetch or
* translation, and repaint the chips in place. The panels stay mounted,
* so an operator who grabbed from a search still has the search.
*/
function refreshSubtitles() {
const id = movieId;
if (id === null) {
return;
}
void movieSubtitleStatus(id).then((outcome) => {
if (outcome.kind === "error" || movieId !== id) {
return;
}
subtitlesByFile = new Map(outcome.statuses.map((status) => [status.media_file_id, status]));
for (const status of outcome.statuses) {
subtitleSections.get(status.media_file_id)?.update(status);
}
});
}
function paintFiles(outcome: FilesOutcome) {
subtitleSections.clear();
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);
const status = subtitlesByFile.get(file.id);
if (status !== undefined) {
const section = subtitleSection(status, refreshSubtitles);
subtitleSections.set(status.media_file_id, section);
item.append(section.line, section.panel);
}
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, subtitleOutcome, fetchedRoots] = await Promise.all([
fetchMovie(id),
movieFiles(id),
movieSubtitleStatus(id),
allRoots().catch(() => roots),
]);
if (ticket !== sequence || movieId !== id) {
return;
}
roots = fetchedRoots;
if (movie === null) {
setStatus("movie unavailable", "fault");
return;
}
current = movie;
subtitlesByFile = new Map(
subtitleOutcome.kind === "status"
? subtitleOutcome.statuses.map((status) => [status.media_file_id, status])
: [],
);
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 on a waived row. */
grab: (
release: MovieRelease,
bucket: "eligible" | "waived" | "rejected",
) => Promise<WaiveOutcome>;
}
/** Stops in the score heat scale — keep in step with the --score-* tokens. */
const SCORE_STOPS = 6;
type ScoreStopper = (score: number | null) => number | null;
/**
* Score colour is relative to one list (issue 111): highest green, lowest
* red, seven interpolated stops between.
*/
function scoreStopper(releases: MovieRelease[]): ScoreStopper {
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const release of releases) {
if (release.score !== null) {
if (release.score < min) {
min = release.score;
}
if (release.score > max) {
max = release.score;
}
}
}
return (score: number | null): number | null => {
if (score === null || min > max) {
return null;
}
// all-equal scores are jointly the best the list offers
return max === min ? SCORE_STOPS : Math.round(((score - min) / (max - min)) * SCORE_STOPS);
};
}
/**
* Splits classified releases into the three buckets and paints them. The
* API pre-sorts by bucket then score; splitting preserves order. Returns
* false when there was nothing to paint — the caller owns the empty state,
* which differs per deck.
*/
function paintBuckets(dom: BucketsDom, releases: MovieRelease[], actions: ReleaseActions): boolean {
clearBuckets(dom);
if (releases.length === 0) {
return false;
}
const scoreStop = scoreStopper(releases);
const buckets = {
eligible: releases.filter((release) => bucketOf(release) === "eligible"),
waived: releases.filter((release) => bucketOf(release) === "waived"),
rejected: releases.filter((release) => bucketOf(release) === "rejected"),
};
dom.eligible.section.hidden = false;
dom.eligible.count.textContent = String(buckets.eligible.length);
if (buckets.eligible.length > 0) {
for (const release of buckets.eligible) {
dom.eligible.rows.append(releaseRow(release, "eligible", scoreStop, actions));
}
} else {
// §9.3: over-strict filters must be visible, not silently absent
const none = document.createElement("li");
none.className = "rel rel-none readout dim";
none.textContent = "none — every candidate was waived or rejected by policy";
dom.eligible.rows.append(none);
}
for (const name of ["waived", "rejected"] as const) {
const rows = buckets[name];
if (rows.length === 0) {
continue;
}
const bucket = dom[name];
bucket.section.hidden = false;
for (const release of rows) {
bucket.rows.append(releaseRow(release, name, scoreStop, actions));
}
setToggle(bucket, rows.length);
}
return true;
}
/** Builds the §9.3 bucket structure for a deck whose markup is dynamic. */
function buildBucketDom(root: HTMLElement): BucketsDom {
function colhead(): HTMLElement {
const head = document.createElement("div");
head.className = "colhead";
head.setAttribute("aria-hidden", "true");
for (const [width, label] of [
["cw-score", "score"],
["cw-res", "res"],
["cw-src", "source"],
["cw-hdr", "hdr"],
["cw-aud", "audio"],
["cw-size", "size"],
["cw-seed", "seed"],
] as const) {
const cell = document.createElement("span");
cell.className = `cw ${width}`;
cell.textContent = label;
head.append(cell);
}
return head;
}
function rows(): HTMLUListElement {
const list = document.createElement("ul");
list.className = "deck-rows";
return list;
}
const eligibleSection = document.createElement("section");
eligibleSection.className = "deck-group";
eligibleSection.hidden = true;
const eligibleHead = document.createElement("header");
eligibleHead.className = "deck-head";
const eligibleName = document.createElement("h3");
eligibleName.className = "deck-label";
eligibleName.textContent = "eligible";
const eligibleCount = document.createElement("span");
eligibleCount.className = "deck-count readout";
eligibleHead.append(eligibleName, eligibleCount);
const eligibleRows = rows();
eligibleSection.append(eligibleHead, colhead(), eligibleRows);
function collapsed(label: string): CollapsedBucketDom {
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "bucket-toggle readout";
toggle.setAttribute("aria-expanded", "false");
const sectionEl = document.createElement("section");
sectionEl.className = "deck-group";
sectionEl.hidden = true;
const head = document.createElement("header");
head.className = "deck-head bucket-head";
const name = document.createElement("h3");
name.className = "deck-label";
name.textContent = label;
head.append(name, toggle);
const wrap = document.createElement("div");
wrap.hidden = true;
const list = rows();
wrap.append(colhead(), list);
sectionEl.append(head, wrap);
const bucket: CollapsedBucketDom = { section: sectionEl, toggle, wrap, rows: list };
wireCollapsedToggle(bucket);
return bucket;
}
const dom: BucketsDom = {
eligible: { section: eligibleSection, count: eligibleCount, rows: eligibleRows },
waived: collapsed("waived"),
rejected: collapsed("rejected"),
};
root.append(dom.eligible.section, dom.waived.section, dom.rejected.section);
return dom;
}
/**
* One classified release: chips lead, the name is secondary (§9.3). The
* score chip is coloured on the list-relative heat scale (issue 111);
* nothing on the line is clickable — grab is its own control.
*/
function releaseRow(
release: MovieRelease,
bucket: "eligible" | "waived" | "rejected",
scoreStop: ScoreStopper,
actions: ReleaseActions,
): HTMLLIElement {
const item = document.createElement("li");
item.className = "rel";
const line = document.createElement("div");
line.className = "rel-line";
const stop = scoreStop(release.score);
const columns: [string, string, string][] = [
["cw-score", "score", formatScore(release.score)],
["cw-res", "resolution", formatResolution(release.parsed)],
["cw-src", "source", formatSource(release.parsed)],
["cw-hdr", "hdr", formatHdr(release.parsed)],
["cw-aud", "audio", formatAudio(release.parsed)],
["cw-size", "size", formatSize(release.size)],
["cw-seed", "seeders", formatSeeders(release.seeders)],
];
for (const [width, label, value] of columns) {
line.append(
chip(value, (span) => {
span.classList.add("cw", width);
span.setAttribute("aria-label", value === "—" ? `${label} unclaimed` : `${label} ${value}`);
if (value === "—") {
span.classList.add("dim");
}
// the heat scale replaces the flat eligible green on this cell
if (width === "cw-score" && stop !== null) {
span.dataset.score = String(stop);
}
}),
);
}
if (bucket !== "eligible") {
line.append(
chip(`${bucket} · ${ruleLabel(release.rejected_rule)}`, (span) => {
span.dataset.verdict = bucket;
}),
);
}
const name = document.createElement("span");
name.className = "rel-name readout";
name.textContent = release.name;
line.append(name);
item.append(line);
const note = document.createElement("span");
note.className = "rel-note readout";
note.setAttribute("role", "status");
note.hidden = true;
if (bucket !== "rejected") {
const grab = document.createElement("button");
grab.type = "button";
grab.className = "control rel-grab";
const writesOverride = bucket === "waived" && waiverOverride(release.rejected_rule) !== null;
grab.textContent = writesOverride ? "waive + grab" : "grab";
grab.addEventListener("click", () => {
grab.disabled = true;
note.hidden = false;
delete note.dataset.tone;
note.textContent = "grabbing…";
void actions.grab(release, bucket).then((outcome) => {
const text =
outcome.kind === "error"
? `${outcome.overrideWritten ? "override written · " : ""}grab failed — ${outcome.detail}`
: `${outcome.overrideWritten ? "override written · " : ""}grab sent`;
if (outcome.kind === "error") {
grab.disabled = false;
note.textContent = text;
note.dataset.tone = "fault";
} else {
note.textContent = text;
}
if (outcome.overrideWritten) {
// the override changed classification; reread the verdicts and
// keep the outcome visible past the re-render
void actions.reload().then(() => {
actions.notify(text, outcome.kind === "error" ? "fault" : undefined);
});
}
});
});
item.append(grab);
}
item.append(note);
return item;
}
/* ---- library view (§4.2, issue #32) ----------------------------------- */
interface LibraryView {
hide: () => void;
open: () => void;
}
interface LibraryGroup {
section: HTMLElement;
count: HTMLElement;
rows: HTMLUListElement;
}
/* ---- §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 === "waived") {
return waiveAndGrabTv(current.seriesId, current.target, release.id, release.rejected_rule);
}
return current.target
.grab(release.id)
.then((outcome) =>
outcome.kind === "done"
? { kind: "done" as const, overrideWritten: false }
: { kind: "error" as const, detail: outcome.detail, overrideWritten: false },
);
},
};
async function load(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>();
let subtitlesByEpisode = new Map<number, EpisodeSubtitleStatus>();
const subtitleSections = new Map<number, SubtitleSection>();
// 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);
const status = subtitlesByEpisode.get(episode.id);
if (status !== undefined) {
const section = subtitleSection(status, refreshSubtitles);
subtitleSections.set(status.media_file_id, section);
item.append(section.line, section.panel);
}
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;
}
/**
* Re-read the series' subtitle status after a manual fetch or translation
* and repaint the chips in place — one call for the whole series (§9.6),
* and the open panel survives it.
*/
function refreshSubtitles() {
const id = seriesId;
if (id === null) {
return;
}
void seriesSubtitleStatus(id).then((outcome) => {
if (outcome.kind === "error" || seriesId !== id) {
return;
}
subtitlesByEpisode = new Map(outcome.statuses.map((status) => [status.episode_id, status]));
for (const status of outcome.statuses) {
subtitleSections.get(status.media_file_id)?.update(status);
}
});
}
function renderSeasons() {
subtitleSections.clear();
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, subtitleOutcome, fetchedRoots] = await Promise.all(
[
fetchSeries(id),
fetchSeasons(id),
fetchSeriesFiles(id),
seriesSubtitleStatus(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])
: [],
);
subtitlesByEpisode = new Map(
subtitleOutcome.kind === "status"
? subtitleOutcome.statuses.map((status) => [status.episode_id, status])
: [],
);
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,
seriesDetail: SeriesView,
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; subtitles: 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"),
},
subtitles: {
section: must<HTMLElement>("#queue-subtitles"),
count: must<HTMLElement>("#count-subtitles"),
rows: must<HTMLUListElement>("#rows-subtitles"),
},
};
const allGroups = [groups.noPt, groups.decision, groups.subtitles];
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 allGroups) {
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 allGroups) {
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" });
};
const openMovie = (movieId: number, origin: HTMLElement) => {
navigate({ kind: "movie", movieId });
void movieDetail.open(movieId, origin, view, { kind: "queues" });
};
const openSeries = (seriesId: number, origin: HTMLElement) => {
navigate({ kind: "series", seriesId });
void seriesDetail.open(seriesId, 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, subtitles: SubtitleQueue | null) {
clearGroups();
// every section always renders, even empty — this is the one surface
// whose good news is an absence, and the operator visits to see it
for (const group of allGroups) {
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));
}
if (subtitles !== null) {
for (const movie of subtitles.movies) {
groups.subtitles.rows.append(movieSubtitleGapsRow(movie, openMovie));
}
for (const series of subtitles.series) {
groups.subtitles.rows.append(seriesSubtitleGapsRow(series, openSeries));
}
}
for (const group of allGroups) {
emptyLine(group);
}
paintCounts();
const nothingWaiting =
attentionTotal(queues) === 0 && (subtitles === null || subtitleQueueTotal(subtitles) === 0);
setStatus(nothingWaiting ? "nothing needs attention" : null);
}
async function load() {
sequence += 1;
const ticket = sequence;
setStatus("reading queues…");
const [outcome, subtitlesOutcome, fetchedRoots] = await Promise.all([
fetchAttention(),
fetchSubtitleQueue(),
allRoots().catch(() => roots),
]);
if (ticket !== sequence) {
return;
}
roots = fetchedRoots;
if (outcome.kind === "error") {
clearGroups();
setStatus(`queues unavailable — ${outcome.detail}`, "fault");
return;
}
render(outcome.queues, subtitlesOutcome.kind === "results" ? subtitlesOutcome.queue : null);
if (subtitlesOutcome.kind === "error") {
setStatus(`subtitle queue unavailable — ${subtitlesOutcome.detail}`, "fault");
}
}
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, subtitlesOutcome] = await Promise.all([fetchAttention(), fetchSubtitleQueue()]);
if (outcome.kind === "error") {
return;
}
const subtitlesTotal =
subtitlesOutcome.kind === "results" ? subtitleQueueTotal(subtitlesOutcome.queue) : 0;
paintBadge(attentionTotal(outcome.queues) + subtitlesTotal);
}
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;
}
/**
* One missing-subtitles entry for a movie: its title, plus one chip per gap
* — language and why (#202). The row opens the movie page, where the
* subtitle section already offers manual search, translate and retry
* (#203, §9.3).
*/
function movieSubtitleGapsRow(
movie: MovieSubtitleGaps,
open: (movieId: number, origin: HTMLElement) => void,
): HTMLLIElement {
const item = document.createElement("li");
const line = document.createElement("button");
line.type = "button";
line.className = "row row-tmdb queue-line";
const chips = document.createElement("span");
chips.className = "row-chips";
for (const gap of movie.gaps) {
chips.append(chip(missingChipLabel(gap)));
}
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.movie_id, line);
});
item.append(line);
return item;
}
/**
* One missing-subtitles entry for a series: one chip per episode gap,
* tagged `SxxEyy`, plus one per season row a uniform gap collapsed into
* (§9.5's restraint). Opens the series page, same reasoning as the movie row.
*/
function seriesSubtitleGapsRow(
series: SeriesSubtitleGaps,
open: (seriesId: number, origin: HTMLElement) => void,
): HTMLLIElement {
const item = document.createElement("li");
const line = document.createElement("button");
line.type = "button";
line.className = "row row-tmdb queue-line";
const chips = document.createElement("span");
chips.className = "row-chips";
for (const episode of series.episodes) {
const tag = episodeTag(episode.season_number, episode.episode_number);
for (const gap of episode.gaps) {
chips.append(chip(`${tag} ${missingChipLabel(gap)}`));
}
}
for (const season of series.seasons) {
const tag = seasonTag(season.season_number);
for (const gap of season.gaps) {
chips.append(chip(`${tag} ${missingChipLabel(gap)}`));
}
}
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "open";
chips.append(affordance);
line.append(rowTitle(series.title, series.year), chips);
line.addEventListener("click", () => {
open(series.series_id, line);
});
item.append(line);
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();