diff --git a/.sqlx/query-833f1514dad9e77075cd8c1b38d483de6a9ba5be46a8e7d89a05fe33f3f20044.json b/.sqlx/query-2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2.json similarity index 66% rename from .sqlx/query-833f1514dad9e77075cd8c1b38d483de6a9ba5be46a8e7d89a05fe33f3f20044.json rename to .sqlx/query-2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2.json index fbf66fb..c0b913e 100644 --- a/.sqlx/query-833f1514dad9e77075cd8c1b38d483de6a9ba5be46a8e7d89a05fe33f3f20044.json +++ b/.sqlx/query-2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\", s.poster_path, s.vote_average FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id", + "query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\", s.poster_path, s.vote_average, s.tmdb_id AS \"series_tmdb_id!: i64\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id", "describe": { "columns": [ { @@ -37,6 +37,11 @@ "name": "vote_average", "ordinal": 6, "type_info": "Float" + }, + { + "name": "series_tmdb_id!: i64", + "ordinal": 7, + "type_info": "Integer" } ], "parameters": { @@ -49,8 +54,9 @@ null, false, true, - true + true, + false ] }, - "hash": "833f1514dad9e77075cd8c1b38d483de6a9ba5be46a8e7d89a05fe33f3f20044" + "hash": "2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2" } diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 02418a0..f38b36c 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -79,6 +79,9 @@ pub struct LibraryEpisode { pub poster_path: Option, /// The series' rating, out of 10; `null` when TMDB has no votes for it. pub vote_average: Option, + /// The series' TMDB id — the episode row's trailer chip resolves through + /// it (#148); an episode has no videos of its own worth listing. + pub series_tmdb_id: i64, } #[derive(Debug, Clone, Copy, Serialize, ToSchema)] @@ -232,7 +235,7 @@ pub async fn search( // §9.2 names the TV case explicitly: `bluey hospital` finds the episode. // Tokens may split across the series and episode titles, so the haystack // is both joined and every token must land somewhere in it. - let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String", s.poster_path, s.vote_average FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens) + let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String", s.poster_path, s.vote_average, s.tmdb_id AS "series_tmdb_id!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens) .fetch_all(database.pool()) .await?; library.extend(episode_rows.into_iter().map(LibraryResult::Episode)); diff --git a/web/src/main.ts b/web/src/main.ts index a5bdc5f..bc96c2e 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -14,6 +14,7 @@ import { imdbLink, type MetadataCastMember, movieMetadata, + resolveTrailer, rottenTomatoesSearch, tmdbImage, tmdbMovieLink, @@ -628,7 +629,9 @@ function searchMain( void run(query); } } else if (event.key === "ArrowDown") { - const first = refs.deck.querySelector(".row-tmdb:not(:disabled)"); + const first = refs.deck.querySelector( + '.row-tmdb:not(:disabled):not([aria-disabled="true"])', + ); if (first) { event.preventDefault(); first.focus(); @@ -678,7 +681,11 @@ function searchMain( return; } event.preventDefault(); - const rows = [...refs.deck.querySelectorAll(".row-tmdb:not(:disabled)")]; + const rows = [ + ...refs.deck.querySelectorAll( + '.row-tmdb:not(:disabled):not([aria-disabled="true"])', + ), + ]; const index = rows.indexOf(target); if (event.key === "ArrowUp" && index === 0) { input.focus(); @@ -727,6 +734,131 @@ function seasonTag(seasonNumber: number): string { return `S${two(seasonNumber)}`; } +/* ---- §9.2 rich-row parts (#148) ---------------------------------------- */ + +/** A search row's poster at w92, or a same-size blank so the edge never jitters. */ +function rowPoster(posterPath: string | null, title: string): HTMLElement { + if (posterPath === null) { + const blank = document.createElement("span"); + blank.className = "row-poster-blank"; + blank.setAttribute("aria-hidden", "true"); + return blank; + } + const img = document.createElement("img"); + img.className = "row-poster"; + img.loading = "lazy"; + img.src = tmdbImage(posterPath, "w92") ?? ""; + img.alt = `${title} poster`; + // hotlinked art can 404; fall back to the blank rather than alt-text spill + img.addEventListener("error", () => { + const blank = document.createElement("span"); + blank.className = "row-poster-blank"; + blank.setAttribute("aria-hidden", "true"); + img.replaceWith(blank); + }); + return img; +} + +/** + * TMDB's rating as a chip (#148). No votes means no chip — never a 0.0. + * `vote_count` rides along as the title when the response carries one. + */ +function ratingChip(voteAverage: number | null, voteCount?: number): HTMLSpanElement | null { + if (voteAverage === null) { + return null; + } + const span = document.createElement("span"); + span.className = "chip"; + span.append(starIcon(), document.createTextNode(formatRating(voteAverage))); + if (voteCount !== undefined) { + span.title = `${formatVoteCount(voteCount)} votes`; + } + return span; +} + +/** + * The §9.6 trailer chip: one call, fired from the click and never before, + * so a page of results costs zero trailer requests. The tab opens inside + * the handler — an async `window.open` is what popup blockers eat — and is + * navigated when the key arrives; no video closes it again quietly. + */ +function trailerChip(kind: "movie" | "tv", tmdbId: number): HTMLSpanElement { + const chipEl = document.createElement("span"); + chipEl.className = "chip row-trailer"; + chipEl.textContent = "trailer"; + chipEl.setAttribute("role", "button"); + chipEl.tabIndex = 0; + + let busy = false; + const resolve = () => { + if (busy || chipEl.dataset.state !== undefined) { + return; + } + busy = true; + const tab = window.open("", "_blank"); + chipEl.dataset.state = "pending"; + chipEl.textContent = "trailer…"; + void resolveTrailer(kind, tmdbId).then((outcome) => { + busy = false; + if (outcome.kind === "trailer") { + if (tab) { + tab.location.href = youtubeLink(outcome.youtubeKey); + delete chipEl.dataset.state; + chipEl.textContent = "trailer"; + } else { + // visibly blocked beats silently swallowed + chipEl.dataset.state = "blocked"; + chipEl.textContent = "popups blocked"; + } + return; + } + tab?.close(); + if (outcome.kind === "none") { + // TMDB having no video is an ordinary outcome, not a failure + chipEl.dataset.state = "none"; + chipEl.textContent = "no trailer"; + chipEl.removeAttribute("role"); + chipEl.removeAttribute("tabindex"); + } else { + delete chipEl.dataset.state; + chipEl.title = outcome.detail; + chipEl.textContent = "trailer"; + } + }); + }; + chipEl.addEventListener("click", (event) => { + event.stopPropagation(); + event.preventDefault(); + resolve(); + }); + chipEl.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.stopPropagation(); + event.preventDefault(); + resolve(); + } + }); + return chipEl; +} + +/** Poster left, then title / chips / overview in a wrapping column (#148). */ +function richRow(): { + item: HTMLLIElement; + row: HTMLButtonElement; + body: HTMLElement; + chips: HTMLElement; +} { + const item = document.createElement("li"); + const row = document.createElement("button"); + row.type = "button"; + row.className = "row row-tmdb row-rich"; + const body = document.createElement("span"); + body.className = "row-main"; + const chips = document.createElement("span"); + chips.className = "row-chips"; + return { item, row, body, chips }; +} + function episodeTag(seasonNumber: number, episodeNumber: number): string { return `${seasonTag(seasonNumber)}E${two(episodeNumber)}`; } @@ -740,12 +872,7 @@ function libraryRow( roots: Root[], open: (movie: LibraryMovie, origin: HTMLElement) => void, ): HTMLLIElement { - const item = document.createElement("li"); - const row = document.createElement("button"); - row.type = "button"; - row.className = "row row-tmdb"; - const chips = document.createElement("span"); - chips.className = "row-chips"; + const { item, row, body, chips } = richRow(); const root = roots.find((candidate) => candidate.id === movie.root_id); chips.append(chip(root ? root.audience : `root ${movie.root_id}`)); chips.append( @@ -768,11 +895,17 @@ function libraryRow( if (movie.blocked) { chips.append(chip("blocked")); } + const rating = ratingChip(movie.vote_average); + if (rating !== null) { + chips.append(rating); + } + chips.append(trailerChip("movie", movie.tmdb_id)); const affordance = document.createElement("span"); affordance.className = "row-add readout"; affordance.textContent = "open"; chips.append(affordance); - row.append(rowTitle(movie.title, movie.year), chips); + body.append(rowTitle(movie.title, movie.year), chips); + row.append(rowPoster(movie.poster_path, movie.title), body); row.addEventListener("click", () => { open(movie, row); }); @@ -789,22 +922,23 @@ function librarySeriesRow( roots: Root[], open: (id: number, origin: HTMLElement) => void, ): HTMLLIElement { - const item = document.createElement("li"); - const row = document.createElement("button"); - row.type = "button"; - row.className = "row row-tmdb"; - const chips = document.createElement("span"); - chips.className = "row-chips"; + const { item, row, body, chips } = richRow(); const root = roots.find((candidate) => candidate.id === series.root_id); chips.append(chip(root ? root.audience : `root ${series.root_id}`)); if (series.blocked) { chips.append(chip("blocked")); } + const rating = ratingChip(series.vote_average); + if (rating !== null) { + chips.append(rating); + } + chips.append(trailerChip("tv", series.tmdb_id)); const affordance = document.createElement("span"); affordance.className = "row-add readout"; affordance.textContent = "episodes"; chips.append(affordance); - row.append(rowTitle(series.title, series.year), chips); + body.append(rowTitle(series.title, series.year), chips); + row.append(rowPoster(series.poster_path, series.title), body); row.addEventListener("click", () => { open(series.id, row); }); @@ -817,12 +951,7 @@ function episodeRow( episode: LibraryEpisodeHit, open: (id: number, origin: HTMLElement) => void, ): HTMLLIElement { - const item = document.createElement("li"); - const row = document.createElement("button"); - row.type = "button"; - row.className = "row row-tmdb"; - const chips = document.createElement("span"); - chips.className = "row-chips"; + const { item, row, body, chips } = richRow(); chips.append( chip(episode.tag, (span) => { span.setAttribute("aria-label", `${episode.series_title} ${episode.tag}, ${episode.title}`); @@ -833,11 +962,17 @@ function episodeRow( title.className = "row-sub"; title.textContent = episode.title; chips.append(title); + const rating = ratingChip(episode.vote_average); + if (rating !== null) { + chips.append(rating); + } + chips.append(trailerChip("tv", episode.series_tmdb_id)); const affordance = document.createElement("span"); affordance.className = "row-add readout"; affordance.textContent = "episodes"; chips.append(affordance); - row.append(rowTitle(episode.series_title, null), chips); + body.append(rowTitle(episode.series_title, null), chips); + row.append(rowPoster(episode.poster_path, episode.series_title), body); row.addEventListener("click", () => { open(episode.series_id, row); }); @@ -851,13 +986,7 @@ function tmdbRow( inLibrary: boolean, fetchRoots: () => Promise, ): HTMLLIElement { - const item = document.createElement("li"); - - const row = document.createElement("button"); - row.type = "button"; - row.className = "row row-tmdb"; - const chips = document.createElement("span"); - chips.className = "row-chips"; + const { item, row, body, chips } = richRow(); chips.append(chip(hit.original_language)); if (inLibrary) { chips.append( @@ -871,17 +1000,25 @@ function tmdbRow( add.textContent = "add"; chips.append(add); } - row.append(rowTitle(hit.title, hit.year), chips); + const rating = ratingChip(hit.vote_average, hit.vote_count); + if (rating !== null) { + chips.append(rating); + } + chips.append(trailerChip(hit.kind === "series" ? "tv" : "movie", hit.tmdb_id)); + body.append(rowTitle(hit.title, hit.year), chips); if (hit.overview) { const overview = document.createElement("span"); overview.className = "row-overview"; overview.textContent = hit.overview; - row.append(overview); + body.append(overview); } + row.append(rowPoster(hit.poster_path, hit.title), body); item.append(row); if (inLibrary) { - row.disabled = true; + // aria-disabled, not disabled: a dead button would also kill the + // trailer chip's clicks, and that chip stays live on every row + row.setAttribute("aria-disabled", "true"); return item; } @@ -1037,7 +1174,7 @@ function addPanel( : `added to ${root.audience} — future seasons ${autoTrack ? "" : "not "}tracked automatically`; panel.replaceChildren(done); done.focus(); - row.disabled = true; + row.setAttribute("aria-disabled", "true"); row.setAttribute("aria-expanded", "true"); row.querySelector(".row-add")?.replaceWith( chip("in library", (span) => { diff --git a/web/src/movie.ts b/web/src/movie.ts index acdf984..945febe 100644 --- a/web/src/movie.ts +++ b/web/src/movie.ts @@ -43,6 +43,36 @@ export type MetadataOutcome = | { kind: "metadata"; metadata: MovieMetadata } | { kind: "error"; detail: string }; +/** The click-resolved trailer behind a search row's chip (#148). */ +export type TrailerOutcome = + | { kind: "trailer"; youtubeKey: string } + | { kind: "none" } + | { kind: "error"; detail: string }; + +/** + * `GET /api/trailer` (#144), fired from a chip click only (§9.6) — a page + * of results costs zero extra calls. A 404 is TMDB simply having no video: + * an ordinary outcome, never an error. + */ +export async function resolveTrailer( + kind: "movie" | "tv", + tmdbId: number, +): Promise { + try { + const response = await fetch(`/api/trailer?kind=${kind}&tmdb_id=${tmdbId}`); + if (response.status === 404) { + return { kind: "none" }; + } + if (!response.ok) { + return { kind: "error", detail: await errorDetail(response) }; + } + const body = (await response.json()) as MetadataTrailer; + return { kind: "trailer", youtubeKey: body.youtube_key }; + } catch { + return { kind: "error", detail: "daemon unreachable" }; + } +} + /** One request for everything above the release deck (§9.6). */ export async function movieMetadata(movieId: number): Promise { try { diff --git a/web/src/search.ts b/web/src/search.ts index 78e17e2..2ca9f23 100644 --- a/web/src/search.ts +++ b/web/src/search.ts @@ -48,6 +48,8 @@ export interface LibraryEpisodeHit { /** The series' poster — an episode has no artwork of its own worth showing at row size. */ poster_path: string | null; vote_average: number | null; + /** The series' TMDB id — what the row's trailer chip resolves through. */ + series_tmdb_id: number; } export type LibraryResult = LibraryMovie | LibrarySeriesHit | LibraryEpisodeHit; diff --git a/web/src/style.css b/web/src/style.css index 4d49d81..a387e2b 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -699,12 +699,13 @@ body { transition: background 150ms var(--ease-out); } -.row-tmdb:hover:not(:disabled), -.row-tmdb:focus-visible { +.row-tmdb:hover:not(:disabled):not([aria-disabled="true"]), +.row-tmdb:focus-visible:not([aria-disabled="true"]) { background: var(--panel); } -.row-tmdb:disabled { +.row-tmdb:disabled, +.row-tmdb[aria-disabled="true"] { cursor: default; } @@ -730,6 +731,68 @@ body { color: var(--ink-muted); } +/* ---- rich search rows (#148): poster, rating chip, trailer chip -------- */ + +.row-rich { + align-items: flex-start; +} + +.row-main { + display: flex; + flex: 1; + align-items: baseline; + flex-wrap: wrap; + gap: var(--space-1) var(--space-3); + min-width: 0; +} + +/* w92 is the right order of magnitude for a row; the blank is the same + box, so the column edge holds while results stream in */ +.row-poster, +.row-poster-blank { + width: 2.75rem; + aspect-ratio: 2 / 3; + flex-shrink: 0; + align-self: center; + border: 1px solid var(--line); + border-radius: var(--radius); +} + +.row-poster { + display: block; + background: var(--panel-raised); + object-fit: cover; +} + +.chip[role="button"] { + cursor: pointer; + transition: + color 150ms var(--ease-out), + border-color 150ms var(--ease-out); +} + +.chip[role="button"]:hover, +.chip[role="button"]:focus-visible { + color: var(--accent); + border-color: oklch(from var(--accent) l c h / 45%); +} + +.row-trailer[data-state="pending"] { + color: var(--ink-faint); +} + +/* no video on TMDB is an ordinary outcome: quiet and dashed, not fault red */ +.row-trailer[data-state="none"] { + border-style: dashed; + color: var(--ink-faint); + cursor: default; +} + +.row-trailer[data-state="blocked"] { + color: var(--signal-warn); + cursor: default; +} + /* ---- add flow (inline, root and policy pre-filled) -------------------- */ .add-panel { @@ -1390,6 +1453,12 @@ body { fill: var(--ink-muted); } +/* a row chip is smaller than the detail page's rating readout */ +.row-rating .star { + width: 0.625rem; + height: 0.625rem; +} + .movie-meta { margin: 0; }