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 = { 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(".lamp"); const status = root?.querySelector('[data-role="status"]'); const detail = root?.querySelector('[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(selector: string): T { const element = document.querySelector(selector); if (!element) { throw new Error(`board markup is missing ${selector}`); } return element; } function main() { const board = must(".board"); const masterLamp = must("#master-lamp"); const verdict = must("#master-verdict"); const version = must("#version"); const roundtrip = must("#roundtrip"); const probeButton = must("#probe"); const master = moduleRefs("master"); const checks = { tmdb: moduleRefs("tmdb"), prowlarr: moduleRefs("prowlarr"), transmission: moduleRefs("transmission"), } as const; const traces = { tmdb: document.querySelector('[data-trace="tmdb"]'), prowlarr: document.querySelector('[data-trace="prowlarr"]'), master: document.querySelector('[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("#search"); const hint = must("#search-hint"); const refs: DeckRefs = { deck: must("#deck"), status: must("#deck-status"), groups: { library: { section: must("#group-library"), count: must("#count-library"), rows: must("#rows-library"), }, tmdb: { section: must("#group-tmdb"), count: must("#count-tmdb"), rows: must("#rows-tmdb"), }, }, manualSection: must("#group-manual"), manualIntake: must("#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(".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(".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, ): 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(".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();