+97
-19
@@ -183,33 +183,110 @@
|
||||
<span class="deck-count readout" id="count-decision"></span>
|
||||
</header>
|
||||
<p class="queue-note readout dim">
|
||||
hard-failed twice on different releases — open a movie's releases and decide by hand;
|
||||
hard-failed twice on different releases — open the movie's page and decide by hand;
|
||||
series entries are display-only for now.
|
||||
</p>
|
||||
<ul class="deck-rows" id="rows-decision"></ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main class="deck releases" id="releases" hidden>
|
||||
<!--
|
||||
MOVIE PAGE (§9.6, issue 149): a library row opens /movies/{id} — one
|
||||
machined module carries what the film IS: backdrop hotlinked from TMDB
|
||||
under a scrim that sinks it into the console charcoal, poster framed at
|
||||
left, Rajdhani title with year, TMDB rating and vote count, runtime and
|
||||
genres, the tagline quoted, overview below. TRAILER resolves from the
|
||||
metadata response's own key (no second call); TMDB, IMDb and Rotten
|
||||
Tomatoes sit beside it — RT is a search URL built here, never a resolved
|
||||
page. LIBRARY controls follow on their own row: wanted, blocked, root,
|
||||
then search indexers and remove. CAST is the top 10 billed, each photo
|
||||
linking out to that person's TMDB page. ON DISK lists imported files
|
||||
with ffprobe attribute tags and any §5.7 waiver, worded honestly. The
|
||||
§9.3 deck closes the page unchanged — buckets, chips, score heat and
|
||||
rejection rules untouched; /movies/{id}/releases lands here with the
|
||||
deck section in view.
|
||||
-->
|
||||
|
||||
<main class="deck" id="movie" hidden aria-label="movie detail">
|
||||
<header class="releases-head">
|
||||
<button type="button" class="control" id="releases-back">back</button>
|
||||
<div class="releases-id">
|
||||
<h2 class="releases-title" id="releases-title">—</h2>
|
||||
<span class="releases-year readout dim" id="releases-year">—</span>
|
||||
</div>
|
||||
<button type="button" class="control" id="releases-sweep">search indexers</button>
|
||||
<button
|
||||
type="button"
|
||||
class="control control-quiet"
|
||||
id="releases-remove"
|
||||
aria-expanded="false"
|
||||
aria-controls="remove-panel"
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
<button type="button" class="control" id="movie-back">back</button>
|
||||
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
|
||||
</header>
|
||||
<div class="remove-panel" id="remove-panel" hidden></div>
|
||||
<p class="deck-status readout" id="releases-status" role="status" hidden></p>
|
||||
|
||||
<section class="module movie-hero" id="movie-hero" aria-label="title metadata">
|
||||
<div class="movie-body">
|
||||
<img class="movie-poster" id="movie-poster" alt="" hidden />
|
||||
<div class="movie-info">
|
||||
<div class="movie-idline">
|
||||
<h2 class="movie-title" id="movie-title">—</h2>
|
||||
<span class="movie-year readout dim" id="movie-year"></span>
|
||||
</div>
|
||||
<span class="row-chips movie-chips" id="movie-chips"></span>
|
||||
<p class="movie-rating readout" id="movie-rating" hidden></p>
|
||||
<p class="movie-meta readout dim" id="movie-meta" hidden></p>
|
||||
<p class="movie-tagline" id="movie-tagline" hidden></p>
|
||||
<p class="movie-overview" id="movie-overview" hidden></p>
|
||||
<div class="movie-actions" id="movie-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="movie-controls" aria-label="library controls">
|
||||
<header class="deck-head">
|
||||
<h3 class="deck-label">library</h3>
|
||||
</header>
|
||||
<div class="movie-controls">
|
||||
<button type="button" class="control control-quiet" id="movie-wanted" aria-pressed="false">
|
||||
wanted
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="control control-quiet"
|
||||
id="movie-blocked"
|
||||
aria-pressed="false"
|
||||
>
|
||||
blocked
|
||||
</button>
|
||||
<label class="movie-root-field">
|
||||
<span class="field-label readout dim" id="movie-root-label">root</span>
|
||||
<select class="form-input movie-root" id="movie-root" aria-labelledby="movie-root-label">
|
||||
</select>
|
||||
</label>
|
||||
<span class="movie-controls-space"></span>
|
||||
<button type="button" class="control" id="movie-sweep">search indexers</button>
|
||||
<button
|
||||
type="button"
|
||||
class="control control-quiet"
|
||||
id="movie-remove"
|
||||
aria-expanded="false"
|
||||
aria-controls="remove-panel"
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
</div>
|
||||
<div class="remove-panel" id="remove-panel" hidden></div>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="movie-cast" hidden aria-labelledby="label-cast">
|
||||
<header class="deck-head">
|
||||
<h3 class="deck-label" id="label-cast">cast</h3>
|
||||
</header>
|
||||
<ul class="cast-grid" id="rows-cast"></ul>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="movie-files" hidden aria-labelledby="label-disk">
|
||||
<header class="deck-head">
|
||||
<h3 class="deck-label" id="label-disk">on disk</h3>
|
||||
<span class="deck-count readout" id="count-disk"></span>
|
||||
</header>
|
||||
<ul class="deck-rows" id="rows-disk"></ul>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="movie-releases" aria-labelledby="label-releases">
|
||||
<header class="deck-head">
|
||||
<h3 class="deck-label" id="label-releases">releases</h3>
|
||||
</header>
|
||||
<p class="deck-status readout" id="releases-status" role="status" hidden></p>
|
||||
|
||||
<section class="deck-group" id="bucket-eligible" hidden aria-labelledby="label-eligible">
|
||||
<header class="deck-head">
|
||||
@@ -277,6 +354,7 @@
|
||||
<ul class="deck-rows" id="rows-rejected"></ul>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main class="deck" id="settings" hidden aria-label="settings">
|
||||
|
||||
+600
-140
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user