622 lines
18 KiB
TypeScript
622 lines
18 KiB
TypeScript
import { type CheckStatus, type Probe, probeHealth } from "./health";
|
|
import {
|
|
addMovie,
|
|
type LibraryMovie,
|
|
movieRoots,
|
|
parseManualInput,
|
|
type Root,
|
|
type SearchResponse,
|
|
searchTitles,
|
|
type TmdbMovie,
|
|
} from "./search";
|
|
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 roundtrip = must<HTMLElement>("#roundtrip");
|
|
const probeButton = must<HTMLButtonElement>("#probe");
|
|
|
|
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);
|
|
}
|
|
roundtrip.textContent = "rtt —";
|
|
return;
|
|
}
|
|
|
|
const { report, roundtripMs } = 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}`;
|
|
roundtrip.textContent = `rtt ${roundtripMs} ms`;
|
|
|
|
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;
|
|
|
|
async function probe() {
|
|
if (inFlight) {
|
|
return;
|
|
}
|
|
inFlight = true;
|
|
probeButton.disabled = true;
|
|
render(await probeHealth());
|
|
probeButton.disabled = false;
|
|
inFlight = false;
|
|
}
|
|
|
|
probeButton.addEventListener("click", () => {
|
|
void probe();
|
|
});
|
|
window.setInterval(() => {
|
|
void probe();
|
|
}, POLL_MS);
|
|
void probe();
|
|
|
|
searchMain(board);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function searchMain(board: HTMLElement) {
|
|
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. movieRoots() caches
|
|
// success, so a failed boot fetch is retried whenever an add panel opens.
|
|
let roots: Root[] = [];
|
|
const fetchRoots = () =>
|
|
movieRoots().then(
|
|
(fetched) => {
|
|
roots = fetched;
|
|
return fetched;
|
|
},
|
|
() => roots,
|
|
);
|
|
void fetchRoots();
|
|
|
|
let timer: number | undefined;
|
|
let controller: AbortController | null = null;
|
|
|
|
function showBoard() {
|
|
controller?.abort();
|
|
controller = null;
|
|
refs.deck.hidden = true;
|
|
board.hidden = false;
|
|
}
|
|
|
|
function showDeck() {
|
|
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;
|
|
}
|
|
|
|
const inLibrary = new Set(response.library.map((movie) => movie.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 response.library) {
|
|
refs.groups.library.rows.append(libraryRow(movie, roots));
|
|
}
|
|
}
|
|
if (response.tmdb.length > 0) {
|
|
refs.groups.tmdb.section.hidden = false;
|
|
refs.groups.tmdb.count.textContent = String(response.tmdb.length);
|
|
for (const movie of response.tmdb) {
|
|
refs.groups.tmdb.rows.append(tmdbRow(movie, inLibrary.has(movie.tmdb_id), fetchRoots));
|
|
}
|
|
}
|
|
}
|
|
|
|
input.addEventListener("input", () => {
|
|
window.clearTimeout(timer);
|
|
const query = input.value.trim();
|
|
if (query === "") {
|
|
showBoard();
|
|
clearGroups();
|
|
setStatus(null);
|
|
return;
|
|
}
|
|
timer = window.setTimeout(() => {
|
|
void run(query);
|
|
}, DEBOUNCE_MS);
|
|
});
|
|
|
|
input.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter") {
|
|
window.clearTimeout(timer);
|
|
const query = input.value.trim();
|
|
if (query !== "") {
|
|
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 = "";
|
|
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 = "";
|
|
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 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 libraryRow(movie: LibraryMovie, roots: Root[]): HTMLLIElement {
|
|
const item = document.createElement("li");
|
|
item.className = "row";
|
|
const chips = document.createElement("span");
|
|
chips.className = "row-chips";
|
|
const root = roots.find((candidate) => candidate.id === movie.root_id);
|
|
chips.append(chip(root ? root.audience : `root ${movie.root_id}`));
|
|
chips.append(
|
|
chip(movie.state, (span) => {
|
|
span.dataset.movieState = movie.state;
|
|
}),
|
|
);
|
|
if (!movie.wanted) {
|
|
chips.append(chip("not wanted"));
|
|
}
|
|
if (movie.blocked) {
|
|
chips.append(chip("blocked"));
|
|
}
|
|
item.append(rowTitle(movie.title, movie.year), chips);
|
|
return item;
|
|
}
|
|
|
|
function tmdbRow(
|
|
movie: TmdbMovie,
|
|
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(movie.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(movie.title, movie.year), chips);
|
|
if (movie.overview) {
|
|
const overview = document.createElement("span");
|
|
overview.className = "row-overview";
|
|
overview.textContent = movie.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(movie, 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. */
|
|
function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTMLElement {
|
|
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);
|
|
}
|
|
|
|
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;
|
|
note.textContent = "roots unavailable — daemon down or database missing";
|
|
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…";
|
|
void addMovie(movie, root.id).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"
|
|
: `added to ${root.audience} — wanted, search follows`;
|
|
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);
|
|
panel.append(options, actions);
|
|
return panel;
|
|
}
|
|
|
|
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();
|