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 = { 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 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); } 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("#nav-library"), must("#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("#nav-library"), must("#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("#nav-library"), must("#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("#series-back"), returnTo: must("#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("#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. 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(".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(".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, ): 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(".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("#releases"); const deck = must("#deck"); const back = must("#releases-back"); const title = must("#releases-title"); const year = must("#releases-year"); const sweep = must("#releases-sweep"); const remove = must("#releases-remove"); const removeWrap = must("#remove-panel"); const status = must("#releases-status"); const eligible: BucketsDom["eligible"] = { section: must("#bucket-eligible"), count: must("#count-eligible"), rows: must("#rows-eligible"), }; const collapsed = { waived: { section: must("#bucket-waived"), toggle: must("#toggle-waived"), wrap: must("#wrap-waived"), rows: must("#rows-waived"), }, rejected: { section: must("#bucket-rejected"), toggle: must("#toggle-rejected"), wrap: must("#wrap-rejected"), rows: must("#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(".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; 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; } /** 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("#library"); const deck = must("#deck"); const nav = must("#nav-library"); const summary = must("#library-summary"); const toggle = must("#library-toggle"); const status = must("#library-status"); const groups: { series: LibraryGroup; movies: LibraryGroup } = { series: { section: must("#library-series"), count: must("#count-lib-series"), rows: must("#rows-lib-series"), }, movies: { section: must("#library-movies"), count: must("#count-lib-movies"), rows: must("#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(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("#tv-releases"); const back = must("#tv-releases-back"); const title = must("#tv-releases-title"); const sub = must("#tv-releases-sub"); const sweep = must("#tv-releases-sweep"); const statusEl = must("#tv-releases-status"); const dom = buildBucketDom(must("#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; } const PAD_TWO = (value: number): string => String(value).padStart(2, "0"); function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableView[]): SeriesView { const view = must("#series"); const deckEl = must("#deck"); const back = must("#series-back"); const titleEl = must("#series-title"); const yearEl = must("#series-year"); const chipsEl = must("#series-chips"); const statusEl = must("#series-status"); const seasonsList = must("#rows-seasons"); let roots: Root[] = []; let series: ApiSeries | null = null; let seasons: ApiSeason[] | null = null; let filesByEpisode = new Map(); // which seasons stand open survives the refetch every action triggers let expanded = new Set(); 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(`#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(`[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; } interface QueueGroup { section: HTMLElement; count: HTMLElement; rows: HTMLUListElement; } function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableView[]): QueuesView { const view = must("#queues"); const deck = must("#deck"); const nav = must("#nav-queues"); const badge = must("#queues-count"); const summary = must("#queues-summary"); const status = must("#queues-status"); const groups: { noPt: QueueGroup; decision: QueueGroup } = { noPt: { section: must("#queue-no-pt"), count: must("#count-no-pt"), rows: must("#rows-no-pt"), }, decision: { section: must("#queue-decision"), count: must("#count-decision"), rows: must("#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();