9fee07f080
A release below §5.5's floor was rejected with no way through, so a policy wrong about one title left three Rick and Morty S09 packs visible and none grabbable. `allow_below_floor` relaxes the floor for one title into a soft fail, never a pass: the release is waived, so automatic grabbing still skips it and the import records a §5.7 waiver. The deck offers the one click on a rejected row where the rule has an override, which is exactly what §9.3's override is for. Stored verdicts are re-derived when a title's overrides change — the deck and the daemon's grab gate both read that column, so without it the row the operator just acted on would keep reading `rejected`. Closes #210 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4184 lines
130 KiB
TypeScript
4184 lines
130 KiB
TypeScript
import { type CheckStatus, type Probe, probeHealth } from "./health";
|
|
import {
|
|
fetchLibrary,
|
|
type LibrarySeries,
|
|
movieNeedsAttention,
|
|
seriesNeedsAttention,
|
|
waiverLabel,
|
|
} from "./library";
|
|
import {
|
|
fileName,
|
|
formatMetaLine,
|
|
formatRating,
|
|
formatVoteCount,
|
|
imdbLink,
|
|
movieMetadata,
|
|
resolveTrailer,
|
|
rottenTomatoesSearch,
|
|
tmdbImage,
|
|
tmdbMovieLink,
|
|
tmdbSeriesLink,
|
|
tvdbLink,
|
|
updateMovie,
|
|
youtubeLink,
|
|
} from "./movie";
|
|
import {
|
|
type AttentionMovie,
|
|
type AttentionQueues,
|
|
allowEnglishAudio,
|
|
attemptsLabel,
|
|
attentionTotal,
|
|
fetchAttention,
|
|
type SeriesAttention,
|
|
} from "./queues";
|
|
import {
|
|
type ActionOutcome,
|
|
bucketOf,
|
|
type FilesOutcome,
|
|
formatAudio,
|
|
formatHdr,
|
|
formatResolution,
|
|
formatRetryWait,
|
|
formatScore,
|
|
formatSeeders,
|
|
formatSize,
|
|
formatSource,
|
|
formatSweepAge,
|
|
grabRelease,
|
|
libraryFolder,
|
|
type MovieRelease,
|
|
movieFiles,
|
|
movieReleases,
|
|
movieSearchState,
|
|
overridable,
|
|
probedAttributeTags,
|
|
queueSearch,
|
|
removeMovie,
|
|
ruleLabel,
|
|
type SeasonPackState,
|
|
sweepExpected,
|
|
totalSize,
|
|
type WaiveOutcome,
|
|
waiveAndGrab,
|
|
} from "./releases";
|
|
import { currentRoute, navigate, type Route } from "./router";
|
|
import {
|
|
addMovie,
|
|
addSeries,
|
|
allRoots,
|
|
fetchMovie,
|
|
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,
|
|
refreshSeriesMetadata,
|
|
removeEpisodeFiles,
|
|
removeSeasonFiles,
|
|
removeSeries,
|
|
type SeriesMetadata,
|
|
seasonCounts,
|
|
seasonTarget,
|
|
seriesFolder,
|
|
seriesMetadata,
|
|
setEpisodeWanted,
|
|
setSeasonTracked,
|
|
type TvTarget,
|
|
waiveAndGrabTv,
|
|
} from "./series";
|
|
import { armedDelete, settingsMain } from "./settings";
|
|
import "./style.css";
|
|
|
|
const POLL_MS = 15_000;
|
|
|
|
type Tone = "ok" | "warn" | "fault" | "idle";
|
|
|
|
const TONE_BY_STATUS: Record<CheckStatus, Tone> = {
|
|
ok: "ok",
|
|
unconfigured: "warn",
|
|
unreachable: "fault",
|
|
};
|
|
|
|
interface ModuleRefs {
|
|
lamp: HTMLElement;
|
|
status: HTMLElement;
|
|
detail: HTMLElement;
|
|
defaultDetail: string;
|
|
}
|
|
|
|
function moduleRefs(name: string): ModuleRefs {
|
|
const root = document.querySelector(`[data-check="${name}"]`);
|
|
const lamp = root?.querySelector<HTMLElement>(".lamp");
|
|
const status = root?.querySelector<HTMLElement>('[data-role="status"]');
|
|
const detail = root?.querySelector<HTMLElement>('[data-role="detail"]');
|
|
if (!lamp || !status || !detail) {
|
|
throw new Error(`signal chain is missing the ${name} module`);
|
|
}
|
|
return { lamp, status, detail, defaultDetail: detail.textContent ?? "" };
|
|
}
|
|
|
|
function setModule(refs: ModuleRefs, state: string, tone: Tone, word: string, detail?: string) {
|
|
refs.lamp.dataset.state = state;
|
|
refs.status.dataset.tone = tone;
|
|
refs.status.textContent = word;
|
|
refs.detail.textContent = detail ?? refs.defaultDetail;
|
|
}
|
|
|
|
function must<T extends Element>(selector: string): T {
|
|
const element = document.querySelector<T>(selector);
|
|
if (!element) {
|
|
throw new Error(`markup is missing ${selector}`);
|
|
}
|
|
return element;
|
|
}
|
|
|
|
function main() {
|
|
const chain = must<HTMLElement>(".chain");
|
|
const masterLamp = must<HTMLElement>("#master-lamp");
|
|
const version = must<HTMLElement>("#version");
|
|
|
|
const master = moduleRefs("master");
|
|
const checks = {
|
|
tmdb: moduleRefs("tmdb"),
|
|
prowlarr: moduleRefs("prowlarr"),
|
|
transmission: moduleRefs("transmission"),
|
|
} as const;
|
|
|
|
const traces = {
|
|
tmdb: document.querySelector<SVGElement>('[data-trace="tmdb"]'),
|
|
prowlarr: document.querySelector<SVGElement>('[data-trace="prowlarr"]'),
|
|
master: document.querySelector<SVGElement>('[data-trace="master"]'),
|
|
} as const;
|
|
|
|
function setTrace(name: keyof typeof traces, tone: Tone | null) {
|
|
const trace = traces[name];
|
|
if (!trace) {
|
|
return;
|
|
}
|
|
if (tone === null || tone === "idle") {
|
|
delete trace.dataset.tone;
|
|
} else {
|
|
trace.dataset.tone = tone;
|
|
}
|
|
}
|
|
|
|
// stagger order for the one power-up animation, upstream to downstream
|
|
const strikeOrder = [checks.tmdb, checks.prowlarr, master, checks.transmission];
|
|
strikeOrder.forEach((refs, index) => {
|
|
refs.lamp.style.setProperty("--strike", String(index));
|
|
});
|
|
masterLamp.style.setProperty("--strike", "0");
|
|
|
|
let powered = false;
|
|
|
|
function render(probe: Probe) {
|
|
if (!powered) {
|
|
powered = true;
|
|
chain.classList.add("powered");
|
|
}
|
|
|
|
if (probe.kind === "unreachable") {
|
|
masterLamp.dataset.state = "unreachable";
|
|
version.textContent = "v —";
|
|
// the outage detail lives on the chain's arr module — the header has no verdict line
|
|
setModule(master, "unreachable", "fault", "down", probe.detail);
|
|
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";
|
|
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(tvDeck, views);
|
|
const movieDetail = movieMain(views);
|
|
const library = libraryMain(movieDetail, seriesDetail, views);
|
|
const goHome = () => library.open();
|
|
const queues = queuesMain(movieDetail, views, goHome);
|
|
const settings = settingsMain(views, goHome);
|
|
views.push(tvDeck, seriesDetail, movieDetail, library, queues, settings);
|
|
const search = searchMain(movieDetail, seriesDetail, views, goHome);
|
|
// a removed title must not survive on the surface the page opened over
|
|
const openAfterRemoval = (parent: Route) => {
|
|
switch (parent.kind) {
|
|
case "library":
|
|
library.open();
|
|
break;
|
|
case "queues":
|
|
queues.open();
|
|
break;
|
|
case "search":
|
|
search.restore(parent.query);
|
|
break;
|
|
default:
|
|
library.open();
|
|
break;
|
|
}
|
|
};
|
|
movieDetail.setRemoved(openAfterRemoval);
|
|
seriesDetail.setRemoved(openAfterRemoval);
|
|
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 "library":
|
|
// §9.7: "/" is the library — clear any stale query so the rail
|
|
// input matches the route on a cold load or back-navigation
|
|
search.showIdle();
|
|
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<HTMLElement>("#nav-library"),
|
|
must<HTMLElement>("#library"),
|
|
{
|
|
kind: "library",
|
|
},
|
|
);
|
|
// `/movies/{id}/releases` keeps resolving (#149): same page, deck
|
|
// section in view
|
|
if (route.kind === "releases") {
|
|
must<HTMLElement>("#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<HTMLElement>("#nav-library"),
|
|
must<HTMLElement>("#library"),
|
|
{
|
|
kind: "library",
|
|
},
|
|
);
|
|
break;
|
|
}
|
|
case "seasonReleases":
|
|
case "episodeReleases": {
|
|
// both deck routes live over the series detail view, exactly as a
|
|
// click on a deck button does; resolve what that click would have
|
|
let title: string;
|
|
let sub: string;
|
|
let seriesId: number;
|
|
let target: TvTarget;
|
|
if (route.kind === "seasonReleases") {
|
|
seriesId = route.seriesId;
|
|
target = seasonTarget(route.seriesId, route.seasonNumber);
|
|
sub = `season ${PAD_TWO(route.seasonNumber)} · packs`;
|
|
title = (await fetchSeries(route.seriesId))?.title ?? "";
|
|
if (title === "") {
|
|
navigate({ kind: "library" }, { replace: true });
|
|
library.open();
|
|
return;
|
|
}
|
|
} else {
|
|
const episode = await fetchEpisode(route.episodeId);
|
|
if (!episode) {
|
|
navigate({ kind: "library" }, { replace: true });
|
|
library.open();
|
|
return;
|
|
}
|
|
seriesId = episode.series_id;
|
|
target = episodeTarget(episode.id);
|
|
sub = `S${PAD_TWO(episode.season_number)}E${PAD_TWO(episode.number)} · ${episode.title}`;
|
|
title = (await fetchSeries(seriesId))?.title ?? "";
|
|
if (title === "") {
|
|
navigate({ kind: "library" }, { replace: true });
|
|
library.open();
|
|
return;
|
|
}
|
|
}
|
|
library.open();
|
|
await seriesDetail.open(
|
|
seriesId,
|
|
must<HTMLElement>("#nav-library"),
|
|
must<HTMLElement>("#library"),
|
|
{ kind: "library" },
|
|
);
|
|
// no origin click to restore focus to on a deep link — the series
|
|
// view's back control is the closest stand-in
|
|
tvDeck.open({
|
|
title,
|
|
sub,
|
|
seriesId,
|
|
target,
|
|
origin: must<HTMLButtonElement>("#series-back"),
|
|
returnTo: must<HTMLElement>("#series"),
|
|
parentRoute: { kind: "series", seriesId },
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// §9.7: the wordmark is a link home — intercepted so it navigates the
|
|
// SPA instead of reloading, but still a real anchor for the keyboard
|
|
const wordmark = must<HTMLAnchorElement>("#wordmark");
|
|
wordmark.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigate({ kind: "library" });
|
|
search.showIdle();
|
|
});
|
|
|
|
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 library with the search box empty — the "/" route. */
|
|
showIdle: () => void;
|
|
/** Restores the deck for `query` — the "/search?q=" route. */
|
|
restore: (query: string) => void;
|
|
}
|
|
|
|
function searchMain(
|
|
movieDetail: MovieView,
|
|
seriesDetail: SeriesView,
|
|
views: HideableView[],
|
|
goHome: () => void,
|
|
): SearchView {
|
|
const input = must<HTMLInputElement>("#search");
|
|
const hint = must<HTMLElement>("#search-hint");
|
|
const refs: DeckRefs = {
|
|
deck: must<HTMLElement>("#deck"),
|
|
status: must<HTMLElement>("#deck-status"),
|
|
groups: {
|
|
library: {
|
|
section: must<HTMLElement>("#group-library"),
|
|
count: must<HTMLElement>("#count-library"),
|
|
rows: must<HTMLUListElement>("#rows-library"),
|
|
},
|
|
tmdb: {
|
|
section: must<HTMLElement>("#group-tmdb"),
|
|
count: must<HTMLElement>("#count-tmdb"),
|
|
rows: must<HTMLUListElement>("#rows-tmdb"),
|
|
},
|
|
},
|
|
manualSection: must<HTMLElement>("#group-manual"),
|
|
manualIntake: must<HTMLElement>("#manual-intake"),
|
|
};
|
|
|
|
// The audience chip on a library row needs id → root. allRoots() caches
|
|
// success, so a failed boot fetch is retried whenever an add panel opens.
|
|
let roots: Root[] = [];
|
|
const fetchRoots = () =>
|
|
allRoots().then(
|
|
(fetched) => {
|
|
roots = fetched;
|
|
return fetched;
|
|
},
|
|
() => roots,
|
|
);
|
|
void fetchRoots();
|
|
|
|
/** A series or episode hit opens the series detail view (issue 129). */
|
|
const openSeries = (id: number, origin: HTMLElement) => {
|
|
navigate({ kind: "series", seriesId: id });
|
|
void seriesDetail.open(id, origin, refs.deck, {
|
|
kind: "search",
|
|
query: input.value.trim(),
|
|
});
|
|
};
|
|
|
|
/** 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;
|
|
|
|
/** A cleared search lands on the library — the "/" route (§9.7). */
|
|
function showHome() {
|
|
controller?.abort();
|
|
controller = null;
|
|
goHome();
|
|
}
|
|
|
|
function showDeck() {
|
|
for (const view of views) {
|
|
view.hide();
|
|
}
|
|
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 or a series.
|
|
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 inLibrary = new Set([...libraryMovies, ...librarySeries].map((title) => title.tmdb_id));
|
|
// Issue #168: an unreachable TMDB degrades only its own group — the
|
|
// operator sees the library half plus why TMDB is missing, and can tell
|
|
// that apart from a clean "nothing found".
|
|
const tmdbDown = response.tmdb_status === "unavailable";
|
|
if (response.library.length === 0 && response.tmdb.length === 0) {
|
|
setStatus(
|
|
tmdbDown ? "no matches in library — tmdb unavailable" : "no matches in library or on tmdb",
|
|
);
|
|
return;
|
|
}
|
|
if (tmdbDown) {
|
|
setStatus("tmdb unavailable — showing library matches only", "fault");
|
|
} else {
|
|
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));
|
|
}
|
|
}
|
|
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: "library" });
|
|
showHome();
|
|
clearGroups();
|
|
setStatus(null);
|
|
return;
|
|
}
|
|
timer = window.setTimeout(() => {
|
|
navigate({ kind: "search", query }, { replace: true });
|
|
void run(query);
|
|
}, DEBOUNCE_MS);
|
|
});
|
|
|
|
input.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter") {
|
|
window.clearTimeout(timer);
|
|
const query = input.value.trim();
|
|
if (query !== "") {
|
|
navigate({ kind: "search", query }, { replace: true });
|
|
void run(query);
|
|
}
|
|
} else if (event.key === "ArrowDown") {
|
|
const first = refs.deck.querySelector<HTMLElement>(
|
|
'.row-tmdb:not(:disabled):not([aria-disabled="true"])',
|
|
);
|
|
if (first) {
|
|
event.preventDefault();
|
|
first.focus();
|
|
}
|
|
} else if (event.key === "Escape") {
|
|
input.value = "";
|
|
navigate({ kind: "library" });
|
|
showHome();
|
|
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: "library" });
|
|
showHome();
|
|
clearGroups();
|
|
setStatus(null);
|
|
input.focus();
|
|
}
|
|
});
|
|
|
|
refs.deck.addEventListener("keydown", (event) => {
|
|
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
|
|
return;
|
|
}
|
|
const target = event.target;
|
|
if (!(target instanceof HTMLElement) || !target.classList.contains("row-tmdb")) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
const rows = [
|
|
...refs.deck.querySelectorAll<HTMLElement>(
|
|
'.row-tmdb:not(:disabled):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 = "";
|
|
showHome();
|
|
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;
|
|
}
|
|
|
|
/** §4.2 state ramp on a media-state chip; the word carries, colour accelerates. */
|
|
function mediaStateChip(state: string, wanted: boolean): HTMLSpanElement {
|
|
return chip(state, (span) => {
|
|
span.dataset.movieState = state;
|
|
span.dataset.wanted = String(wanted);
|
|
});
|
|
}
|
|
|
|
/** The ramp read off N/M itself: green when full, amber while a gap remains. */
|
|
function countsTone(available: number, wanted: number): "complete" | "partial" | null {
|
|
if (wanted === 0) {
|
|
return null;
|
|
}
|
|
return available >= wanted ? "complete" : "partial";
|
|
}
|
|
|
|
function countsChip(
|
|
label: string,
|
|
available: number,
|
|
wanted: number,
|
|
ariaLabel: string,
|
|
): HTMLSpanElement {
|
|
return chip(label, (span) => {
|
|
const tone = countsTone(available, wanted);
|
|
if (tone !== null) {
|
|
span.dataset.counts = tone;
|
|
}
|
|
span.setAttribute("aria-label", ariaLabel);
|
|
});
|
|
}
|
|
|
|
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(mediaStateChip(movie.state, movie.wanted));
|
|
// §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;
|
|
}
|
|
|
|
/** A TMDB hit of either kind — the row opens the add flow (§9.2). */
|
|
function tmdbRow(
|
|
hit: TmdbMovie | TmdbSeries,
|
|
inLibrary: boolean,
|
|
fetchRoots: () => Promise<Root[]>,
|
|
): HTMLLIElement {
|
|
const { item, 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<HTMLElement>(".control")?.focus();
|
|
});
|
|
});
|
|
return item;
|
|
}
|
|
|
|
/**
|
|
* The §9.2 add flow: inline, root and policy pre-filled, one confirm. A
|
|
* series asks the one series-level question at add time — `auto_track`
|
|
* (§4.1) — and states what it does in one plain line.
|
|
*/
|
|
function addPanel(
|
|
title: TmdbMovie | TmdbSeries,
|
|
available: Root[],
|
|
row: HTMLButtonElement,
|
|
): HTMLElement {
|
|
const isSeries = title.kind === "series";
|
|
const kind = isSeries ? "tv" : "movie";
|
|
const roots = available.filter((root) => root.kind === kind);
|
|
const panel = document.createElement("div");
|
|
panel.className = "add-panel";
|
|
|
|
let selected = roots.find((root) => root.audience === "main") ?? roots[0] ?? null;
|
|
|
|
const options = document.createElement("div");
|
|
options.className = "add-roots";
|
|
options.setAttribute("role", "group");
|
|
options.setAttribute("aria-label", "root folder");
|
|
|
|
const optionButtons: HTMLButtonElement[] = [];
|
|
for (const root of roots) {
|
|
const option = document.createElement("button");
|
|
option.type = "button";
|
|
option.className = "root-option";
|
|
option.setAttribute("aria-pressed", String(root === selected));
|
|
|
|
const audience = document.createElement("span");
|
|
audience.className = "root-audience";
|
|
audience.textContent = root.audience;
|
|
const policy = document.createElement("span");
|
|
policy.className = "root-policy readout dim";
|
|
policy.textContent = root.policy_name;
|
|
const path = document.createElement("span");
|
|
path.className = "root-path readout dim";
|
|
path.textContent = root.path;
|
|
option.append(audience, policy, path);
|
|
|
|
option.addEventListener("click", () => {
|
|
selected = root;
|
|
for (const other of optionButtons) {
|
|
other.setAttribute("aria-pressed", String(other === option));
|
|
}
|
|
});
|
|
optionButtons.push(option);
|
|
options.append(option);
|
|
}
|
|
|
|
// §4.1: auto_track is a rule about seasons metadata reveals, not intent.
|
|
// Future seasons are picked up by themselves; past seasons never are.
|
|
let autoTrack = true;
|
|
let autotrack: HTMLElement | null = null;
|
|
if (isSeries) {
|
|
const track = document.createElement("button");
|
|
track.type = "button";
|
|
track.className = "control control-quiet";
|
|
track.setAttribute("aria-pressed", "true");
|
|
const trackState = () => {
|
|
track.textContent = `auto-track future seasons: ${autoTrack ? "on" : "off"}`;
|
|
};
|
|
trackState();
|
|
track.addEventListener("click", () => {
|
|
autoTrack = !autoTrack;
|
|
track.setAttribute("aria-pressed", String(autoTrack));
|
|
trackState();
|
|
});
|
|
|
|
const trackNote = document.createElement("p");
|
|
trackNote.className = "add-note readout dim";
|
|
trackNote.textContent = "future seasons are tracked automatically; past seasons are not";
|
|
|
|
autotrack = document.createElement("div");
|
|
autotrack.className = "add-autotrack";
|
|
autotrack.append(track, trackNote);
|
|
}
|
|
|
|
const confirm = document.createElement("button");
|
|
confirm.type = "button";
|
|
confirm.className = "control";
|
|
confirm.textContent = "add to library";
|
|
|
|
const note = document.createElement("p");
|
|
note.className = "add-note readout";
|
|
note.setAttribute("role", "status");
|
|
note.hidden = true;
|
|
|
|
if (roots.length === 0) {
|
|
confirm.disabled = true;
|
|
note.hidden = false;
|
|
// a failed fetch and an absent kind are different problems
|
|
note.textContent =
|
|
available.length === 0
|
|
? "roots unavailable — daemon down or database missing"
|
|
: `no ${isSeries ? "tv" : "movie"} roots configured — create one under settings`;
|
|
note.dataset.tone = "fault";
|
|
}
|
|
|
|
confirm.addEventListener("click", () => {
|
|
const root = selected;
|
|
if (!root) {
|
|
return;
|
|
}
|
|
confirm.disabled = true;
|
|
note.hidden = false;
|
|
delete note.dataset.tone;
|
|
note.textContent = "adding…";
|
|
const adding =
|
|
title.kind === "movie" ? addMovie(title, root.id) : addSeries(title, root.id, autoTrack);
|
|
void adding.then((outcome) => {
|
|
if (outcome.kind === "error") {
|
|
confirm.disabled = false;
|
|
note.textContent = `add failed — ${outcome.detail}`;
|
|
note.dataset.tone = "fault";
|
|
return;
|
|
}
|
|
const already = outcome.kind === "conflict";
|
|
const done = document.createElement("p");
|
|
done.className = "add-done readout";
|
|
done.setAttribute("role", "status");
|
|
done.tabIndex = -1;
|
|
done.textContent = already
|
|
? "already in library"
|
|
: title.kind === "movie"
|
|
? `added to ${root.audience} — wanted, search follows`
|
|
: `added to ${root.audience} — future seasons ${autoTrack ? "" : "not "}tracked automatically`;
|
|
panel.replaceChildren(done);
|
|
done.focus();
|
|
row.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<void>;
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
function movieMain(views: HideableView[]): MovieView {
|
|
const view = must<HTMLElement>("#movie");
|
|
const deckEl = must<HTMLElement>("#deck");
|
|
const back = must<HTMLButtonElement>("#movie-back");
|
|
const statusEl = must<HTMLElement>("#movie-status");
|
|
const hero = must<HTMLElement>("#movie-hero");
|
|
const poster = must<HTMLImageElement>("#movie-poster");
|
|
const titleEl = must<HTMLElement>("#movie-title");
|
|
const yearEl = must<HTMLElement>("#movie-year");
|
|
const chipsEl = must<HTMLElement>("#movie-chips");
|
|
const ratingEl = must<HTMLElement>("#movie-rating");
|
|
const metaEl = must<HTMLElement>("#movie-meta");
|
|
const taglineEl = must<HTMLElement>("#movie-tagline");
|
|
const overviewEl = must<HTMLElement>("#movie-overview");
|
|
const actionsEl = must<HTMLElement>("#movie-actions");
|
|
const wanted = must<HTMLButtonElement>("#movie-wanted");
|
|
const blocked = must<HTMLButtonElement>("#movie-blocked");
|
|
const rootSelect = must<HTMLSelectElement>("#movie-root");
|
|
const sweep = must<HTMLButtonElement>("#movie-sweep");
|
|
const remove = must<HTMLButtonElement>("#movie-remove");
|
|
const removeWrap = must<HTMLElement>("#remove-panel");
|
|
const filesSection = must<HTMLElement>("#movie-files");
|
|
const diskCount = must<HTMLElement>("#count-disk");
|
|
const diskRows = must<HTMLUListElement>("#rows-disk");
|
|
const releaseStatus = must<HTMLElement>("#releases-status");
|
|
const eligible: BucketsDom["eligible"] = {
|
|
section: must<HTMLElement>("#bucket-eligible"),
|
|
count: must<HTMLElement>("#count-eligible"),
|
|
rows: must<HTMLUListElement>("#rows-eligible"),
|
|
};
|
|
const collapsed = {
|
|
waived: {
|
|
section: must<HTMLElement>("#bucket-waived"),
|
|
toggle: must<HTMLButtonElement>("#toggle-waived"),
|
|
wrap: must<HTMLElement>("#wrap-waived"),
|
|
rows: must<HTMLUListElement>("#rows-waived"),
|
|
},
|
|
rejected: {
|
|
section: must<HTMLElement>("#bucket-rejected"),
|
|
toggle: must<HTMLButtonElement>("#toggle-rejected"),
|
|
wrap: must<HTMLElement>("#wrap-rejected"),
|
|
rows: must<HTMLUListElement>("#rows-rejected"),
|
|
},
|
|
} as const;
|
|
|
|
let 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: "library" };
|
|
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 !== "eligible") {
|
|
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(mediaStateChip(movie.state, movie.wanted));
|
|
// §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();
|
|
}
|
|
|
|
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)),
|
|
);
|
|
}
|
|
}
|
|
|
|
/* ---- 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(
|
|
{
|
|
title: movie.title,
|
|
files: () => movieFiles(movie.id),
|
|
folder: libraryFolder,
|
|
remove: () => removeMovie(movie.id),
|
|
seedLine: "the torrent keeps seeding until its tracker rule clears.",
|
|
},
|
|
{
|
|
cancel: closeRemove,
|
|
removed: () => {
|
|
const parent = parentRoute;
|
|
clearRemove();
|
|
close();
|
|
removed?.(parent);
|
|
},
|
|
},
|
|
);
|
|
removeWrap.replaceChildren(panel);
|
|
removeWrap.hidden = false;
|
|
remove.setAttribute("aria-expanded", "true");
|
|
// cancel takes focus, not the destructive action: no toggle is left to arm first
|
|
panel.querySelector<HTMLElement>(".remove-actions .control:last-child")?.focus();
|
|
}
|
|
|
|
remove.addEventListener("click", () => {
|
|
if (removeWrap.hidden) {
|
|
openRemove();
|
|
} else {
|
|
closeRemove();
|
|
}
|
|
});
|
|
|
|
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;
|
|
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 slice of a file the removal panel reads: evidence, nothing else. */
|
|
interface RemoveFile {
|
|
path: string;
|
|
size: number;
|
|
}
|
|
|
|
/**
|
|
* What the panel needs to know about a title (issue 175): a movie and a
|
|
* series differ only in which endpoints they call, how their files roll up
|
|
* to one folder, and how many torrents the §7.3 warning speaks of.
|
|
*/
|
|
interface RemoveSubject {
|
|
title: string;
|
|
files: () => Promise<{ kind: "files"; files: RemoveFile[] } | { kind: "error"; detail: string }>;
|
|
/** The one folder the delete takes, when the files agree on it. */
|
|
folder: (files: RemoveFile[]) => string | null;
|
|
remove: () => Promise<ActionOutcome>;
|
|
/** What does not happen: seeding continues under its own rule (§7.3). */
|
|
seedLine: string;
|
|
}
|
|
|
|
/**
|
|
* 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:
|
|
* seeding continues under its own rule (§7.3).
|
|
*/
|
|
function removePanel(subject: RemoveSubject, actions: RemoveActions): HTMLElement {
|
|
const panel = document.createElement("div");
|
|
panel.className = "remove-body";
|
|
panel.setAttribute("role", "group");
|
|
panel.setAttribute("aria-label", `remove ${subject.title}`);
|
|
|
|
let files: RemoveFile[] | null = null;
|
|
let folderNamed = false;
|
|
|
|
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 = `${folderNamed ? "deletes the folder above." : "deletes the files above."} ${subject.seedLine}`;
|
|
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 subject.remove().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 subject.files().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 = subject.folder(files);
|
|
folderNamed = folder !== null;
|
|
path.hidden = false;
|
|
path.textContent = folder ?? files.map((file) => file.path).join("\n");
|
|
paint();
|
|
});
|
|
|
|
const controls = document.createElement("div");
|
|
controls.className = "remove-actions";
|
|
controls.append(confirm, cancel);
|
|
paint();
|
|
// evidence, then what removing it costs
|
|
panel.append(evidence, path, note, controls);
|
|
return panel;
|
|
}
|
|
|
|
interface ReleaseActions {
|
|
reload: () => Promise<void>;
|
|
notify: (text: string, tone?: "fault") => void;
|
|
/** One click on grab — a plain grab, or waive-then-grab where the rule
|
|
* that failed has an override (§9.3). */
|
|
grab: (
|
|
release: MovieRelease,
|
|
bucket: "eligible" | "waived" | "rejected",
|
|
) => Promise<WaiveOutcome>;
|
|
}
|
|
|
|
/** Stops in the score heat scale — keep in step with the --score-* tokens. */
|
|
const SCORE_STOPS = 6;
|
|
|
|
type ScoreStopper = (score: number | null) => number | null;
|
|
|
|
/**
|
|
* Score colour is relative to one list (issue 111): highest green, lowest
|
|
* red, seven interpolated stops between.
|
|
*/
|
|
function scoreStopper(releases: MovieRelease[]): ScoreStopper {
|
|
let min = Number.POSITIVE_INFINITY;
|
|
let max = Number.NEGATIVE_INFINITY;
|
|
for (const release of releases) {
|
|
if (release.score !== null) {
|
|
if (release.score < min) {
|
|
min = release.score;
|
|
}
|
|
if (release.score > max) {
|
|
max = release.score;
|
|
}
|
|
}
|
|
}
|
|
return (score: number | null): number | null => {
|
|
if (score === null || min > max) {
|
|
return null;
|
|
}
|
|
// all-equal scores are jointly the best the list offers
|
|
return max === min ? SCORE_STOPS : Math.round(((score - min) / (max - min)) * SCORE_STOPS);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Splits classified releases into the three buckets and paints them. The
|
|
* API pre-sorts by bucket then score; splitting preserves order. Returns
|
|
* false when there was nothing to paint — the caller owns the empty state,
|
|
* which differs per deck.
|
|
*/
|
|
function paintBuckets(dom: BucketsDom, releases: MovieRelease[], actions: ReleaseActions): boolean {
|
|
clearBuckets(dom);
|
|
if (releases.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const scoreStop = scoreStopper(releases);
|
|
const buckets = {
|
|
eligible: releases.filter((release) => bucketOf(release) === "eligible"),
|
|
waived: releases.filter((release) => bucketOf(release) === "waived"),
|
|
rejected: releases.filter((release) => bucketOf(release) === "rejected"),
|
|
};
|
|
|
|
dom.eligible.section.hidden = false;
|
|
dom.eligible.count.textContent = String(buckets.eligible.length);
|
|
if (buckets.eligible.length > 0) {
|
|
for (const release of buckets.eligible) {
|
|
dom.eligible.rows.append(releaseRow(release, "eligible", scoreStop, actions));
|
|
}
|
|
} else {
|
|
// §9.3: over-strict filters must be visible, not silently absent — and
|
|
// where a rule can be waived, the count says so rather than leaving the
|
|
// way out folded inside a collapsed bucket.
|
|
const waivable = releases.filter(overridable).length;
|
|
const none = document.createElement("li");
|
|
none.className = "rel rel-none readout dim";
|
|
none.textContent =
|
|
waivable > 0
|
|
? `none — every candidate was waived or rejected by policy; ${waivable} can be waived`
|
|
: "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") {
|
|
// A rejected row always names its rule; a waived one cannot — the
|
|
// `releases` CHECK allows `rejected_rule` only on a rejection. Say
|
|
// `waived` plainly rather than calling a classified row unclassified.
|
|
const verdict = release.rejected_rule
|
|
? `${bucket} · ${ruleLabel(release.rejected_rule)}`
|
|
: bucket;
|
|
line.append(
|
|
chip(verdict, (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;
|
|
|
|
// A rejected row is normally inert, but §9.3's one click is exactly for
|
|
// the rule the operator disagrees with: where an override exists, the row
|
|
// offers it and the grab that follows stays a waiver.
|
|
const writesOverride = bucket !== "eligible" && overridable(release);
|
|
if (bucket !== "rejected" || writesOverride) {
|
|
const grab = document.createElement("button");
|
|
grab.type = "button";
|
|
grab.className = "control rel-grab";
|
|
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(
|
|
movieDetail: MovieView,
|
|
seriesDetail: SeriesView,
|
|
views: HideableView[],
|
|
): LibraryView {
|
|
const view = must<HTMLElement>("#library");
|
|
const deck = must<HTMLElement>("#deck");
|
|
const nav = must<HTMLButtonElement>("#nav-library");
|
|
const summary = must<HTMLElement>("#library-summary");
|
|
const toggle = must<HTMLButtonElement>("#library-toggle");
|
|
const status = must<HTMLElement>("#library-status");
|
|
const gridBtn = must<HTMLButtonElement>("#library-view-grid");
|
|
const listBtn = must<HTMLButtonElement>("#library-view-list");
|
|
const groups: { series: LibraryGroup; movies: LibraryGroup } = {
|
|
series: {
|
|
section: must<HTMLElement>("#library-series"),
|
|
count: must<HTMLElement>("#count-lib-series"),
|
|
rows: must<HTMLUListElement>("#rows-lib-series"),
|
|
},
|
|
movies: {
|
|
section: must<HTMLElement>("#library-movies"),
|
|
count: must<HTMLElement>("#count-lib-movies"),
|
|
rows: must<HTMLUListElement>("#rows-lib-movies"),
|
|
},
|
|
};
|
|
|
|
// §4.2: ONE toggle for everything the default view leaves out
|
|
let showAll = false;
|
|
// §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<T>(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();
|
|
}
|
|
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;
|
|
}
|
|
|
|
// §9.7: the library is the homepage — the bottom layer. There is nothing
|
|
// beneath it, so the nav control never toggles off and Esc has no handler.
|
|
nav.addEventListener("click", () => {
|
|
if (view.hidden) {
|
|
navigate({ kind: "library" });
|
|
open();
|
|
}
|
|
});
|
|
|
|
toggle.addEventListener("click", () => {
|
|
showAll = !showAll;
|
|
render();
|
|
});
|
|
|
|
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(
|
|
countsChip(
|
|
`${series.available_episodes}/${series.wanted_episodes} eps`,
|
|
series.available_episodes,
|
|
series.wanted_episodes,
|
|
`${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(
|
|
countsChip(
|
|
`${series.available_episodes}/${series.wanted_episodes} eps`,
|
|
series.available_episodes,
|
|
series.wanted_episodes,
|
|
`${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(mediaStateChip(movie.state, movie.wanted));
|
|
// §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<HTMLElement>("#tv-releases");
|
|
const back = must<HTMLButtonElement>("#tv-releases-back");
|
|
const title = must<HTMLElement>("#tv-releases-title");
|
|
const sub = must<HTMLElement>("#tv-releases-sub");
|
|
const sweep = must<HTMLButtonElement>("#tv-releases-sweep");
|
|
const statusEl = must<HTMLElement>("#tv-releases-status");
|
|
const dom = buildBucketDom(must<HTMLElement>("#tv-buckets"));
|
|
|
|
let request: TvDeckRequest | null = null;
|
|
// guards a stale fetch from painting over a newer view
|
|
let sequence = 0;
|
|
let pollTimer: number | undefined;
|
|
|
|
function setStatus(text: string | null, tone?: "fault", busy = false) {
|
|
statusEl.hidden = text === null;
|
|
if (busy && text !== null) {
|
|
const lamp = document.createElement("span");
|
|
lamp.className = "lamp";
|
|
lamp.dataset.state = "probing";
|
|
statusEl.replaceChildren(lamp, document.createTextNode(text));
|
|
} else {
|
|
statusEl.textContent = text ?? "";
|
|
}
|
|
if (tone) {
|
|
statusEl.dataset.tone = tone;
|
|
} else {
|
|
delete statusEl.dataset.tone;
|
|
}
|
|
delete statusEl.dataset.action;
|
|
}
|
|
|
|
/**
|
|
* A status that carries its own way out. A season held off the pack lane
|
|
* cannot be helped by the head's re-search alone (#181, #182), so the
|
|
* sentence that explains the wait also offers the retry that ends it.
|
|
*/
|
|
function setStatusAction(text: string, label: string, run: () => void) {
|
|
const action = document.createElement("button");
|
|
action.type = "button";
|
|
action.className = "control";
|
|
action.textContent = label;
|
|
action.addEventListener("click", run);
|
|
statusEl.hidden = false;
|
|
statusEl.replaceChildren(document.createTextNode(text), action);
|
|
delete statusEl.dataset.tone;
|
|
statusEl.dataset.action = "";
|
|
}
|
|
|
|
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 !== "eligible") {
|
|
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(sweepIfEmpty = false) {
|
|
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)) {
|
|
clearBuckets(dom);
|
|
await emptyVerdict(current, ticket, sweepIfEmpty);
|
|
return;
|
|
}
|
|
setStatus(null);
|
|
}
|
|
|
|
/**
|
|
* An empty season deck is three different truths (#182), and until the
|
|
* season could say which, it blamed backoff for all three: a pack sweep
|
|
* still running, a pack sweep that ran and found nothing, and a season on
|
|
* the per-episode lane, where no pack sweep is coming at all. The lane
|
|
* answers the third; `last_pack_search_at` separates the first two, the
|
|
* same way a movie's `last_searched_at` does (#177).
|
|
*/
|
|
async function emptyVerdict(current: TvDeckRequest, ticket: number, sweepIfEmpty: boolean) {
|
|
const outcome = await current.target.packState?.();
|
|
if (ticket !== sequence || request !== current) {
|
|
return;
|
|
}
|
|
if (outcome?.kind === "state" && outcome.state.lane === "per_episode") {
|
|
sweep.disabled = false;
|
|
describePerEpisode(current, outcome.state);
|
|
return;
|
|
}
|
|
if (outcome?.kind === "state" && outcome.state.last_pack_search_at !== null) {
|
|
sweep.disabled = false;
|
|
setStatus(
|
|
`no season pack found — indexers last swept ${formatSweepAge(outcome.state.last_pack_search_at)}; re-search runs a new one`,
|
|
);
|
|
return;
|
|
}
|
|
// issue 167: an empty deck sweeps on open instead of describing a sweep
|
|
if (sweepIfEmpty) {
|
|
void startSweep(current, outcome?.kind === "state" ? outcome.state : null);
|
|
return;
|
|
}
|
|
setStatus("no releases indexed yet — re-search queues a targeted sweep");
|
|
}
|
|
|
|
/**
|
|
* Name the lane instead of the backoff. A season grabbing episode by
|
|
* episode has no pack to show and never will while the reason holds, so
|
|
* the deck says which reason it is and where the releases actually are.
|
|
*/
|
|
function describePerEpisode(current: TvDeckRequest, state: SeasonPackState) {
|
|
const elsewhere = "open an episode for its releases";
|
|
if (state.reason === "no_episodes") {
|
|
setStatus(
|
|
"no episodes known for this season yet — a metadata refresh has to find them before anything can be searched",
|
|
);
|
|
return;
|
|
}
|
|
if (state.reason === "still_airing") {
|
|
setStatus(
|
|
`season still airing — a pack is only searched once every episode has aired, so this one is grabbed episode by episode; ${elsewhere}`,
|
|
);
|
|
return;
|
|
}
|
|
if (state.reason === "episodes_on_disk") {
|
|
setStatus(
|
|
`episodes already on disk — a pack would re-import them, so the rest is grabbed episode by episode; ${elsewhere}`,
|
|
);
|
|
return;
|
|
}
|
|
const failures =
|
|
state.pack_failures === 1 ? "1 failed pack grab" : `${state.pack_failures} failed pack grabs`;
|
|
const quiet =
|
|
state.pack_retry_at === null
|
|
? "pack search is quiet until its backoff elapses"
|
|
: `pack search is quiet for another ${formatRetryWait(state.pack_retry_at)}, then retries on its own`;
|
|
setStatusAction(
|
|
`${failures} — ${quiet}. episodes are grabbed one at a time meanwhile.`,
|
|
"retry the pack now",
|
|
() => {
|
|
void startSweep(current, state);
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A sweep is done when its releases appear, or when the season stamps the
|
|
* pack search it just finished. `baseline` is that stamp as it read before
|
|
* the sweep was queued: once it moves, an empty deck is a settled answer
|
|
* rather than a pending one, and `load` says so.
|
|
*/
|
|
function watchSweep(current: TvDeckRequest, baseline: string | null, 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;
|
|
}
|
|
const state = await current.target.packState?.();
|
|
if (ticket !== sequence || request !== current) {
|
|
return;
|
|
}
|
|
if (state?.kind === "state" && state.state.last_pack_search_at !== baseline) {
|
|
sweep.disabled = false;
|
|
await emptyVerdict(current, ticket, false);
|
|
return;
|
|
}
|
|
if (Date.now() >= deadline) {
|
|
sweep.disabled = false;
|
|
setStatus(
|
|
"sweep has not landed yet — nothing has come back from the indexers; results appear here when it does",
|
|
);
|
|
return;
|
|
}
|
|
pollTimer = window.setTimeout(() => {
|
|
void tick();
|
|
}, TV_SWEEP_POLL_MS);
|
|
};
|
|
pollTimer = window.setTimeout(() => {
|
|
void tick();
|
|
}, TV_SWEEP_POLL_MS);
|
|
}
|
|
|
|
/**
|
|
* Queue a targeted sweep, then watch for it to land (§6.2). `known` is the
|
|
* lane as it read a moment ago, so the watch is only entered when a pack
|
|
* sweep is actually expected: on the per-episode lane the sweep searches
|
|
* episodes, and waiting for a pack that is not coming is the lie #182 is
|
|
* about. A failed pack is the exception — a manual search waives its
|
|
* window and does try a pack (#181).
|
|
*/
|
|
async function startSweep(current: TvDeckRequest, known?: SeasonPackState | null) {
|
|
sweep.disabled = true;
|
|
const before =
|
|
known === undefined
|
|
? await current.target.packState?.().then((it) => (it.kind === "state" ? it.state : null))
|
|
: known;
|
|
if (request !== current) {
|
|
return;
|
|
}
|
|
const outcome = await current.target.search();
|
|
if (request !== current) {
|
|
return;
|
|
}
|
|
sequence += 1;
|
|
window.clearTimeout(pollTimer);
|
|
if (outcome.kind === "error") {
|
|
sweep.disabled = false;
|
|
setStatus(`search failed — ${outcome.detail}`, "fault");
|
|
return;
|
|
}
|
|
if (before && before.lane === "per_episode" && before.reason !== "pack_backoff") {
|
|
sweep.disabled = false;
|
|
setStatus("searching the season's episodes — open an episode for its releases");
|
|
return;
|
|
}
|
|
watchSweep(current, before?.last_pack_search_at ?? null, sequence);
|
|
}
|
|
|
|
sweep.addEventListener("click", () => {
|
|
const current = request;
|
|
if (!current) {
|
|
return;
|
|
}
|
|
void startSweep(current);
|
|
});
|
|
|
|
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(true);
|
|
}
|
|
|
|
function hide() {
|
|
view.hidden = true;
|
|
request = null;
|
|
sequence += 1;
|
|
window.clearTimeout(pollTimer);
|
|
}
|
|
|
|
function close() {
|
|
const current = request;
|
|
navigate(current?.parentRoute ?? { kind: "library" });
|
|
hide();
|
|
if (current) {
|
|
current.returnTo.hidden = false;
|
|
current.origin.focus();
|
|
}
|
|
}
|
|
|
|
back.addEventListener("click", close);
|
|
|
|
// capture + stopImmediatePropagation: registered before the series view's
|
|
// own Escape listener, so one Esc steps back one layer — deck first
|
|
window.addEventListener(
|
|
"keydown",
|
|
(event) => {
|
|
if (event.key !== "Escape" || view.hidden) {
|
|
return;
|
|
}
|
|
event.stopImmediatePropagation();
|
|
close();
|
|
},
|
|
true,
|
|
);
|
|
|
|
return { open, hide };
|
|
}
|
|
|
|
/* ---- series detail view (§4.1, §4.2, issue issue 129) ----------------------- */
|
|
|
|
interface SeriesView {
|
|
hide: () => void;
|
|
open: (
|
|
seriesId: number,
|
|
origin: HTMLElement,
|
|
returnTo: HTMLElement,
|
|
parentRoute: Route,
|
|
) => Promise<void>;
|
|
/** Where to land once the title is gone — same contract as a movie. */
|
|
setRemoved: (handler: (parent: Route) => void) => void;
|
|
}
|
|
|
|
const PAD_TWO = (value: number): string => String(value).padStart(2, "0");
|
|
|
|
/**
|
|
* Issue #177: the on-demand refresh a new series' add sends (#176) lands
|
|
* within seconds. Poll modestly and give up bounded so a page left open
|
|
* does not spin forever.
|
|
*/
|
|
const SERIES_REFRESH_POLL_MS = 2000;
|
|
const SERIES_REFRESH_WAIT_MS = 30_000;
|
|
|
|
function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|
const view = must<HTMLElement>("#series");
|
|
const deckEl = must<HTMLElement>("#deck");
|
|
const back = must<HTMLButtonElement>("#series-back");
|
|
const hero = must<HTMLElement>("#series-hero");
|
|
const poster = must<HTMLImageElement>("#series-poster");
|
|
const titleEl = must<HTMLElement>("#series-title");
|
|
const yearEl = must<HTMLElement>("#series-year");
|
|
const chipsEl = must<HTMLElement>("#series-chips");
|
|
const ratingEl = must<HTMLElement>("#series-rating");
|
|
const metaEl = must<HTMLElement>("#series-meta");
|
|
const taglineEl = must<HTMLElement>("#series-tagline");
|
|
const overviewEl = must<HTMLElement>("#series-overview");
|
|
const actionsEl = must<HTMLElement>("#series-actions");
|
|
const statusEl = must<HTMLElement>("#series-status");
|
|
const seasonsList = must<HTMLUListElement>("#rows-seasons");
|
|
const remove = must<HTMLButtonElement>("#series-remove");
|
|
const removeWrap = must<HTMLElement>("#series-remove-panel");
|
|
|
|
let roots: Root[] = [];
|
|
let series: ApiSeries | null = null;
|
|
let seasons: ApiSeason[] | null = null;
|
|
let filesByEpisode = new Map<number, EpisodeFile>();
|
|
// which seasons stand open survives the refetch every action triggers
|
|
let expanded = new Set<number>();
|
|
let origin: HTMLElement | null = null;
|
|
let returnTo: HTMLElement = deckEl;
|
|
let parentRoute: Route = { kind: "library" };
|
|
let seriesId: number | null = null;
|
|
// the control to refocus once the refetch a click triggered repainted
|
|
let focusKey: string | null = null;
|
|
// guards a stale fetch from painting over a newer view
|
|
let sequence = 0;
|
|
let removed: ((parent: Route) => void) | null = null;
|
|
let refreshPollTimer: number | undefined;
|
|
// set once the bounded poll for a pending metadata refresh times out
|
|
let refreshGaveUp = false;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/* ---- removal confirmation: the movie panel, generalised (issue 175) ---- */
|
|
|
|
/** 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 current = series;
|
|
const id = seriesId;
|
|
if (!current || id === null) {
|
|
return;
|
|
}
|
|
const panel = removePanel(
|
|
{
|
|
title: current.title,
|
|
files: () => fetchSeriesFiles(id),
|
|
folder: seriesFolder,
|
|
remove: () => removeSeries(id),
|
|
seedLine: "torrents keep seeding until their tracker rules clear.",
|
|
},
|
|
{
|
|
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 (same as a movie)
|
|
panel.querySelector<HTMLElement>(".remove-actions .control:last-child")?.focus();
|
|
}
|
|
|
|
remove.addEventListener("click", () => {
|
|
if (removeWrap.hidden) {
|
|
openRemove();
|
|
} else {
|
|
closeRemove();
|
|
}
|
|
});
|
|
|
|
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(
|
|
countsChip(
|
|
`${current.available_episodes}/${current.wanted_episodes} eps`,
|
|
current.available_episodes,
|
|
current.wanted_episodes,
|
|
`${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();
|
|
}
|
|
|
|
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)),
|
|
);
|
|
}
|
|
}
|
|
|
|
function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
|
|
const counts = seasonCounts(season);
|
|
return countsChip(
|
|
`${counts.available}/${counts.wanted} on disk`,
|
|
counts.available,
|
|
counts.wanted,
|
|
`${counts.available} of ${counts.wanted} wanted episodes on disk`,
|
|
);
|
|
}
|
|
|
|
function seasonRow(season: ApiSeason): HTMLLIElement {
|
|
const item = document.createElement("li");
|
|
item.className = "season";
|
|
|
|
const line = document.createElement("div");
|
|
line.className = "season-line";
|
|
|
|
const isOpen = expanded.has(season.number);
|
|
const disclose = document.createElement("button");
|
|
disclose.type = "button";
|
|
disclose.className = "season-disclose";
|
|
disclose.setAttribute("aria-expanded", String(isOpen));
|
|
disclose.setAttribute("aria-controls", `episodes-${season.number}`);
|
|
disclose.setAttribute("aria-label", `season ${PAD_TWO(season.number)} episodes`);
|
|
disclose.addEventListener("click", () => {
|
|
const nowOpen = !expanded.has(season.number);
|
|
if (nowOpen) {
|
|
expanded.add(season.number);
|
|
} else {
|
|
expanded.delete(season.number);
|
|
}
|
|
disclose.setAttribute("aria-expanded", String(nowOpen));
|
|
const list = item.querySelector<HTMLElement>(`#episodes-${season.number}`);
|
|
if (list) {
|
|
list.hidden = !nowOpen;
|
|
}
|
|
disclose.focus();
|
|
});
|
|
|
|
// season 0 never counts toward tracking or totals (§4.1, §4.2); say so
|
|
const name = document.createElement("span");
|
|
name.className = "season-name";
|
|
name.textContent = season.number === 0 ? "specials · s00" : `season ${PAD_TWO(season.number)}`;
|
|
|
|
const track = document.createElement("button");
|
|
track.type = "button";
|
|
track.className = "control control-quiet";
|
|
track.dataset.focusId = `track-${season.number}`;
|
|
track.setAttribute("aria-pressed", String(season.tracked));
|
|
track.textContent = "tracked";
|
|
track.addEventListener("click", () => {
|
|
if (seriesId === null) {
|
|
return;
|
|
}
|
|
track.disabled = true;
|
|
const wantedNext = !season.tracked;
|
|
void setSeasonTracked(seriesId, season.number, wantedNext).then((outcome) => {
|
|
if (outcome.kind === "error") {
|
|
track.disabled = false;
|
|
setStatus(`tracking failed — ${outcome.detail}`, "fault");
|
|
return;
|
|
}
|
|
// §4.1: turning tracking on marks revealed episodes wanted; turning
|
|
// it off withdraws nothing. The refetch paints both truths.
|
|
focusKey = `track-${season.number}`;
|
|
void load();
|
|
});
|
|
});
|
|
|
|
const space = document.createElement("span");
|
|
space.className = "season-space";
|
|
|
|
const 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, deckBtn);
|
|
// #174: files go, episodes stop being wanted, the season stays listed.
|
|
// Only offered with files on disk — intent alone is the tracked toggle.
|
|
if (season.episodes.some((episode) => filesByEpisode.has(episode.id))) {
|
|
const clear = armedDelete("remove files", () => {
|
|
const currentId = seriesId;
|
|
if (currentId === null) {
|
|
return;
|
|
}
|
|
void removeSeasonFiles(currentId, season.number).then((outcome) => {
|
|
if (outcome.kind === "error") {
|
|
clear.disabled = false;
|
|
setStatus(`remove failed — ${outcome.detail}`, "fault");
|
|
return;
|
|
}
|
|
// the control itself disappears with the files; the tracked
|
|
// toggle is the season's control that survives the repaint
|
|
focusKey = `track-${season.number}`;
|
|
void load();
|
|
});
|
|
});
|
|
clear.setAttribute(
|
|
"aria-label",
|
|
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
|
|
);
|
|
line.append(clear);
|
|
}
|
|
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(mediaStateChip(episode.state, episode.wanted));
|
|
// 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) {
|
|
// #174: the file goes and the episode stops being wanted; the row
|
|
// stays listed. Same arm-then-confirm as a settings row.
|
|
const clear = armedDelete("remove file", () => {
|
|
void removeEpisodeFiles(episode.id).then((outcome) => {
|
|
if (outcome.kind === "error") {
|
|
clear.disabled = false;
|
|
setStatus(`remove failed — ${outcome.detail}`, "fault");
|
|
return;
|
|
}
|
|
// once missing, the row's want control is what remains to focus
|
|
focusKey = `want-${episode.id}`;
|
|
void load();
|
|
});
|
|
});
|
|
clear.setAttribute(
|
|
"aria-label",
|
|
`remove the ${episodeTag(seasonNumber, episode.number)} file from disk and stop wanting the episode — it stays listed`,
|
|
);
|
|
actions.append(clear);
|
|
} 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 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;
|
|
}
|
|
|
|
/**
|
|
* A never-refreshed series (`metadata_refreshed_at` null) reads as a
|
|
* refresh under way, since #176 queues one on add and it lands within
|
|
* seconds. A refreshed series with no seasons is a settled, if rare,
|
|
* empty state upstream — the two must not share a message (#177).
|
|
*/
|
|
function seasonsEmptyRow(): HTMLLIElement {
|
|
const item = document.createElement("li");
|
|
item.className = "rel rel-none readout dim";
|
|
if (series !== null && series.metadata_refreshed_at === null) {
|
|
if (refreshGaveUp) {
|
|
item.append(document.createTextNode("metadata refresh has not landed yet — "));
|
|
const retry = document.createElement("button");
|
|
retry.type = "button";
|
|
retry.className = "control control-quiet";
|
|
retry.textContent = "retry";
|
|
retry.addEventListener("click", () => {
|
|
retry.disabled = true;
|
|
void retryRefresh();
|
|
});
|
|
item.append(retry);
|
|
} else {
|
|
const lamp = document.createElement("span");
|
|
lamp.className = "lamp";
|
|
lamp.dataset.state = "probing";
|
|
item.append(
|
|
lamp,
|
|
document.createTextNode(" refreshing metadata — seasons will appear here"),
|
|
);
|
|
}
|
|
} else {
|
|
item.textContent = "no seasons listed upstream";
|
|
}
|
|
return item;
|
|
}
|
|
|
|
function renderSeasons() {
|
|
seasonsList.replaceChildren();
|
|
if (!seasons) {
|
|
return;
|
|
}
|
|
for (const season of seasons) {
|
|
seasonsList.append(seasonRow(season));
|
|
}
|
|
if (seasons.length === 0) {
|
|
seasonsList.append(seasonsEmptyRow());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Poll while a refresh is pending, painting seasons as soon as they land
|
|
* with no manual reload. Stops on arrival, on the refresh being recorded
|
|
* done with none, or once `SERIES_REFRESH_WAIT_MS` passes — a page left
|
|
* open must not poll forever (#177).
|
|
*/
|
|
function syncRefreshWatch(ticket: number) {
|
|
window.clearTimeout(refreshPollTimer);
|
|
const id = seriesId;
|
|
if (id === null || series === null || seasons === null) {
|
|
return;
|
|
}
|
|
const pending = series.metadata_refreshed_at === null && seasons.length === 0;
|
|
if (!pending || refreshGaveUp) {
|
|
return;
|
|
}
|
|
const deadline = Date.now() + SERIES_REFRESH_WAIT_MS;
|
|
const tick = async () => {
|
|
if (ticket !== sequence || seriesId !== id) {
|
|
return;
|
|
}
|
|
const [detail, seasonsOutcome] = await Promise.all([fetchSeries(id), fetchSeasons(id)]);
|
|
if (ticket !== sequence || seriesId !== id) {
|
|
return;
|
|
}
|
|
if (detail && seasonsOutcome.kind === "seasons") {
|
|
series = detail;
|
|
seasons = seasonsOutcome.seasons;
|
|
paintHeader();
|
|
if (series.metadata_refreshed_at !== null || seasons.length > 0) {
|
|
renderSeasons();
|
|
return;
|
|
}
|
|
}
|
|
if (Date.now() >= deadline) {
|
|
refreshGaveUp = true;
|
|
renderSeasons();
|
|
return;
|
|
}
|
|
refreshPollTimer = window.setTimeout(() => {
|
|
void tick();
|
|
}, SERIES_REFRESH_POLL_MS);
|
|
};
|
|
refreshPollTimer = window.setTimeout(() => {
|
|
void tick();
|
|
}, SERIES_REFRESH_POLL_MS);
|
|
}
|
|
|
|
/** The empty state's retry control: resend the on-demand command by hand. */
|
|
async function retryRefresh() {
|
|
const id = seriesId;
|
|
if (id === null) {
|
|
return;
|
|
}
|
|
const ticket = sequence;
|
|
refreshGaveUp = false;
|
|
renderSeasons();
|
|
const outcome = await refreshSeriesMetadata(id);
|
|
if (ticket !== sequence || seriesId !== id) {
|
|
return;
|
|
}
|
|
if (outcome.kind === "error") {
|
|
refreshGaveUp = true;
|
|
setStatus(`refresh failed — ${outcome.detail}`, "fault");
|
|
renderSeasons();
|
|
return;
|
|
}
|
|
syncRefreshWatch(ticket);
|
|
}
|
|
|
|
async function load() {
|
|
const id = seriesId;
|
|
if (id === null) {
|
|
return;
|
|
}
|
|
sequence += 1;
|
|
const ticket = sequence;
|
|
window.clearTimeout(refreshPollTimer);
|
|
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();
|
|
syncRefreshWatch(ticket);
|
|
setStatus(null);
|
|
void loadMetadata(id, ticket);
|
|
if (focusKey !== null) {
|
|
view.querySelector<HTMLElement>(`[data-focus-id="${focusKey}"]`)?.focus();
|
|
focusKey = null;
|
|
}
|
|
}
|
|
|
|
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(),
|
|
// which nulls seriesId — hide first, then take state (same as a movie)
|
|
tvDeck.hide();
|
|
for (const sibling of views) {
|
|
sibling.hide();
|
|
}
|
|
seriesId = id;
|
|
origin = from;
|
|
returnTo = container;
|
|
parentRoute = parent;
|
|
expanded = new Set();
|
|
focusKey = null;
|
|
refreshGaveUp = false;
|
|
deckEl.hidden = true;
|
|
view.hidden = false;
|
|
clearRemove();
|
|
clearRichDetail();
|
|
back.focus();
|
|
await load();
|
|
}
|
|
|
|
function hide() {
|
|
view.hidden = true;
|
|
seriesId = null;
|
|
seasons = null;
|
|
sequence += 1;
|
|
window.clearTimeout(refreshPollTimer);
|
|
clearRemove();
|
|
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();
|
|
// the confirmation is the innermost layer: Esc abandons it first
|
|
if (!removeWrap.hidden) {
|
|
closeRemove();
|
|
return;
|
|
}
|
|
navigate(parentRoute);
|
|
close();
|
|
}
|
|
},
|
|
true,
|
|
);
|
|
|
|
return {
|
|
hide,
|
|
open,
|
|
setRemoved: (handler: (parent: Route) => void) => {
|
|
removed = handler;
|
|
},
|
|
};
|
|
}
|
|
|
|
/* ---- attention queues (§5.2 + §5.7, issue #33) ------------------------ */
|
|
|
|
interface QueuesView {
|
|
hide: () => void;
|
|
open: () => void;
|
|
/** Refetches both queues and repaints the rail badge. */
|
|
refreshBadge: () => Promise<void>;
|
|
}
|
|
|
|
interface QueueGroup {
|
|
section: HTMLElement;
|
|
count: HTMLElement;
|
|
rows: HTMLUListElement;
|
|
}
|
|
|
|
function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () => void): QueuesView {
|
|
const view = must<HTMLElement>("#queues");
|
|
const deck = must<HTMLElement>("#deck");
|
|
const nav = must<HTMLButtonElement>("#nav-queues");
|
|
const badge = must<HTMLElement>("#queues-count");
|
|
const summary = must<HTMLElement>("#queues-summary");
|
|
const status = must<HTMLElement>("#queues-status");
|
|
const groups: { noPt: QueueGroup; decision: QueueGroup } = {
|
|
noPt: {
|
|
section: must<HTMLElement>("#queue-no-pt"),
|
|
count: must<HTMLElement>("#count-no-pt"),
|
|
rows: must<HTMLUListElement>("#rows-no-pt"),
|
|
},
|
|
decision: {
|
|
section: must<HTMLElement>("#queue-decision"),
|
|
count: must<HTMLElement>("#count-decision"),
|
|
rows: must<HTMLUListElement>("#rows-decision"),
|
|
},
|
|
};
|
|
|
|
let roots: Root[] = [];
|
|
// guards a stale fetch from painting over a newer view
|
|
let sequence = 0;
|
|
|
|
function setStatus(text: string | null, tone?: "fault") {
|
|
status.hidden = text === null;
|
|
status.textContent = text ?? "";
|
|
if (tone) {
|
|
status.dataset.tone = tone;
|
|
} else {
|
|
delete status.dataset.tone;
|
|
}
|
|
}
|
|
|
|
function clearGroups() {
|
|
for (const group of [groups.noPt, groups.decision]) {
|
|
group.section.hidden = true;
|
|
group.rows.replaceChildren();
|
|
}
|
|
summary.textContent = "";
|
|
}
|
|
|
|
function paintBadge(total: number) {
|
|
badge.hidden = total === 0;
|
|
badge.textContent = total === 0 ? "" : String(total);
|
|
}
|
|
|
|
function emptyLine(group: QueueGroup) {
|
|
if (group.rows.childElementCount === 0) {
|
|
const none = document.createElement("li");
|
|
none.className = "queue-empty readout dim";
|
|
none.textContent = "queue empty";
|
|
group.rows.append(none);
|
|
}
|
|
}
|
|
|
|
function entryCount(group: QueueGroup): number {
|
|
return [...group.rows.children].filter((child) => !child.classList.contains("queue-empty"))
|
|
.length;
|
|
}
|
|
|
|
function paintCounts() {
|
|
let total = 0;
|
|
for (const group of [groups.noPt, groups.decision]) {
|
|
const entries = entryCount(group);
|
|
group.count.textContent = String(entries);
|
|
total += entries;
|
|
}
|
|
paintBadge(total);
|
|
summary.textContent = total === 0 ? "" : `${total} waiting`;
|
|
}
|
|
|
|
const openReleases = (movie: LibraryMovie, origin: HTMLElement) => {
|
|
navigate({ kind: "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();
|
|
}
|
|
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();
|
|
goHome();
|
|
nav.focus();
|
|
}
|
|
|
|
nav.addEventListener("click", () => {
|
|
if (view.hidden) {
|
|
navigate({ kind: "queues" });
|
|
open();
|
|
} else {
|
|
navigate({ kind: "library" });
|
|
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: "library" });
|
|
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();
|