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