Merge #149: movie detail page

Closes #149
This commit is contained in:
Miguel Palhas
2026-08-24 00:00:22 +01:00
7 changed files with 1181 additions and 181 deletions
+600 -140
View File
@@ -6,6 +6,21 @@ import {
seriesNeedsAttention,
waiverLabel,
} from "./library";
import {
fileName,
formatMetaLine,
formatRating,
formatVoteCount,
imdbLink,
type MetadataCastMember,
movieMetadata,
rottenTomatoesSearch,
tmdbImage,
tmdbMovieLink,
tmdbPersonLink,
updateMovie,
youtubeLink,
} from "./movie";
import {
type AttentionMovie,
type AttentionQueues,
@@ -17,6 +32,7 @@ import {
} from "./queues";
import {
bucketOf,
type FilesOutcome,
formatAudio,
formatHdr,
formatResolution,
@@ -32,6 +48,7 @@ import {
movieFiles,
movieReleases,
movieSearchState,
probedAttributeTags,
queueSearch,
removeMovie,
ruleLabel,
@@ -230,18 +247,18 @@ function main() {
// views hide each other on open; the array is shared and filled once
const views: HideableView[] = [];
const releases = releasesMain();
// the tv deck registers its Escape handler before the series view does,
// so one Esc always steps back from the innermost layer first
const tvDeck = tvReleasesMain();
const seriesDetail = seriesMain(board, tvDeck, views);
const library = libraryMain(board, releases, seriesDetail, views);
const queues = queuesMain(board, releases, views);
const movieDetail = movieMain(board, views);
const library = libraryMain(board, movieDetail, seriesDetail, views);
const queues = queuesMain(board, movieDetail, views);
const settings = settingsMain(board, views);
views.push(tvDeck, seriesDetail, library, queues, settings);
const search = searchMain(board, releases, seriesDetail, views);
// a removed title must not survive on the surface the deck opened over
releases.setRemoved((parent) => {
views.push(tvDeck, seriesDetail, movieDetail, library, queues, settings);
const search = searchMain(board, movieDetail, seriesDetail, views);
// a removed title must not survive on the surface the page opened over
movieDetail.setRemoved((parent) => {
switch (parent.kind) {
case "library":
library.open();
@@ -281,6 +298,7 @@ function main() {
case "search":
search.restore(route.query);
break;
case "movie":
case "releases": {
const movie = await fetchMovie(route.movieId);
if (!movie) {
@@ -291,9 +309,19 @@ function main() {
// no origin click to restore focus to on a deep link — the library
// rail button is the closest stand-in
library.open();
releases.open(movie, must<HTMLElement>("#nav-library"), must<HTMLElement>("#library"), {
kind: "library",
});
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": {
@@ -406,7 +434,7 @@ interface SearchView {
function searchMain(
board: HTMLElement,
releases: ReleasesView,
movieDetail: MovieView,
seriesDetail: SeriesView,
views: HideableView[],
): SearchView {
@@ -453,13 +481,21 @@ function searchMain(
});
};
/** A movie hit opens its §9.6 page (#149); the deck section is on it. */
const openMovie = (movie: LibraryMovie, origin: HTMLElement) => {
navigate({ kind: "movie", movieId: movie.id });
void movieDetail.open(movie.id, origin, refs.deck, {
kind: "search",
query: input.value.trim(),
});
};
let timer: number | undefined;
let controller: AbortController | null = null;
function showBoard() {
controller?.abort();
controller = null;
releases.hide();
for (const view of views) {
view.hide();
}
@@ -468,7 +504,6 @@ function searchMain(
}
function showDeck() {
releases.hide();
for (const view of views) {
view.hide();
}
@@ -546,12 +581,7 @@ function searchMain(
refs.groups.library.section.hidden = false;
refs.groups.library.count.textContent = String(response.library.length);
for (const movie of libraryMovies) {
refs.groups.library.rows.append(
libraryRow(movie, roots, (target, origin) => {
navigate({ kind: "releases", movieId: target.id });
releases.open(target, origin, refs.deck, { kind: "search", query: input.value.trim() });
}),
);
refs.groups.library.rows.append(libraryRow(movie, roots, openMovie));
}
for (const series of librarySeries) {
refs.groups.library.rows.append(librarySeriesRow(series, roots, openSeries));
@@ -736,7 +766,7 @@ function libraryRow(
}
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "releases";
affordance.textContent = "open";
chips.append(affordance);
row.append(rowTitle(movie.title, movie.year), chips);
row.addEventListener("click", () => {
@@ -1024,19 +1054,23 @@ function addPanel(
return panel;
}
/* ---- release deck (§9.3, issue #31) ---------------------------------- */
/* ---- movie page (§9.6, issue #149) ------------------------------------ */
interface ReleasesView {
/** Opens over `returnTo`, which back and Esc restore; `parentRoute` is where they navigate. */
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: (
movie: LibraryMovie,
movieId: number,
origin: HTMLElement,
returnTo: HTMLElement,
parentRoute: Route,
) => void;
hide: () => void;
) => Promise<void>;
/**
* Called with the deck's parent route once a title is removed, so the
* 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;
@@ -1085,16 +1119,33 @@ function wireCollapsedToggle(bucket: CollapsedBucketDom) {
});
}
function releasesMain(): ReleasesView {
const view = must<HTMLElement>("#releases");
const deck = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#releases-back");
const title = must<HTMLElement>("#releases-title");
const year = must<HTMLElement>("#releases-year");
const sweep = must<HTMLButtonElement>("#releases-sweep");
const remove = must<HTMLButtonElement>("#releases-remove");
function movieMain(board: HTMLElement, 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 status = must<HTMLElement>("#releases-status");
const castSection = must<HTMLElement>("#movie-cast");
const castRows = must<HTMLUListElement>("#rows-cast");
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"),
@@ -1115,19 +1166,21 @@ function releasesMain(): ReleasesView {
},
} 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 = deck;
let returnTo: HTMLElement | null = null;
let parentRoute: Route = { kind: "board" };
const dom: BucketsDom = { eligible, waived: collapsed.waived, rejected: collapsed.rejected };
const actions: ReleaseActions = {
reload: () => load(),
notify: (text, tone) => setStatus(text, tone),
reload: () => reloadReleases(),
notify: (text, tone) => setReleaseStatus(text, tone),
grab: async (release, bucket) => {
const movie = current;
if (!movie) {
return { kind: "error", detail: "deck closed", overrideWritten: false };
return { kind: "error", detail: "page closed", overrideWritten: false };
}
if (bucket === "waived") {
return waiveAndGrab(movie.id, release.id, release.rejected_rule);
@@ -1148,57 +1201,421 @@ function releasesMain(): ReleasesView {
const SWEEP_WAIT_MS = 150_000;
function setStatus(text: string | null, tone?: "fault", busy = false) {
status.hidden = text === null;
statusEl.hidden = text === null;
if (busy && text !== null) {
const lamp = document.createElement("span");
lamp.className = "lamp";
lamp.dataset.state = "probing";
status.replaceChildren(lamp, document.createTextNode(text));
statusEl.replaceChildren(lamp, document.createTextNode(text));
} else {
status.textContent = text ?? "";
statusEl.textContent = text ?? "";
}
if (tone) {
status.dataset.tone = tone;
statusEl.dataset.tone = tone;
} else {
delete status.dataset.tone;
delete statusEl.dataset.tone;
}
}
function render(releases: MovieRelease[]) {
/** The releases section speaks beside its own buckets, not from the page top. */
function setReleaseStatus(text: string | null, tone?: "fault", busy = false) {
releaseStatus.hidden = text === null;
if (busy && text !== null) {
const lamp = document.createElement("span");
lamp.className = "lamp";
lamp.dataset.state = "probing";
releaseStatus.replaceChildren(lamp, document.createTextNode(text));
} else {
releaseStatus.textContent = text ?? "";
}
if (tone) {
releaseStatus.dataset.tone = tone;
} else {
delete releaseStatus.dataset.tone;
}
}
/* ---- identity: stored fields first, rich detail on top ---- */
function paintIdentity() {
const movie = current;
if (!movie) {
return;
}
titleEl.textContent = movie.title;
yearEl.textContent = movie.year === null ? "" : String(movie.year);
chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === movie.root_id);
chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`));
chipsEl.append(
chip(movie.state, (span) => {
span.dataset.movieState = movie.state;
}),
);
// §5.7 honesty: a waived import is never presented as a clean match
const waiver = waiverLabel(movie.waiver);
if (waiver !== null) {
chipsEl.append(
chip(waiver, (span) => {
span.dataset.verdict = "waived";
}),
);
}
if (movie.blocked) {
chipsEl.append(chip("blocked"));
}
}
function paintRootOptions() {
rootSelect.replaceChildren();
for (const root of roots.filter((candidate) => candidate.kind === "movie")) {
const option = document.createElement("option");
option.value = String(root.id);
option.textContent = `${root.audience} · ${root.policy_name}`;
rootSelect.append(option);
}
}
function paintControls() {
const movie = current;
if (!movie) {
return;
}
wanted.setAttribute("aria-pressed", String(movie.wanted));
blocked.setAttribute("aria-pressed", String(movie.blocked));
paintRootOptions();
// a root list that does not carry the title's own root still shows it —
// a select lying by omission would make the next change move it blindly
if (rootSelect.value !== String(movie.root_id)) {
const option = document.createElement("option");
option.value = String(movie.root_id);
option.textContent = `root ${movie.root_id}`;
rootSelect.append(option);
}
rootSelect.value = String(movie.root_id);
sweep.disabled = false;
}
async function refreshMovie() {
const id = movieId;
if (id === null) {
return;
}
const fresh = await fetchMovie(id);
if (fresh === null || movieId !== id) {
return;
}
current = fresh;
paintIdentity();
paintControls();
}
wanted.addEventListener("click", () => {
const movie = current;
if (!movie) {
return;
}
wanted.disabled = true;
void updateMovie(movie.id, { wanted: !movie.wanted }).then((outcome) => {
if (outcome.kind === "error") {
wanted.disabled = false;
setStatus(`wanted failed — ${outcome.detail}`, "fault");
return;
}
void refreshMovie().then(() => {
wanted.disabled = false;
});
});
});
blocked.addEventListener("click", () => {
const movie = current;
if (!movie) {
return;
}
blocked.disabled = true;
void updateMovie(movie.id, { blocked: !movie.blocked }).then((outcome) => {
if (outcome.kind === "error") {
blocked.disabled = false;
setStatus(`blocked failed — ${outcome.detail}`, "fault");
return;
}
void refreshMovie().then(() => {
blocked.disabled = false;
});
});
});
rootSelect.addEventListener("change", () => {
const movie = current;
if (!movie) {
return;
}
const next = Number(rootSelect.value);
if (!Number.isInteger(next) || next === movie.root_id) {
return;
}
rootSelect.disabled = true;
void updateMovie(movie.id, { root_id: next }).then((outcome) => {
rootSelect.disabled = false;
if (outcome.kind === "error") {
rootSelect.value = String(movie.root_id);
setStatus(`root change failed — ${outcome.detail}`, "fault");
return;
}
void refreshMovie();
});
});
/* ---- §9.6 rich detail: one request, images hotlinked ---- */
function clearRichDetail() {
hero.classList.remove("has-backdrop");
hero.style.removeProperty("--backdrop");
poster.hidden = true;
poster.removeAttribute("src");
ratingEl.hidden = true;
ratingEl.replaceChildren();
metaEl.hidden = true;
taglineEl.hidden = true;
overviewEl.hidden = true;
actionsEl.replaceChildren();
castSection.hidden = true;
castRows.replaceChildren();
}
/** 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 castItem(member: MetadataCastMember): HTMLLIElement {
const item = document.createElement("li");
item.className = "cast-item";
const link = document.createElement("a");
link.className = "cast-link";
link.href = tmdbPersonLink(member.tmdb_id);
link.target = "_blank";
link.rel = "noreferrer";
const photo = document.createElement("span");
photo.className = "cast-photo";
const image = tmdbImage(member.profile_path, "w185");
if (image !== null) {
const img = document.createElement("img");
img.src = image;
img.alt = "";
img.loading = "lazy";
photo.append(img);
} else {
photo.classList.add("cast-photo-none");
}
const name = document.createElement("span");
name.className = "cast-name";
name.textContent = member.name;
const character = document.createElement("span");
character.className = "cast-character readout dim";
character.textContent = member.character;
link.setAttribute("aria-label", `${member.name} as ${member.character} — on TMDB`);
link.append(photo, name, character);
link.title = `${member.name}${member.character}`;
item.append(link);
return item;
}
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_count > 0) {
ratingEl.hidden = false;
ratingEl.append(starIcon(), document.createTextNode(`${formatRating(detail.vote_average)} `));
const votes = document.createElement("span");
votes.className = "dim";
votes.textContent = `(${formatVoteCount(detail.vote_count)})`;
ratingEl.append(votes);
}
const metaLine = formatMetaLine(detail.runtime, detail.genres);
if (metaLine !== "") {
metaEl.hidden = false;
metaEl.textContent = metaLine;
}
if (detail.tagline !== null && detail.tagline !== "") {
taglineEl.hidden = false;
taglineEl.textContent = `${detail.tagline}`;
}
if (detail.overview !== null && detail.overview !== "") {
overviewEl.hidden = false;
overviewEl.textContent = detail.overview;
}
// the trailer resolves from this response's own key — no second call.
// Hidden when absent rather than shown dead (§9.6).
if (detail.trailer !== null) {
const trailer = document.createElement("a");
trailer.className = "control";
trailer.href = youtubeLink(detail.trailer.youtube_key);
trailer.target = "_blank";
trailer.rel = "noreferrer";
trailer.textContent = "trailer";
actionsEl.append(trailer);
}
actionsEl.append(externalLink("tmdb", tmdbMovieLink(detail.tmdb_id)));
if (detail.imdb_id !== null) {
actionsEl.append(externalLink("imdb", imdbLink(detail.imdb_id)));
}
if (current !== null) {
actionsEl.append(
externalLink("rotten tomatoes", rottenTomatoesSearch(current.title, current.year)),
);
}
if (detail.cast.length > 0 && current !== null) {
castSection.hidden = false;
for (const member of detail.cast) {
castRows.append(castItem(member));
}
}
}
/* ---- on disk: ffprobe truth and honest waivers (§5.6, §5.7) ---- */
function paintFiles(outcome: FilesOutcome) {
diskRows.replaceChildren();
filesSection.hidden = false;
if (outcome.kind === "error") {
diskCount.textContent = "";
const row = document.createElement("li");
row.className = "rel rel-none readout";
row.dataset.tone = "fault";
row.textContent = `files unreadable — ${outcome.detail}`;
diskRows.append(row);
return;
}
const files = outcome.files;
diskCount.textContent = String(files.length);
if (files.length === 0) {
const row = document.createElement("li");
row.className = "rel rel-none readout dim";
row.textContent = "nothing on disk yet — imports land here";
diskRows.append(row);
return;
}
for (const file of files) {
const item = document.createElement("li");
item.className = "rel disk-row";
const line = document.createElement("div");
line.className = "rel-line";
const name = document.createElement("span");
name.className = "disk-name readout";
name.textContent = fileName(file.path);
name.title = file.path;
line.append(name);
for (const attribute of probedAttributeTags(file.probed)) {
line.append(chip(attribute));
}
line.append(chip(formatSize(file.size)));
const waiver = waiverLabel(file.waiver);
if (waiver !== null) {
line.append(
chip(waiver, (span) => {
span.dataset.verdict = "waived";
}),
);
}
item.append(line);
diskRows.append(item);
}
}
/* ---- releases: the §9.3 deck, unchanged, as the closing section ---- */
function render(releases: MovieRelease[]) {
if (!current) {
return;
}
if (!paintBuckets(dom, releases, actions)) {
return;
}
setStatus(null);
setReleaseStatus(null);
}
async function load() {
const movie = current;
if (!movie) {
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);
setStatus("reading releases…");
const outcome = await movieReleases(movie.id);
if (ticket !== sequence || current !== movie) {
const outcome = await movieReleases(id);
if (ticket !== sequence || movieId !== id) {
return;
}
if (outcome.kind === "error") {
if (outcome.kind === "results" && !paintBuckets(dom, outcome.releases, actions)) {
clearBuckets(dom);
setStatus(`releases unavailable — ${outcome.detail}`, "fault");
return;
}
if (outcome.releases.length === 0) {
clearBuckets(dom);
await emptyVerdict(movie, ticket);
return;
}
render(outcome.releases);
}
/**
@@ -1206,46 +1623,46 @@ function releasesMain(): ReleasesView {
* has not landed yet, or a sweep that landed and found nothing. Only the
* movie's `last_searched_at` can tell them apart.
*/
async function emptyVerdict(movie: LibraryMovie, ticket: number) {
const outcome = await movieSearchState(movie.id);
if (ticket !== sequence || current !== movie) {
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) {
setStatus(
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(movie, null, ticket);
watchSweep(id, null, ticket);
return;
}
setStatus("no releases indexed for this title — search indexers runs a sweep now");
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(movie: LibraryMovie, baseline: string | null, ticket: number) {
function watchSweep(id: number, baseline: string | null, ticket: number) {
sweep.disabled = true;
setStatus("sweeping indexers…", undefined, true);
setReleaseStatus("sweeping indexers…", undefined, true);
const deadline = Date.now() + SWEEP_WAIT_MS;
const tick = async () => {
if (ticket !== sequence || current !== movie) {
if (ticket !== sequence || movieId !== id) {
return;
}
const outcome = await movieSearchState(movie.id);
if (ticket !== sequence || current !== movie) {
const outcome = await movieSearchState(id);
if (ticket !== sequence || movieId !== id) {
return;
}
if (outcome.kind === "state" && outcome.movie.last_searched_at !== baseline) {
sweep.disabled = false;
void load();
await loadReleases(id, ticket);
return;
}
if (Date.now() >= deadline) {
sweep.disabled = false;
setStatus(
setReleaseStatus(
"sweep has not landed yet — it may be waiting out its backoff; results appear here once it runs",
);
return;
@@ -1259,6 +1676,8 @@ function releasesMain(): ReleasesView {
}, SWEEP_POLL_MS);
}
/* ---- removal confirmation, unchanged from the deck ---- */
/** Tears the confirmation down without stealing focus from a caller. */
function clearRemove() {
removeWrap.hidden = true;
@@ -1300,59 +1719,6 @@ function releasesMain(): ReleasesView {
}
});
function open(movie: LibraryMovie, from: HTMLElement, container: HTMLElement, parent: Route) {
current = movie;
origin = from;
returnTo = container;
parentRoute = parent;
title.textContent = movie.title;
year.textContent = movie.year === null ? "—" : String(movie.year);
container.hidden = true;
view.hidden = false;
clearBuckets(dom);
clearRemove();
sweep.disabled = false;
back.focus();
void load();
}
function hide() {
view.hidden = true;
current = null;
clearRemove();
sequence += 1;
window.clearTimeout(pollTimer);
}
function close() {
const target = origin;
navigate(parentRoute);
hide();
returnTo.hidden = false;
target?.focus();
}
back.addEventListener("click", close);
// capture + stopImmediatePropagation: one Escape steps back one layer —
// the library and search decks also listen for Escape on this window
window.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape" || view.hidden) {
return;
}
event.stopImmediatePropagation();
// the confirmation is the innermost layer: Esc abandons it, not the deck
if (removeWrap.hidden) {
close();
} else {
closeRemove();
}
},
true,
);
sweep.addEventListener("click", () => {
const movie = current;
if (!movie) {
@@ -1372,18 +1738,114 @@ function releasesMain(): ReleasesView {
}
if (outcome.kind === "error") {
sweep.disabled = false;
setStatus(`search failed — ${outcome.detail}`, "fault");
setReleaseStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
sequence += 1;
window.clearTimeout(pollTimer);
watchSweep(movie, baseline, sequence);
watchSweep(movie.id, baseline, sequence);
})();
});
/* ---- open, close, layers ---- */
async function load() {
const id = movieId;
if (id === null) {
return;
}
sequence += 1;
const ticket = sequence;
window.clearTimeout(pollTimer);
const [movie, filesOutcome, fetchedRoots] = await Promise.all([
fetchMovie(id),
movieFiles(id),
allRoots().catch(() => roots),
]);
if (ticket !== sequence || movieId !== id) {
return;
}
roots = fetchedRoots;
if (movie === null) {
setStatus("movie unavailable", "fault");
return;
}
current = movie;
clearRichDetail();
paintIdentity();
paintControls();
paintFiles(filesOutcome);
void loadMetadata(id, ticket);
await loadReleases(id, ticket);
}
async function open(id: number, from: HTMLElement, container: HTMLElement, parent: Route) {
// this page is one of `views`, so the loop below calls its own hide()
// and would wipe the state assigned after it — hide first, then take state
for (const sibling of views) {
sibling.hide();
}
movieId = id;
current = null;
origin = from;
returnTo = container;
parentRoute = parent;
board.hidden = true;
deckEl.hidden = true;
view.hidden = false;
clearBuckets(dom);
clearRemove();
clearRichDetail();
filesSection.hidden = true;
diskRows.replaceChildren();
sweep.disabled = false;
back.focus();
await load();
}
function hide() {
view.hidden = true;
movieId = null;
current = null;
sequence += 1;
window.clearTimeout(pollTimer);
clearRemove();
}
function close() {
const target = origin;
navigate(parentRoute);
hide();
if (returnTo) {
returnTo.hidden = false;
}
target?.focus();
}
back.addEventListener("click", close);
// capture + stopImmediatePropagation: one Escape steps back one layer —
// the library and search decks also listen for Escape on this window
window.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape" || view.hidden) {
return;
}
event.stopImmediatePropagation();
// the confirmation is the innermost layer: Esc abandons it, not the page
if (removeWrap.hidden) {
close();
} else {
closeRemove();
}
},
true,
);
return {
open,
hide,
open,
setRemoved: (handler: (parent: Route) => void) => {
removed = handler;
},
@@ -1776,7 +2238,7 @@ interface LibraryGroup {
function libraryMain(
board: HTMLElement,
releases: ReleasesView,
movieDetail: MovieView,
seriesDetail: SeriesView,
views: HideableView[],
): LibraryView {
@@ -1859,8 +2321,8 @@ function libraryMain(
);
renderGroup(groups.movies, moviesShown, (movie) =>
libraryRow(movie, roots, (target, origin) => {
navigate({ kind: "releases", movieId: target.id });
releases.open(target, origin, view, { kind: "library" });
navigate({ kind: "movie", movieId: target.id });
void movieDetail.open(target.id, origin, view, { kind: "library" });
}),
);
@@ -1895,7 +2357,6 @@ function libraryMain(
}
function open() {
releases.hide();
for (const sibling of views) {
sibling.hide();
}
@@ -2669,7 +3130,7 @@ interface QueueGroup {
rows: HTMLUListElement;
}
function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableView[]): QueuesView {
function queuesMain(board: HTMLElement, movieDetail: MovieView, views: HideableView[]): QueuesView {
const view = must<HTMLElement>("#queues");
const deck = must<HTMLElement>("#deck");
const nav = must<HTMLButtonElement>("#nav-queues");
@@ -2742,8 +3203,8 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV
}
const openReleases = (movie: LibraryMovie, origin: HTMLElement) => {
navigate({ kind: "releases", movieId: movie.id });
releases.open(movie, origin, view, { kind: "queues" });
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. */
@@ -2808,7 +3269,6 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV
}
function open() {
releases.hide();
for (const sibling of views) {
sibling.hide();
}
@@ -2923,7 +3383,7 @@ function noPtRow(
}
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "releases";
affordance.textContent = "open";
chips.append(affordance);
line.append(rowTitle(movie.title, movie.year), chips);
line.addEventListener("click", () => {
+142
View File
@@ -0,0 +1,142 @@
// Hand-written mirror of arr-api's /api/movies/{id}/metadata and PATCH
// /api/movies/{id} schemas — same reasoning as search.ts: the generated
// client (src/api/) is uncommitted, so CI's tsc cannot see it.
import { type ActionOutcome, errorDetail } from "./releases";
/** One of the top-billed cast members (§9.6). */
export interface MetadataCastMember {
tmdb_id: number;
name: string;
character: string;
/** Path fragment exactly as TMDB sends it; the browser composes the URL. */
profile_path: string | null;
}
/** The one trailer worth showing, resolved by the §9.6 rule in arr-meta. */
export interface MetadataTrailer {
youtube_key: string;
name: string;
}
/** Rich detail for one library movie's §9.6 page (#146). */
export interface MovieMetadata {
tmdb_id: number;
overview: string | null;
tagline: string | null;
genres: string[];
/** Minutes. */
runtime: number | null;
status: string;
poster_path: string | null;
backdrop_path: string | null;
vote_average: number;
vote_count: number;
homepage: string | null;
imdb_id: string | null;
cast: MetadataCastMember[];
trailer: MetadataTrailer | null;
}
export type MetadataOutcome =
| { kind: "metadata"; metadata: MovieMetadata }
| { kind: "error"; detail: string };
/** One request for everything above the release deck (§9.6). */
export async function movieMetadata(movieId: number): Promise<MetadataOutcome> {
try {
const response = await fetch(`/api/movies/${movieId}/metadata`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "metadata", metadata: (await response.json()) as MovieMetadata };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/**
* The per-title library controls (#149): wanted, blocked and root all ride
* PATCH /api/movies/{id}. Partial bodies only — overrides stay untouched.
*/
export async function updateMovie(
movieId: number,
patch: { wanted?: boolean; blocked?: boolean; root_id?: number },
): Promise<ActionOutcome> {
try {
const response = await fetch(`/api/movies/${movieId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(patch),
});
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/* ---- §9.6 link composition -------------------------------------------- */
/** TMDB image URL from a path fragment — the browser picks the size. */
export function tmdbImage(path: string | null, size: string): string | null {
return path === null ? null : `https://image.tmdb.org/t/p/${size}${path}`;
}
export function tmdbMovieLink(tmdbId: number): string {
return `https://www.themoviedb.org/movie/${tmdbId}`;
}
export function tmdbPersonLink(tmdbId: number): string {
return `https://www.themoviedb.org/person/${tmdbId}`;
}
export function imdbLink(imdbId: string): string {
return `https://www.imdb.com/title/${imdbId}/`;
}
/**
* The Rotten Tomatoes link is a *search* URL built in the browser (§9.6) —
* never a resolved title page, which this service has no id for.
*/
export function rottenTomatoesSearch(title: string, year: number | null): string {
const query = year === null ? title : `${title} ${year}`;
return `https://www.rottentomatoes.com/search?search=${encodeURIComponent(query)}`;
}
export function youtubeLink(key: string): string {
return `https://www.youtube.com/watch?v=${key}`;
}
/** `8.2` with one decimal, as the readout spells a TMDB rating. */
export function formatRating(voteAverage: number): string {
return voteAverage.toFixed(1);
}
/** `4,821` — grouped votes, matching the issue's own mockup. */
export function formatVoteCount(voteCount: number): string {
return voteCount.toLocaleString("en-US");
}
/** `166 min · Science Fiction, Adventure`, skipping what detail left out. */
export function formatMetaLine(runtime: number | null, genres: string[]): string {
const parts: string[] = [];
if (runtime !== null && runtime > 0) {
parts.push(`${runtime} min`);
}
if (genres.length > 0) {
parts.push(genres.join(", "));
}
return parts.join(" · ");
}
/**
* The file's name inside its §7.4 folder — the audit surface the layout
* section promises (`ls` shows what the file is). The folder itself stays
* with the removal confirmation.
*/
export function fileName(path: string): string {
return path.slice(path.lastIndexOf("/") + 1);
}
+33
View File
@@ -257,6 +257,39 @@ export function bucketOf(release: MovieRelease): Bucket {
return "rejected";
}
/**
* The probed attributes a file row shows as chips (§5.6, §7.4) — ffprobe
* truth, not name claims. Shared by movie and episode file rows; the probe
* JSON arrives untyped from the API, so the shape is narrowed here.
*/
export function probedAttributeTags(probed: unknown): string[] {
if (probed === null || typeof probed !== "object") {
return [];
}
const probe = probed as {
resolution?: string | null;
source?: string | null;
hdr?: string | null;
audio_tracks?: { language?: string | null }[] | null;
};
const tags: string[] = [];
if (probe.resolution) {
tags.push(probe.resolution);
}
if (probe.source) {
tags.push(probe.source);
}
if (probe.hdr && probe.hdr !== "SDR") {
tags.push(probe.hdr);
}
for (const track of probe.audio_tracks ?? []) {
if (track.language) {
tags.push(track.language);
}
}
return tags;
}
/* ---- chip formatting: parsed attributes to fixed-width mono values ---- */
const SOURCE_LABEL: Record<string, string> = {
+9
View File
@@ -8,6 +8,7 @@ export type Route =
| { kind: "queues" }
| { kind: "settings" }
| { kind: "search"; query: string }
| { kind: "movie"; movieId: number }
| { kind: "releases"; movieId: number }
| { kind: "series"; seriesId: number }
| { kind: "seasonReleases"; seriesId: number; seasonNumber: number }
@@ -28,6 +29,12 @@ export function parseRoute(url: URL): Route {
const query = url.searchParams.get("q") ?? "";
return query === "" ? { kind: "board" } : { kind: "search", query };
}
if (segments.length === 2 && segments[0] === "movies") {
const movieId = Number(segments[1]);
if (Number.isInteger(movieId) && movieId > 0) {
return { kind: "movie", movieId };
}
}
if (segments.length === 3 && segments[0] === "movies" && segments[2] === "releases") {
const movieId = Number(segments[1]);
if (Number.isInteger(movieId) && movieId > 0) {
@@ -78,6 +85,8 @@ export function routePath(route: Route): string {
return "/settings";
case "search":
return `/search?q=${encodeURIComponent(route.query)}`;
case "movie":
return `/movies/${route.movieId}`;
case "releases":
return `/movies/${route.movieId}/releases`;
case "series":
+3 -22
View File
@@ -3,7 +3,7 @@
// (src/api/) is uncommitted, so CI's tsc cannot see it.
import type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases";
import { errorDetail, waiverOverride } from "./releases";
import { errorDetail, probedAttributeTags, waiverOverride } from "./releases";
/** §4.2 derived status — displayed, never editable. */
export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended";
@@ -277,26 +277,7 @@ export function formatAirDate(airDate: string | null): string {
return airDate ?? "—";
}
/** The probed attributes a file row shows as chips (§5.6, §7.4). */
/** The probed attributes an episode file row shows as chips (§5.6, §7.4). */
export function fileAttributeTags(file: EpisodeFile): string[] {
const probed = file.probed;
if (!probed) {
return [];
}
const tags: string[] = [];
if (probed.resolution) {
tags.push(probed.resolution);
}
if (probed.source) {
tags.push(probed.source);
}
if (probed.hdr && probed.hdr !== "SDR") {
tags.push(probed.hdr);
}
for (const track of probed.audio_tracks ?? []) {
if (track.language) {
tags.push(track.language);
}
}
return tags;
return probedAttributeTags(file.probed);
}
+297
View File
@@ -1284,6 +1284,274 @@ body {
overflow-wrap: anywhere;
}
/* ---- movie page (§9.6, issue #149) ------------------------------------- */
/* The hero is one machined module carrying what the film IS. The backdrop
hotlinks from image.tmdb.org (§9.6) and sinks into the console charcoal
under a scrim, so the readouts keep their contrast — a photo never sits
behind text bare. */
.movie-hero {
position: relative;
overflow: hidden;
margin: 0 0 var(--space-8);
}
/* the backdrop layer: painted only when detail supplied one */
.movie-hero::before {
content: "";
position: absolute;
inset: 0;
background-image: var(--backdrop);
background-size: cover;
background-position: center 20%;
opacity: 0;
transition: opacity 400ms var(--ease-out);
}
.movie-hero.has-backdrop::before {
opacity: 1;
}
/* the scrim: ground at the bottom, heavy everywhere the text sits */
.movie-hero::after {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(
to top,
oklch(from var(--ground) l c h / 92%) 0%,
oklch(from var(--ground) l c h / 72%) 45%,
oklch(from var(--ground) l c h / 40%) 100%
),
oklch(from var(--ground) l c h / 35%);
}
.movie-body {
position: relative;
z-index: 1;
display: flex;
gap: var(--space-6);
align-items: flex-start;
}
/* the poster is framed evidence, like every other image on a panel */
.movie-poster {
flex: none;
width: 10.5rem;
aspect-ratio: 2 / 3;
object-fit: cover;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
box-shadow: 0 2px 12px oklch(from var(--lowlight) l c h / 75%);
}
.movie-info {
min-width: 0;
display: grid;
gap: var(--space-2);
padding: var(--space-6) 0;
}
.movie-idline {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
}
/* the page's voice: bigger than a deck title, same display face */
.movie-title {
margin: 0;
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1.05;
}
.movie-year {
font-size: var(--text-sm);
}
.movie-chips {
margin-left: 0;
}
/* rating and meta are readouts on one line each; the star is drawn */
.movie-rating {
display: inline-flex;
align-items: center;
gap: var(--space-1);
margin: 0;
color: var(--ink);
}
.star {
width: 0.75rem;
height: 0.75rem;
fill: var(--ink-muted);
}
.movie-meta {
margin: 0;
}
.movie-tagline {
margin: var(--space-1) 0 0;
font-family: var(--font-display);
font-size: var(--text-md);
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ink-muted);
}
.movie-overview {
max-width: 65ch;
margin: var(--space-1) 0 0;
color: var(--ink);
}
.movie-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-2);
}
.movie-actions .add-note[data-tone="fault"] {
margin: 0;
color: var(--signal-fault);
}
/* external links wear the control skin; anchors must drop the underline */
.movie-actions a.control {
text-decoration: none;
}
/* library controls follow the hero on their own row: state left, errands right */
.movie-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
padding: var(--space-2) var(--space-1);
}
.movie-controls .control {
min-height: 2.25rem;
padding: 0 var(--space-3);
}
/* wanted and blocked read as rules, pressed = on — the tracked-toggle idiom */
.movie-controls .control[aria-pressed="true"] {
color: var(--accent-bright);
border-color: var(--accent);
}
.movie-root-field {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.movie-root {
width: auto;
min-width: 11rem;
}
.movie-controls-space {
flex: 1;
}
/* cast: top billed, each linking out to TMDB (§9.6) */
.cast-grid {
margin: 0;
padding: var(--space-3) var(--space-1) 0;
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
gap: var(--space-4) var(--space-2);
}
.cast-link {
display: grid;
gap: var(--space-1);
justify-items: start;
color: inherit;
text-decoration: none;
min-width: 0;
}
.cast-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.cast-photo {
width: 100%;
aspect-ratio: 2 / 3;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel-raised);
transition: border-color 150ms var(--ease-out);
}
.cast-photo img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* no profile photo: dead air, not a broken frame */
.cast-photo-none::before {
content: "";
display: block;
width: 40%;
aspect-ratio: 1;
margin: 30% auto 0;
border-radius: 50%;
border: 1px solid var(--line);
}
.cast-link:hover .cast-photo,
.cast-link:focus-visible .cast-photo {
border-color: var(--accent);
}
.cast-name {
font-size: var(--text-xs);
color: var(--ink);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
}
.cast-character {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
}
/* on-disk rows share the deck's rel grammar: name leads, chips trail */
.disk-name {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--ink);
}
.rel[data-tone="fault"] {
color: var(--signal-fault);
}
/* ---- small screens --------------------------------------------------- */
@media (max-width: 46rem) {
@@ -1357,6 +1625,35 @@ body {
margin-left: auto;
}
/* the hero stacks: poster above the info block, both full width */
.movie-body {
flex-direction: column;
align-items: stretch;
gap: var(--space-3);
}
.movie-poster {
width: 8.5rem;
}
.movie-info {
padding-bottom: var(--space-4);
}
/* state controls wrap to their own line, errands stay right-aligned */
.movie-controls-space {
display: none;
}
.movie-root {
min-width: 0;
flex: 1;
}
.cast-grid {
grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr));
}
.board {
padding: var(--space-6) var(--space-4);
}