import { type CheckStatus, type Probe, probeHealth } from "./health"; import { fetchLibrary, type LibrarySeries, movieNeedsAttention, seriesNeedsAttention, waiverLabel, } from "./library"; import { fileName, formatMetaLine, formatRating, formatVoteCount, imdbLink, type MetadataCastMember, movieMetadata, resolveTrailer, rottenTomatoesSearch, tmdbImage, tmdbMovieLink, tmdbPersonLink, tmdbSeriesLink, tvdbLink, updateMovie, youtubeLink, } from "./movie"; import { type AttentionMovie, type AttentionQueues, allowEnglishAudio, attemptsLabel, attentionTotal, fetchAttention, type SeriesAttention, } from "./queues"; import { bucketOf, type FilesOutcome, formatAudio, formatHdr, formatResolution, formatScore, formatSeeders, formatSize, formatSource, formatSweepAge, grabRelease, libraryFolder, type MovieFile, type MovieRelease, movieFiles, movieReleases, movieSearchState, probedAttributeTags, 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, type SeriesMetadata, seasonCounts, seasonTarget, seriesMetadata, 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[] = []; // 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 movieDetail = movieMain(board, views); const library = libraryMain(board, movieDetail, seriesDetail, views); const queues = queuesMain(board, movieDetail, views); const settings = settingsMain(board, views); views.push(tvDeck, seriesDetail, movieDetail, library, queues, settings); const search = searchMain(board, movieDetail, seriesDetail, views); // a removed title must not survive on the surface the page opened over movieDetail.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 "movie": case "releases": { const movie = await fetchMovie(route.movieId); if (!movie) { navigate({ kind: "library" }, { replace: true }); library.open(); return; } // no origin click to restore focus to on a deep link — the library // rail button is the closest stand-in library.open(); await movieDetail.open( route.movieId, must("#nav-library"), must("#library"), { kind: "library", }, ); // `/movies/{id}/releases` keeps resolving (#149): same page, deck // section in view if (route.kind === "releases") { must("#movie-releases").scrollIntoView({ block: "start" }); } break; } case "series": { const detail = await fetchSeries(route.seriesId); if (!detail) { navigate({ kind: "library" }, { replace: true }); library.open(); return; } // the surface underneath must be live before the detail view covers // it, so back and Esc land somewhere real (same as a movie deep link) library.open(); await seriesDetail.open( route.seriesId, must("#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, movieDetail: MovieView, 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(), }); }; /** A movie hit opens its §9.6 page (#149); the deck section is on it. */ const openMovie = (movie: LibraryMovie, origin: HTMLElement) => { navigate({ kind: "movie", movieId: movie.id }); void movieDetail.open(movie.id, origin, refs.deck, { kind: "search", query: input.value.trim(), }); }; let timer: number | undefined; let controller: AbortController | null = null; function showBoard() { controller?.abort(); controller = null; for (const view of views) { view.hide(); } refs.deck.hidden = true; board.hidden = false; } function showDeck() { 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, openMovie)); } 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):not([aria-disabled="true"])', ); 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):not([aria-disabled="true"])', ), ]; const index = rows.indexOf(target); if (event.key === "ArrowUp" && index === 0) { input.focus(); return; } rows[index + (event.key === "ArrowDown" ? 1 : -1)]?.focus(); }); function showIdle() { input.value = ""; 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)}`; } /* ---- §9.2 rich-row parts (#148) ---------------------------------------- */ /** A search row's poster at w92, or a same-size blank so the edge never jitters. */ function rowPoster(posterPath: string | null, title: string): HTMLElement { if (posterPath === null) { const blank = document.createElement("span"); blank.className = "row-poster-blank"; blank.setAttribute("aria-hidden", "true"); return blank; } const img = document.createElement("img"); img.className = "row-poster"; img.loading = "lazy"; img.src = tmdbImage(posterPath, "w92") ?? ""; img.alt = `${title} poster`; // hotlinked art can 404; fall back to the blank rather than alt-text spill img.addEventListener("error", () => { const blank = document.createElement("span"); blank.className = "row-poster-blank"; blank.setAttribute("aria-hidden", "true"); img.replaceWith(blank); }); return img; } /** * TMDB's rating as a chip (#148). No votes means no chip — never a 0.0. * `vote_count` rides along as the title when the response carries one. */ function ratingChip(voteAverage: number | null, voteCount?: number): HTMLSpanElement | null { if (voteAverage === null) { return null; } const span = document.createElement("span"); span.className = "chip"; span.append(starIcon(), document.createTextNode(formatRating(voteAverage))); if (voteCount !== undefined) { span.title = `${formatVoteCount(voteCount)} votes`; } return span; } /** * The §9.6 trailer chip: one call, fired from the click and never before, * so a page of results costs zero trailer requests. The tab opens inside * the handler — an async `window.open` is what popup blockers eat — and is * navigated when the key arrives; no video closes it again quietly. */ function trailerChip(kind: "movie" | "tv", tmdbId: number): HTMLSpanElement { const chipEl = document.createElement("span"); chipEl.className = "chip row-trailer"; chipEl.textContent = "trailer"; chipEl.setAttribute("role", "button"); chipEl.tabIndex = 0; let busy = false; const resolve = () => { if (busy || chipEl.dataset.state !== undefined) { return; } busy = true; const tab = window.open("", "_blank"); chipEl.dataset.state = "pending"; chipEl.textContent = "trailer…"; void resolveTrailer(kind, tmdbId).then((outcome) => { busy = false; if (outcome.kind === "trailer") { if (tab) { tab.location.href = youtubeLink(outcome.youtubeKey); delete chipEl.dataset.state; chipEl.textContent = "trailer"; } else { // visibly blocked beats silently swallowed chipEl.dataset.state = "blocked"; chipEl.textContent = "popups blocked"; } return; } tab?.close(); if (outcome.kind === "none") { // TMDB having no video is an ordinary outcome, not a failure chipEl.dataset.state = "none"; chipEl.textContent = "no trailer"; chipEl.removeAttribute("role"); chipEl.removeAttribute("tabindex"); } else { delete chipEl.dataset.state; chipEl.title = outcome.detail; chipEl.textContent = "trailer"; } }); }; chipEl.addEventListener("click", (event) => { event.stopPropagation(); event.preventDefault(); resolve(); }); chipEl.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.stopPropagation(); event.preventDefault(); resolve(); } }); return chipEl; } /** Poster left, then title / chips / overview in a wrapping column (#148). */ function richRow(): { item: HTMLLIElement; row: HTMLButtonElement; body: HTMLElement; chips: HTMLElement; } { const item = document.createElement("li"); const row = document.createElement("button"); row.type = "button"; row.className = "row row-tmdb row-rich"; const body = document.createElement("span"); body.className = "row-main"; const chips = document.createElement("span"); chips.className = "row-chips"; return { item, row, body, chips }; } function episodeTag(seasonNumber: number, episodeNumber: number): string { return `${seasonTag(seasonNumber)}E${two(episodeNumber)}`; } function two(value: number): string { return value < 10 ? `0${value}` : String(value); } function libraryRow( movie: LibraryMovie, roots: Root[], open: (movie: LibraryMovie, origin: HTMLElement) => void, ): HTMLLIElement { const { item, row, body, chips } = richRow(); const root = roots.find((candidate) => candidate.id === movie.root_id); chips.append(chip(root ? root.audience : `root ${movie.root_id}`)); chips.append( 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 rating = ratingChip(movie.vote_average); if (rating !== null) { chips.append(rating); } chips.append(trailerChip("movie", movie.tmdb_id)); const affordance = document.createElement("span"); affordance.className = "row-add readout"; affordance.textContent = "open"; chips.append(affordance); body.append(rowTitle(movie.title, movie.year), chips); row.append(rowPoster(movie.poster_path, movie.title), body); row.addEventListener("click", () => { open(movie, row); }); item.append(row); return item; } /** * An in-library series hit: the whole row opens the series detail view, * which is where its seasons, episodes and decks live (issue 129). */ function librarySeriesRow( series: LibrarySeriesHit, roots: Root[], open: (id: number, origin: HTMLElement) => void, ): HTMLLIElement { const { item, row, body, chips } = richRow(); const root = roots.find((candidate) => candidate.id === series.root_id); chips.append(chip(root ? root.audience : `root ${series.root_id}`)); if (series.blocked) { chips.append(chip("blocked")); } const rating = ratingChip(series.vote_average); if (rating !== null) { chips.append(rating); } chips.append(trailerChip("tv", series.tmdb_id)); const affordance = document.createElement("span"); affordance.className = "row-add readout"; affordance.textContent = "episodes"; chips.append(affordance); body.append(rowTitle(series.title, series.year), chips); row.append(rowPoster(series.poster_path, series.title), body); row.addEventListener("click", () => { open(series.id, row); }); item.append(row); return item; } /** 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, row, body, chips } = richRow(); 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 rating = ratingChip(episode.vote_average); if (rating !== null) { chips.append(rating); } chips.append(trailerChip("tv", episode.series_tmdb_id)); const affordance = document.createElement("span"); affordance.className = "row-add readout"; affordance.textContent = "episodes"; chips.append(affordance); body.append(rowTitle(episode.series_title, null), chips); row.append(rowPoster(episode.poster_path, episode.series_title), body); 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, row, body, chips } = richRow(); chips.append(chip(hit.original_language)); if (inLibrary) { chips.append( chip("in library", (span) => { span.dataset.verdict = "eligible"; }), ); } else { const add = document.createElement("span"); add.className = "row-add readout"; add.textContent = "add"; chips.append(add); } const rating = ratingChip(hit.vote_average, hit.vote_count); if (rating !== null) { chips.append(rating); } chips.append(trailerChip(hit.kind === "series" ? "tv" : "movie", hit.tmdb_id)); body.append(rowTitle(hit.title, hit.year), chips); if (hit.overview) { const overview = document.createElement("span"); overview.className = "row-overview"; overview.textContent = hit.overview; body.append(overview); } row.append(rowPoster(hit.poster_path, hit.title), body); item.append(row); if (inLibrary) { // aria-disabled, not disabled: a dead button would also kill the // trailer chip's clicks, and that chip stays live on every row row.setAttribute("aria-disabled", "true"); return item; } row.setAttribute("aria-expanded", "false"); row.addEventListener("click", () => { const open = item.querySelector(".add-panel"); if (open) { open.remove(); row.setAttribute("aria-expanded", "false"); row.focus(); return; } void fetchRoots().then((available) => { if (item.querySelector(".add-panel")) { return; } const panel = addPanel(hit, available, row); item.append(panel); row.setAttribute("aria-expanded", "true"); panel.querySelector(".control")?.focus(); }); }); return item; } /** * The §9.2 add flow: inline, root and policy pre-filled, one confirm. A * series asks the one series-level question at add time — `auto_track` * (§4.1) — and states what it does in one plain line. */ function addPanel( title: TmdbMovie | TmdbSeries, available: Root[], row: HTMLButtonElement, ): HTMLElement { const isSeries = title.kind === "series"; const kind = isSeries ? "tv" : "movie"; const roots = available.filter((root) => root.kind === kind); const panel = document.createElement("div"); panel.className = "add-panel"; let selected = roots.find((root) => root.audience === "main") ?? roots[0] ?? null; const options = document.createElement("div"); options.className = "add-roots"; options.setAttribute("role", "group"); options.setAttribute("aria-label", "root folder"); const optionButtons: HTMLButtonElement[] = []; for (const root of roots) { const option = document.createElement("button"); option.type = "button"; option.className = "root-option"; option.setAttribute("aria-pressed", String(root === selected)); const audience = document.createElement("span"); audience.className = "root-audience"; audience.textContent = root.audience; const policy = document.createElement("span"); policy.className = "root-policy readout dim"; policy.textContent = root.policy_name; const path = document.createElement("span"); path.className = "root-path readout dim"; path.textContent = root.path; option.append(audience, policy, path); option.addEventListener("click", () => { selected = root; for (const other of optionButtons) { other.setAttribute("aria-pressed", String(other === option)); } }); optionButtons.push(option); options.append(option); } // §4.1: auto_track is a rule about seasons metadata reveals, not intent. // Future seasons are picked up by themselves; past seasons never are. let autoTrack = true; let autotrack: HTMLElement | null = null; if (isSeries) { const track = document.createElement("button"); track.type = "button"; track.className = "control control-quiet"; track.setAttribute("aria-pressed", "true"); const trackState = () => { track.textContent = `auto-track future seasons: ${autoTrack ? "on" : "off"}`; }; trackState(); track.addEventListener("click", () => { autoTrack = !autoTrack; track.setAttribute("aria-pressed", String(autoTrack)); trackState(); }); const trackNote = document.createElement("p"); trackNote.className = "add-note readout dim"; trackNote.textContent = "future seasons are tracked automatically; past seasons are not"; autotrack = document.createElement("div"); autotrack.className = "add-autotrack"; autotrack.append(track, trackNote); } const confirm = document.createElement("button"); confirm.type = "button"; confirm.className = "control"; confirm.textContent = "add to library"; const note = document.createElement("p"); note.className = "add-note readout"; note.setAttribute("role", "status"); note.hidden = true; if (roots.length === 0) { confirm.disabled = true; note.hidden = false; // a failed fetch and an absent kind are different problems note.textContent = available.length === 0 ? "roots unavailable — daemon down or database missing" : `no ${isSeries ? "tv" : "movie"} roots configured — create one under settings`; note.dataset.tone = "fault"; } confirm.addEventListener("click", () => { const root = selected; if (!root) { return; } confirm.disabled = true; note.hidden = false; delete note.dataset.tone; note.textContent = "adding…"; const adding = title.kind === "movie" ? addMovie(title, root.id) : addSeries(title, root.id, autoTrack); void adding.then((outcome) => { if (outcome.kind === "error") { confirm.disabled = false; note.textContent = `add failed — ${outcome.detail}`; note.dataset.tone = "fault"; return; } const already = outcome.kind === "conflict"; const done = document.createElement("p"); done.className = "add-done readout"; done.setAttribute("role", "status"); done.tabIndex = -1; done.textContent = already ? "already in library" : title.kind === "movie" ? `added to ${root.audience} — wanted, search follows` : `added to ${root.audience} — future seasons ${autoTrack ? "" : "not "}tracked automatically`; panel.replaceChildren(done); done.focus(); row.setAttribute("aria-disabled", "true"); row.setAttribute("aria-expanded", "true"); row.querySelector(".row-add")?.replaceWith( chip("in library", (span) => { span.dataset.verdict = "eligible"; }), ); }); }); const actions = document.createElement("div"); actions.className = "add-actions"; actions.append(confirm, note); if (autotrack) { panel.append(options, autotrack, actions); } else { panel.append(options, actions); } return panel; } /* ---- movie page (§9.6, issue #149) ------------------------------------ */ interface MovieView { hide: () => void; /** * Opens `/movies/{id}` over `returnTo`, which back and Esc restore; * `parentRoute` is where they navigate. The deep link passes the library * surface, exactly as a series deep link does. */ open: ( movieId: number, origin: HTMLElement, returnTo: HTMLElement, parentRoute: Route, ) => Promise; /** * Called with the page's parent route once a title is removed, so the * surface underneath repaints instead of listing something that is gone. */ setRemoved: (handler: (parent: Route) => void) => void; } /** The eligible bucket always shows; waived and rejected collapse to counts. */ interface CollapsedBucketDom { section: HTMLElement; toggle: HTMLButtonElement; wrap: HTMLElement; rows: HTMLUListElement; } /** * The §9.3 three-bucket structure. The movie deck builds it from static * markup in index.html; the tv deck builds it with [`buildBucketDom`] — * both paint through [`paintBuckets`]. */ interface BucketsDom { eligible: { section: HTMLElement; count: HTMLElement; rows: HTMLUListElement }; waived: CollapsedBucketDom; rejected: CollapsedBucketDom; } function clearBuckets(dom: BucketsDom) { dom.eligible.section.hidden = true; dom.eligible.rows.replaceChildren(); for (const bucket of [dom.waived, dom.rejected]) { bucket.section.hidden = true; bucket.wrap.hidden = true; bucket.toggle.setAttribute("aria-expanded", "false"); bucket.rows.replaceChildren(); } } function setToggle(bucket: CollapsedBucketDom, count: number) { const open = !bucket.wrap.hidden; bucket.toggle.textContent = open ? `hide ${count}` : `show ${count}`; bucket.toggle.setAttribute("aria-expanded", String(open)); } function wireCollapsedToggle(bucket: CollapsedBucketDom) { bucket.toggle.addEventListener("click", () => { bucket.wrap.hidden = !bucket.wrap.hidden; setToggle(bucket, bucket.rows.childElementCount); }); } /* ---- §9.6 painters shared by both detail pages ------------------------- */ /** The drawn star before a TMDB rating — no glyph standing in for an icon. */ function starIcon(): SVGSVGElement { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 12 12"); svg.setAttribute("class", "star"); svg.setAttribute("aria-hidden", "true"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute( "d", "M6 0.8 L7.5 4.2 L11.2 4.6 L8.4 7.1 L9.2 10.8 L6 8.9 L2.8 10.8 L3.6 7.1 L0.8 4.6 L4.5 4.2 Z", ); svg.append(path); return svg; } /** External links read as quiet controls; they leave the app entirely. */ function externalLink(label: string, href: string): HTMLAnchorElement { const link = document.createElement("a"); link.className = "control control-quiet"; link.href = href; link.target = "_blank"; link.rel = "noreferrer"; link.textContent = label; return link; } /** One top-billed cast member, linking out to their TMDB page (§9.6). */ function castItem(member: MetadataCastMember): HTMLLIElement { const item = document.createElement("li"); item.className = "cast-item"; const link = document.createElement("a"); link.className = "cast-link"; link.href = tmdbPersonLink(member.tmdb_id); link.target = "_blank"; link.rel = "noreferrer"; const photo = document.createElement("span"); photo.className = "cast-photo"; const image = tmdbImage(member.profile_path, "w185"); if (image !== null) { const img = document.createElement("img"); img.src = image; img.alt = ""; img.loading = "lazy"; photo.append(img); } else { photo.classList.add("cast-photo-none"); } const name = document.createElement("span"); name.className = "cast-name"; name.textContent = member.name; const character = document.createElement("span"); character.className = "cast-character readout dim"; character.textContent = member.character; link.setAttribute("aria-label", `${member.name} as ${member.character} — on TMDB`); link.append(photo, name, character); link.title = `${member.name} — ${member.character}`; item.append(link); return item; } function movieMain(board: HTMLElement, views: HideableView[]): MovieView { const view = must("#movie"); const deckEl = must("#deck"); const back = must("#movie-back"); const statusEl = must("#movie-status"); const hero = must("#movie-hero"); const poster = must("#movie-poster"); const titleEl = must("#movie-title"); const yearEl = must("#movie-year"); const chipsEl = must("#movie-chips"); const ratingEl = must("#movie-rating"); const metaEl = must("#movie-meta"); const taglineEl = must("#movie-tagline"); const overviewEl = must("#movie-overview"); const actionsEl = must("#movie-actions"); const wanted = must("#movie-wanted"); const blocked = must("#movie-blocked"); const rootSelect = must("#movie-root"); const sweep = must("#movie-sweep"); const remove = must("#movie-remove"); const removeWrap = must("#remove-panel"); const castSection = must("#movie-cast"); const castRows = must("#rows-cast"); const filesSection = must("#movie-files"); const diskCount = must("#count-disk"); const diskRows = must("#rows-disk"); const releaseStatus = 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 movieId: number | null = null; let current: LibraryMovie | null = null; let roots: Root[] = []; let removed: ((parent: Route) => void) | null = null; let origin: HTMLElement | null = null; let returnTo: HTMLElement | null = null; let parentRoute: Route = { kind: "board" }; const dom: BucketsDom = { eligible, waived: collapsed.waived, rejected: collapsed.rejected }; const actions: ReleaseActions = { reload: () => reloadReleases(), notify: (text, tone) => setReleaseStatus(text, tone), grab: async (release, bucket) => { const movie = current; if (!movie) { return { kind: "error", detail: "page closed", overrideWritten: false }; } if (bucket === "waived") { return waiveAndGrab(movie.id, release.id, release.rejected_rule); } const outcome = await grabRelease(movie.id, release.id); return outcome.kind === "done" ? { kind: "done", overrideWritten: false } : { kind: "error", detail: outcome.detail, overrideWritten: false }; }, }; // guards a stale fetch from painting over a newer view let sequence = 0; let pollTimer: number | undefined; // §6.2: the sweep runs on the daemon's own cadence. Poll modestly, and // stop promising once the backoff window is plausibly in charge. const SWEEP_POLL_MS = 5000; const SWEEP_WAIT_MS = 150_000; function setStatus(text: string | null, tone?: "fault", busy = false) { statusEl.hidden = text === null; if (busy && text !== null) { const lamp = document.createElement("span"); lamp.className = "lamp"; lamp.dataset.state = "probing"; statusEl.replaceChildren(lamp, document.createTextNode(text)); } else { statusEl.textContent = text ?? ""; } if (tone) { statusEl.dataset.tone = tone; } else { delete statusEl.dataset.tone; } } /** The releases section speaks beside its own buckets, not from the page top. */ function setReleaseStatus(text: string | null, tone?: "fault", busy = false) { releaseStatus.hidden = text === null; if (busy && text !== null) { const lamp = document.createElement("span"); lamp.className = "lamp"; lamp.dataset.state = "probing"; releaseStatus.replaceChildren(lamp, document.createTextNode(text)); } else { releaseStatus.textContent = text ?? ""; } if (tone) { releaseStatus.dataset.tone = tone; } else { delete releaseStatus.dataset.tone; } } /* ---- identity: stored fields first, rich detail on top ---- */ function paintIdentity() { const movie = current; if (!movie) { return; } titleEl.textContent = movie.title; yearEl.textContent = movie.year === null ? "" : String(movie.year); chipsEl.replaceChildren(); const root = roots.find((candidate) => candidate.id === movie.root_id); chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`)); chipsEl.append( 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) { chipsEl.append( chip(waiver, (span) => { span.dataset.verdict = "waived"; }), ); } if (movie.blocked) { chipsEl.append(chip("blocked")); } } function paintRootOptions() { rootSelect.replaceChildren(); for (const root of roots.filter((candidate) => candidate.kind === "movie")) { const option = document.createElement("option"); option.value = String(root.id); option.textContent = `${root.audience} · ${root.policy_name}`; rootSelect.append(option); } } function paintControls() { const movie = current; if (!movie) { return; } wanted.setAttribute("aria-pressed", String(movie.wanted)); blocked.setAttribute("aria-pressed", String(movie.blocked)); paintRootOptions(); // a root list that does not carry the title's own root still shows it — // a select lying by omission would make the next change move it blindly if (rootSelect.value !== String(movie.root_id)) { const option = document.createElement("option"); option.value = String(movie.root_id); option.textContent = `root ${movie.root_id}`; rootSelect.append(option); } rootSelect.value = String(movie.root_id); sweep.disabled = false; } async function refreshMovie() { const id = movieId; if (id === null) { return; } const fresh = await fetchMovie(id); if (fresh === null || movieId !== id) { return; } current = fresh; paintIdentity(); paintControls(); } wanted.addEventListener("click", () => { const movie = current; if (!movie) { return; } wanted.disabled = true; void updateMovie(movie.id, { wanted: !movie.wanted }).then((outcome) => { if (outcome.kind === "error") { wanted.disabled = false; setStatus(`wanted failed — ${outcome.detail}`, "fault"); return; } void refreshMovie().then(() => { wanted.disabled = false; }); }); }); blocked.addEventListener("click", () => { const movie = current; if (!movie) { return; } blocked.disabled = true; void updateMovie(movie.id, { blocked: !movie.blocked }).then((outcome) => { if (outcome.kind === "error") { blocked.disabled = false; setStatus(`blocked failed — ${outcome.detail}`, "fault"); return; } void refreshMovie().then(() => { blocked.disabled = false; }); }); }); rootSelect.addEventListener("change", () => { const movie = current; if (!movie) { return; } const next = Number(rootSelect.value); if (!Number.isInteger(next) || next === movie.root_id) { return; } rootSelect.disabled = true; void updateMovie(movie.id, { root_id: next }).then((outcome) => { rootSelect.disabled = false; if (outcome.kind === "error") { rootSelect.value = String(movie.root_id); setStatus(`root change failed — ${outcome.detail}`, "fault"); return; } void refreshMovie(); }); }); /* ---- §9.6 rich detail: one request, images hotlinked ---- */ function clearRichDetail() { hero.classList.remove("has-backdrop"); hero.style.removeProperty("--backdrop"); poster.hidden = true; poster.removeAttribute("src"); ratingEl.hidden = true; ratingEl.replaceChildren(); metaEl.hidden = true; taglineEl.hidden = true; overviewEl.hidden = true; actionsEl.replaceChildren(); castSection.hidden = true; castRows.replaceChildren(); } async function loadMetadata(id: number, ticket: number) { const outcome = await movieMetadata(id); if (ticket !== sequence || movieId !== id) { return; } if (outcome.kind === "error") { // the stored identity stands; the actions row says why the rest is absent const note = document.createElement("p"); note.className = "add-note readout"; note.dataset.tone = "fault"; note.textContent = `metadata unavailable — ${outcome.detail}`; actionsEl.append(note); return; } const detail = outcome.metadata; const posterUrl = tmdbImage(detail.poster_path, "w342"); if (posterUrl !== null && current !== null) { poster.src = posterUrl; poster.alt = `${current.title} poster`; poster.hidden = false; } const backdropUrl = tmdbImage(detail.backdrop_path, "w1280"); if (backdropUrl !== null) { hero.classList.add("has-backdrop"); hero.style.setProperty("--backdrop", `url("${backdropUrl}")`); } if (detail.vote_average !== null) { ratingEl.hidden = false; ratingEl.append(starIcon(), document.createTextNode(`${formatRating(detail.vote_average)} `)); const votes = document.createElement("span"); votes.className = "dim"; votes.textContent = `(${formatVoteCount(detail.vote_count)})`; ratingEl.append(votes); } const metaLine = formatMetaLine(detail.runtime, detail.genres); if (metaLine !== "") { metaEl.hidden = false; metaEl.textContent = metaLine; } if (detail.tagline !== null && detail.tagline !== "") { taglineEl.hidden = false; taglineEl.textContent = `“${detail.tagline}”`; } if (detail.overview !== null && detail.overview !== "") { overviewEl.hidden = false; overviewEl.textContent = detail.overview; } // the trailer resolves from this response's own key — no second call. // Hidden when absent rather than shown dead (§9.6). if (detail.trailer !== null) { const trailer = document.createElement("a"); trailer.className = "control"; trailer.href = youtubeLink(detail.trailer.youtube_key); trailer.target = "_blank"; trailer.rel = "noreferrer"; trailer.textContent = "trailer"; actionsEl.append(trailer); } actionsEl.append(externalLink("tmdb", tmdbMovieLink(detail.tmdb_id))); if (detail.imdb_id !== null) { actionsEl.append(externalLink("imdb", imdbLink(detail.imdb_id))); } if (current !== null) { actionsEl.append( externalLink("rotten tomatoes", rottenTomatoesSearch(current.title, current.year)), ); } if (detail.cast.length > 0 && current !== null) { castSection.hidden = false; for (const member of detail.cast) { castRows.append(castItem(member)); } } } /* ---- on disk: ffprobe truth and honest waivers (§5.6, §5.7) ---- */ function paintFiles(outcome: FilesOutcome) { diskRows.replaceChildren(); filesSection.hidden = false; if (outcome.kind === "error") { diskCount.textContent = ""; const row = document.createElement("li"); row.className = "rel rel-none readout"; row.dataset.tone = "fault"; row.textContent = `files unreadable — ${outcome.detail}`; diskRows.append(row); return; } const files = outcome.files; diskCount.textContent = String(files.length); if (files.length === 0) { const row = document.createElement("li"); row.className = "rel rel-none readout dim"; row.textContent = "nothing on disk yet — imports land here"; diskRows.append(row); return; } for (const file of files) { const item = document.createElement("li"); item.className = "rel disk-row"; const line = document.createElement("div"); line.className = "rel-line"; const name = document.createElement("span"); name.className = "disk-name readout"; name.textContent = fileName(file.path); name.title = file.path; line.append(name); for (const attribute of probedAttributeTags(file.probed)) { line.append(chip(attribute)); } line.append(chip(formatSize(file.size))); const waiver = waiverLabel(file.waiver); if (waiver !== null) { line.append( chip(waiver, (span) => { span.dataset.verdict = "waived"; }), ); } item.append(line); diskRows.append(item); } } /* ---- releases: the §9.3 deck, unchanged, as the closing section ---- */ function render(releases: MovieRelease[]) { if (!current) { return; } if (!paintBuckets(dom, releases, actions)) { return; } setReleaseStatus(null); } async function loadReleases(id: number, ticket: number) { window.clearTimeout(pollTimer); setReleaseStatus("reading releases…"); const outcome = await movieReleases(id); if (ticket !== sequence || movieId !== id) { return; } if (outcome.kind === "error") { clearBuckets(dom); setReleaseStatus(`releases unavailable — ${outcome.detail}`, "fault"); return; } if (outcome.releases.length === 0) { clearBuckets(dom); await emptyVerdict(id, ticket); return; } render(outcome.releases); } /** Refetch verdicts only — the reload an override-written grab triggers. */ async function reloadReleases() { const id = movieId; if (id === null) { return; } sequence += 1; const ticket = sequence; window.clearTimeout(pollTimer); const outcome = await movieReleases(id); if (ticket !== sequence || movieId !== id) { return; } if (outcome.kind === "results" && !paintBuckets(dom, outcome.releases, actions)) { clearBuckets(dom); } } /** * The empty deck is two different truths (§9.3, issue 101): a sweep that * has not landed yet, or a sweep that landed and found nothing. Only the * movie's `last_searched_at` can tell them apart. */ async function emptyVerdict(id: number, ticket: number) { const outcome = await movieSearchState(id); if (ticket !== sequence || movieId !== id) { return; } if (outcome.kind === "state" && outcome.movie.last_searched_at !== null) { setReleaseStatus( `no releases found — sweep finished ${formatSweepAge(outcome.movie.last_searched_at)}; search indexers runs a new one`, ); return; } if (outcome.kind === "state" && sweepExpected(outcome.movie)) { // first sweep still in flight — visibly different from "nothing found" watchSweep(id, null, ticket); return; } setReleaseStatus("no releases indexed for this title — search indexers runs a sweep now"); } /** Poll the movie until `last_searched_at` moves off `baseline`. */ function watchSweep(id: number, baseline: string | null, ticket: number) { sweep.disabled = true; setReleaseStatus("sweeping indexers…", undefined, true); const deadline = Date.now() + SWEEP_WAIT_MS; const tick = async () => { if (ticket !== sequence || movieId !== id) { return; } const outcome = await movieSearchState(id); if (ticket !== sequence || movieId !== id) { return; } if (outcome.kind === "state" && outcome.movie.last_searched_at !== baseline) { sweep.disabled = false; await loadReleases(id, ticket); return; } if (Date.now() >= deadline) { sweep.disabled = false; setReleaseStatus( "sweep has not landed yet — it may be waiting out its backoff; results appear here once it runs", ); return; } pollTimer = window.setTimeout(() => { void tick(); }, SWEEP_POLL_MS); }; pollTimer = window.setTimeout(() => { void tick(); }, SWEEP_POLL_MS); } /* ---- removal confirmation, unchanged from the deck ---- */ /** Tears the confirmation down without stealing focus from a caller. */ function clearRemove() { removeWrap.hidden = true; removeWrap.replaceChildren(); remove.setAttribute("aria-expanded", "false"); } function closeRemove() { clearRemove(); remove.focus(); } function openRemove() { const movie = current; if (!movie) { return; } const panel = removePanel(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(); } }); sweep.addEventListener("click", () => { const movie = current; if (!movie) { return; } sweep.disabled = true; void (async () => { // snapshot the last sweep first: its change is the completion signal const before = await movieSearchState(movie.id); if (current !== movie) { return; } const baseline = before.kind === "state" ? before.movie.last_searched_at : null; const outcome = await queueSearch(movie.id); if (current !== movie) { return; } if (outcome.kind === "error") { sweep.disabled = false; setReleaseStatus(`search failed — ${outcome.detail}`, "fault"); return; } sequence += 1; window.clearTimeout(pollTimer); watchSweep(movie.id, baseline, sequence); })(); }); /* ---- open, close, layers ---- */ async function load() { const id = movieId; if (id === null) { return; } sequence += 1; const ticket = sequence; window.clearTimeout(pollTimer); const [movie, filesOutcome, fetchedRoots] = await Promise.all([ fetchMovie(id), movieFiles(id), allRoots().catch(() => roots), ]); if (ticket !== sequence || movieId !== id) { return; } roots = fetchedRoots; if (movie === null) { setStatus("movie unavailable", "fault"); return; } current = movie; clearRichDetail(); paintIdentity(); paintControls(); paintFiles(filesOutcome); void loadMetadata(id, ticket); await loadReleases(id, ticket); } async function open(id: number, from: HTMLElement, container: HTMLElement, parent: Route) { // this page is one of `views`, so the loop below calls its own hide() // and would wipe the state assigned after it — hide first, then take state for (const sibling of views) { sibling.hide(); } movieId = id; current = null; origin = from; returnTo = container; parentRoute = parent; board.hidden = true; deckEl.hidden = true; view.hidden = false; clearBuckets(dom); clearRemove(); clearRichDetail(); filesSection.hidden = true; diskRows.replaceChildren(); sweep.disabled = false; back.focus(); await load(); } function hide() { view.hidden = true; movieId = null; current = null; sequence += 1; window.clearTimeout(pollTimer); clearRemove(); } function close() { const target = origin; navigate(parentRoute); hide(); if (returnTo) { returnTo.hidden = false; } target?.focus(); } back.addEventListener("click", close); // capture + stopImmediatePropagation: one Escape steps back one layer — // the library and search decks also listen for Escape on this window window.addEventListener( "keydown", (event) => { if (event.key !== "Escape" || view.hidden) { return; } event.stopImmediatePropagation(); // the confirmation is the innermost layer: Esc abandons it, not the page if (removeWrap.hidden) { close(); } else { closeRemove(); } }, true, ); return { hide, open, setRemoved: (handler: (parent: Route) => void) => { removed = handler; }, }; } interface RemoveActions { cancel: () => void; removed: () => void; } /** * The 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; } /* ---- §9.6 poster grid (#151) -------------------------------------------- */ type LibraryShape = "grid" | "list"; const LIBRARY_SHAPE_KEY = "arr.libraryView"; /** * The grid is default and the choice is per-person — no server state. A * corrupt or foreign value falls back to the grid rather than breaking open. */ function loadLibraryShape(): LibraryShape { try { const stored = window.localStorage.getItem(LIBRARY_SHAPE_KEY); return stored === "list" ? "list" : "grid"; } catch { return "grid"; } } function libraryMain( board: HTMLElement, movieDetail: MovieView, 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 gridBtn = must("#library-view-grid"); const listBtn = must("#library-view-list"); 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; // §9.6 library view: the poster grid is default, per-person, localStorage let shape = loadLibraryShape(); let data: { series: LibrarySeries[]; movies: LibraryMovie[] } | null = null; let roots: Root[] = []; // guards a stale fetch from painting over a newer view let sequence = 0; function setShape(next: LibraryShape) { shape = next; try { window.localStorage.setItem(LIBRARY_SHAPE_KEY, next); } catch { // private mode or full storage: the choice just does not persist } gridBtn.setAttribute("aria-pressed", String(shape === "grid")); listBtn.setAttribute("aria-pressed", String(shape === "list")); render(); } gridBtn.addEventListener("click", () => setShape("grid")); listBtn.addEventListener("click", () => setShape("list")); gridBtn.setAttribute("aria-pressed", String(shape === "grid")); listBtn.setAttribute("aria-pressed", String(shape === "list")); function setStatus(text: string | null, tone?: "fault") { status.hidden = text === null; status.textContent = text ?? ""; if (tone) { status.dataset.tone = tone; } else { delete status.dataset.tone; } } function clearGroups() { for (const group of [groups.series, groups.movies]) { group.section.hidden = true; group.rows.replaceChildren(); } summary.textContent = ""; toggle.hidden = true; } function renderGroup(group: LibraryGroup, items: T[], build: (item: T) => HTMLLIElement) { group.rows.className = shape === "grid" ? "deck-rows grid-rows" : "deck-rows"; group.rows.replaceChildren(); group.section.hidden = items.length === 0; group.count.textContent = String(items.length); for (const item of items) { group.rows.append(build(item)); } } function render() { const library = data; if (!library) { return; } const total = library.series.length + library.movies.length; const satisfied = library.series.filter((series) => !seriesNeedsAttention(series)).length + library.movies.filter((movie) => !movieNeedsAttention(movie)).length; summary.textContent = total === 0 ? "" : `${total} ${total === 1 ? "title" : "titles"}`; toggle.hidden = satisfied === 0; toggle.textContent = `${showAll ? "hide" : "show"} ${satisfied} satisfied`; toggle.setAttribute("aria-expanded", String(showAll)); const seriesShown = showAll ? library.series : library.series.filter(seriesNeedsAttention); const moviesShown = showAll ? library.movies : library.movies.filter(movieNeedsAttention); const openSeries = (id: number, origin: HTMLElement) => { navigate({ kind: "series", seriesId: id }); void seriesDetail.open(id, origin, view, { kind: "library" }); }; const openMovie = (movie: LibraryMovie, origin: HTMLElement) => { navigate({ kind: "movie", movieId: movie.id }); void movieDetail.open(movie.id, origin, view, { kind: "library" }); }; if (shape === "grid") { renderGroup(groups.series, seriesShown, (series) => seriesCard(series, roots, openSeries)); renderGroup(groups.movies, moviesShown, (movie) => movieCard(movie, roots, openMovie)); } else { renderGroup(groups.series, seriesShown, (series) => seriesRow(series, roots, openSeries)); renderGroup(groups.movies, moviesShown, (movie) => libraryRow(movie, roots, openMovie)); } if (total === 0) { setStatus("library is empty — the search box above adds titles"); } else if (seriesShown.length + moviesShown.length === 0) { setStatus("nothing needs attention"); } else { setStatus(null); } } async function load() { sequence += 1; const ticket = sequence; setStatus("reading library…"); const [outcome, fetchedRoots] = await Promise.all([ fetchLibrary(), allRoots().catch(() => roots), ]); if (ticket !== sequence) { return; } roots = fetchedRoots; if (outcome.kind === "error") { clearGroups(); setStatus(`library unavailable — ${outcome.detail}`, "fault"); return; } data = { series: outcome.series, movies: outcome.movies }; render(); } function open() { for (const sibling of views) { sibling.hide(); } 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; } /** * One §9.6 grid tile (#151): poster at w342, title and year beneath, then * the chips the row view carries — a card is legible before it is pretty. * A missing poster becomes a same-ratio placeholder carrying its title, * so the grid never reflows and never has a hole in it. */ function libraryCard( posterPath: string | null, title: string, year: number | null, chips: HTMLSpanElement[], open: (origin: HTMLElement) => void, ): HTMLLIElement { const item = document.createElement("li"); const card = document.createElement("button"); card.type = "button"; card.className = "card"; const art = document.createElement("span"); art.className = "card-art"; if (posterPath === null) { const blank = document.createElement("span"); blank.className = "card-blank"; const blankTitle = document.createElement("span"); blankTitle.className = "card-blank-title"; blankTitle.textContent = title; blank.append(blankTitle); art.append(blank); } else { const img = document.createElement("img"); img.className = "card-poster"; img.loading = "lazy"; img.src = tmdbImage(posterPath, "w342") ?? ""; img.alt = `${title} poster`; // hotlinked art can 404; fall back to the titled blank rather than a hole img.addEventListener("error", () => { const blankTitle = document.createElement("span"); blankTitle.className = "card-blank-title"; blankTitle.textContent = title; const blank = document.createElement("span"); blank.className = "card-blank"; blank.append(blankTitle); img.replaceWith(blank); }); art.append(img); } const id = document.createElement("span"); id.className = "card-id"; const name = document.createElement("span"); name.className = "card-name"; name.textContent = title; const when = document.createElement("span"); when.className = "card-year readout dim"; when.textContent = year === null ? "—" : String(year); id.append(name, when); const chipLine = document.createElement("span"); chipLine.className = "card-chips"; for (const chipEl of chips) { chipLine.append(chipEl); } const body = document.createElement("span"); body.className = "card-body"; body.append(id, chipLine); card.append(art, body); card.addEventListener("click", () => { open(card); }); item.append(card); return item; } function seriesCard( series: LibrarySeries, roots: Root[], open: (id: number, origin: HTMLElement) => void, ): HTMLLIElement { const root = roots.find((candidate) => candidate.id === series.root_id); const chips = [chip(root ? root.audience : `root ${series.root_id}`)]; if (series.wanted_episodes > 0) { chips.push( 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.push(chip("blocked")); } chips.push( chip(series.status, (span) => { span.dataset.status = series.status; }), ); const rating = ratingChip(series.vote_average); if (rating !== null) { chips.push(rating); } return libraryCard(series.poster_path, series.title, series.year, chips, (origin) => open(series.id, origin), ); } function movieCard( movie: LibraryMovie, roots: Root[], open: (movie: LibraryMovie, origin: HTMLElement) => void, ): HTMLLIElement { const root = roots.find((candidate) => candidate.id === movie.root_id); const chips = [chip(root ? root.audience : `root ${movie.root_id}`)]; chips.push( 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.push( chip(waiver, (span) => { span.dataset.verdict = "waived"; }), ); } if (!movie.wanted) { chips.push(chip("not wanted")); } if (movie.blocked) { chips.push(chip("blocked")); } const rating = ratingChip(movie.vote_average); if (rating !== null) { chips.push(rating); } return libraryCard(movie.poster_path, movie.title, movie.year, chips, (origin) => open(movie, origin), ); } /* ---- tv release deck (§9.3 for season packs and single episodes) ------- */ interface TvDeckRequest { title: string; sub: string | null; seriesId: number; target: TvTarget; origin: HTMLElement; returnTo: HTMLElement; parentRoute: Route; } interface TvReleasesView { open: (request: TvDeckRequest) => void; hide: () => void; } /** §6.2: the sweep runs on the daemon's cadence. Poll modestly, give up softly. */ const TV_SWEEP_POLL_MS = 5000; const TV_SWEEP_WAIT_MS = 150_000; function tvReleasesMain(): TvReleasesView { const view = must("#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 hero = must("#series-hero"); const poster = must("#series-poster"); const titleEl = must("#series-title"); const yearEl = must("#series-year"); const chipsEl = must("#series-chips"); const ratingEl = must("#series-rating"); const metaEl = must("#series-meta"); const taglineEl = must("#series-tagline"); const overviewEl = must("#series-overview"); const actionsEl = must("#series-actions"); const castSection = must("#series-cast"); const castRows = must("#rows-series-cast"); 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")); } } /* ---- §9.6 rich detail: one request, images hotlinked ---- */ function clearRichDetail() { hero.classList.remove("has-backdrop"); hero.style.removeProperty("--backdrop"); poster.hidden = true; poster.removeAttribute("src"); ratingEl.hidden = true; ratingEl.replaceChildren(); metaEl.hidden = true; taglineEl.hidden = true; overviewEl.hidden = true; actionsEl.replaceChildren(); castSection.hidden = true; castRows.replaceChildren(); } async function loadMetadata(id: number, ticket: number) { const outcome = await seriesMetadata(id); if (ticket !== sequence || seriesId !== id) { return; } if (outcome.kind === "error") { // the operational header stands; the actions row says why the rest is absent const note = document.createElement("p"); note.className = "add-note readout"; note.dataset.tone = "fault"; note.textContent = `metadata unavailable — ${outcome.detail}`; actionsEl.append(note); return; } paintMetadata(outcome.metadata); } /** * What the show IS (#150): art, rating, overview above the season tree. * The operational chips — audience, counts, derived status — stay from * [`paintHeader`] and outrank all of it. */ function paintMetadata(detail: SeriesMetadata) { const posterUrl = tmdbImage(detail.poster_path, "w342"); if (posterUrl !== null && series !== null) { poster.src = posterUrl; poster.alt = `${series.title} poster`; poster.hidden = false; } const backdropUrl = tmdbImage(detail.backdrop_path, "w1280"); if (backdropUrl !== null) { hero.classList.add("has-backdrop"); hero.style.setProperty("--backdrop", `url("${backdropUrl}")`); } if (detail.vote_average !== null) { ratingEl.hidden = false; ratingEl.append(starIcon(), document.createTextNode(`${formatRating(detail.vote_average)} `)); const votes = document.createElement("span"); votes.className = "dim"; votes.textContent = `(${formatVoteCount(detail.vote_count)})`; ratingEl.append(votes); } const metaLine = formatMetaLine(detail.runtime, detail.genres); if (metaLine !== "") { metaEl.hidden = false; metaEl.textContent = metaLine; } if (detail.tagline !== null && detail.tagline !== "") { taglineEl.hidden = false; taglineEl.textContent = `“${detail.tagline}”`; } if (detail.overview !== null && detail.overview !== "") { overviewEl.hidden = false; overviewEl.textContent = detail.overview; } // the trailer resolves from this response's own key — no second call. // Hidden when absent rather than shown dead (§9.6). if (detail.trailer !== null) { const trailer = document.createElement("a"); trailer.className = "control"; trailer.href = youtubeLink(detail.trailer.youtube_key); trailer.target = "_blank"; trailer.rel = "noreferrer"; trailer.textContent = "trailer"; actionsEl.append(trailer); } actionsEl.append(externalLink("tmdb", tmdbSeriesLink(detail.tmdb_id))); // a series has no imdb_id in this app; TVDB is its second id (§9.6) if (detail.tvdb_id !== null) { actionsEl.append(externalLink("tvdb", tvdbLink(detail.tvdb_id))); } if (series !== null) { actionsEl.append( externalLink("rotten tomatoes", rottenTomatoesSearch(series.title, series.year)), ); } if (detail.cast.length > 0) { castSection.hidden = false; for (const member of detail.cast) { castRows.append(castItem(member)); } } } 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(); clearRichDetail(); renderSeasons(); setStatus(null); void loadMetadata(id, ticket); 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; clearRichDetail(); back.focus(); await load(); } function hide() { view.hidden = true; seriesId = null; seasons = null; sequence += 1; clearRichDetail(); } function close() { navigate(parentRoute); hide(); returnTo.hidden = false; origin?.focus(); } back.addEventListener("click", close); // capture, like every other layer: the tv deck's listener is registered // first, so one Esc steps back one layer window.addEventListener( "keydown", (event) => { if (event.key === "Escape" && !view.hidden) { event.stopImmediatePropagation(); 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, movieDetail: MovieView, 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: "movie", movieId: movie.id }); void movieDetail.open(movie.id, 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() { 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 = "open"; chips.append(affordance); line.append(rowTitle(movie.title, movie.year), chips); line.addEventListener("click", () => { open(movie, line); }); const allow = document.createElement("button"); allow.type = "button"; allow.className = "control queue-allow"; allow.textContent = "allow english"; const note = document.createElement("span"); note.className = "queue-feedback readout"; note.setAttribute("role", "status"); note.hidden = true; allow.addEventListener("click", () => { allow.disabled = true; note.hidden = false; delete note.dataset.tone; note.textContent = "writing override…"; void allowEnglishAudio(movie.id).then((outcome) => { if (outcome.kind === "error") { allow.disabled = false; note.textContent = `${outcome.overrideWritten ? "override written · " : ""}${outcome.detail}`; note.dataset.tone = "fault"; return; } settled(item, `${movie.title} — english allowed, search queued`); }); }); item.append(line, allow, note); return item; } 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();