Merge #130: add a series from unified search

Closes #130
This commit is contained in:
Miguel Palhas
2026-08-23 18:05:45 +01:00
4 changed files with 155 additions and 35 deletions
+14 -5
View File
@@ -17,8 +17,11 @@ reads the library as derived status, not flags.
anywhere.
- Structure (pinned by DESIGN.md §9.2): IN LIBRARY group first, ON TMDB below,
engraved micro-label headings, machined rows, chips on the right.
- Add flow: inline panel under a TMDB row — root radio cards (audience name,
policy name, path), main pre-selected, one confirm. Never a separate page.
- Add flow: inline panel under a TMDB row of either kind — root radio cards
(audience name, policy name, path), main pre-selected, one confirm. Never a
separate page. A series adds the one §4.1 add-time question: an auto-track
pressed control (default on) with the plain line "future seasons are tracked
automatically; past seasons are not", echoed in the added confirmation.
- Release deck (§9.3, issue #31): a library row opens the buckets view.
ELIGIBLE leads with fixed-width attribute chips in aligned mono columns
(rem-width `.cw-*` classes shared by chips and the column header, so the
@@ -59,7 +62,13 @@ reads the library as derived status, not flags.
- Constraints: 250 ms debounce, AbortController cancellation, token-gated CSS,
cyan for interaction only, dead-air rule (no idle animation), no horizontal
scroll at any viewport (§9.3 acceptance criterion).
- Unified search covers both kinds (issue 130): in-library hits read as
whatever they are — a movie row, a series hit (display-only until the
detail view of issue 129 exists) and an episode hit rendered as its series,
an `SxxEyy` chip and the episode title in sans beside it. TMDB rows show TV
alongside film; the "in library" dedup spans movies and series.
- Unresolved: releases land in the persisted store only once the daemon
consumes search commands (#68-adjacent); TMDB search is movie-only until
series land (#39); per-season and per-episode library actions are #39;
attention queues surface is #33.
consumes search commands (#68-adjacent); episode hits have no link target
until issue 129's series detail view exists — they render display-only for
now; per-season and per-episode library actions are #39; attention queues
surface is #33.
+127 -20
View File
@@ -42,15 +42,18 @@ import {
import { currentRoute, navigate, type Route } from "./router";
import {
addMovie,
addSeries,
allRoots,
fetchMovie,
type LibraryEpisodeHit,
type LibraryMovie,
movieRoots,
type LibrarySeriesHit,
parseManualInput,
type Root,
type SearchResponse,
searchTitles,
type TmdbMovie,
type TmdbSeries,
} from "./search";
import { settingsMain } from "./settings";
import "./style.css";
@@ -322,11 +325,11 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV
manualIntake: must<HTMLElement>("#manual-intake"),
};
// The audience chip on a library row needs id → root. movieRoots() caches
// The audience chip on a library row needs id → root. allRoots() caches
// success, so a failed boot fetch is retried whenever an add panel opens.
let roots: Root[] = [];
const fetchRoots = () =>
movieRoots().then(
allRoots().then(
(fetched) => {
roots = fetched;
return fetched;
@@ -406,11 +409,18 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV
return;
}
// Series and episode hits render in issue 130; movies keep the current row.
// §9.2: in-library hits read as whatever they are — a movie, a series,
// or an episode with its series and SxxEyy for context
const libraryMovies = response.library.filter(
(hit): hit is LibraryMovie => hit.kind === "movie",
);
const inLibrary = new Set(libraryMovies.map((movie) => movie.tmdb_id));
const librarySeries = response.library.filter(
(hit): hit is LibrarySeriesHit => hit.kind === "series",
);
const libraryEpisodes = response.library.filter(
(hit): hit is LibraryEpisodeHit => hit.kind === "episode",
);
const inLibrary = new Set([...libraryMovies, ...librarySeries].map((title) => title.tmdb_id));
if (response.library.length === 0 && response.tmdb.length === 0) {
setStatus("no matches in library or on tmdb");
return;
@@ -428,14 +438,17 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV
}),
);
}
for (const series of librarySeries) {
refs.groups.library.rows.append(librarySeriesRow(series, roots));
}
for (const episode of libraryEpisodes) {
refs.groups.library.rows.append(episodeRow(episode));
}
}
if (response.tmdb.length > 0) {
refs.groups.tmdb.section.hidden = false;
refs.groups.tmdb.count.textContent = String(response.tmdb.length);
for (const hit of response.tmdb) {
if (hit.kind !== "movie") {
continue;
}
refs.groups.tmdb.rows.append(tmdbRow(hit, inLibrary.has(hit.tmdb_id), fetchRoots));
}
}
@@ -606,8 +619,51 @@ function libraryRow(
return item;
}
/**
* An in-library series hit. Display-only, like its library-view sibling —
* the series detail view it would open into is issue 129.
*/
function librarySeriesRow(series: LibrarySeriesHit, roots: Root[]): HTMLLIElement {
const item = document.createElement("li");
const row = document.createElement("div");
row.className = "row";
const chips = document.createElement("span");
chips.className = "row-chips";
const root = roots.find((candidate) => candidate.id === series.root_id);
chips.append(chip(root ? root.audience : `root ${series.root_id}`));
if (series.blocked) {
chips.append(chip("blocked"));
}
row.append(rowTitle(series.title, series.year), chips);
item.append(row);
return item;
}
/** An in-library episode hit: its series, the `SxxEyy` tag, then the title. */
function episodeRow(episode: LibraryEpisodeHit): HTMLLIElement {
const item = document.createElement("li");
const row = document.createElement("div");
row.className = "row";
const chips = document.createElement("span");
chips.className = "row-chips";
chips.append(
chip(episode.tag, (span) => {
span.setAttribute("aria-label", `${episode.series_title} ${episode.tag}, ${episode.title}`);
}),
);
// the episode title is a name, not a readout: sans, never mono
const title = document.createElement("span");
title.className = "row-sub";
title.textContent = episode.title;
chips.append(title);
row.append(rowTitle(episode.series_title, null), chips);
item.append(row);
return item;
}
/** A TMDB hit of either kind — the row opens the add flow (§9.2). */
function tmdbRow(
movie: TmdbMovie,
hit: TmdbMovie | TmdbSeries,
inLibrary: boolean,
fetchRoots: () => Promise<Root[]>,
): HTMLLIElement {
@@ -618,7 +674,7 @@ function tmdbRow(
row.className = "row row-tmdb";
const chips = document.createElement("span");
chips.className = "row-chips";
chips.append(chip(movie.original_language));
chips.append(chip(hit.original_language));
if (inLibrary) {
chips.append(
chip("in library", (span) => {
@@ -631,11 +687,11 @@ function tmdbRow(
add.textContent = "add";
chips.append(add);
}
row.append(rowTitle(movie.title, movie.year), chips);
if (movie.overview) {
row.append(rowTitle(hit.title, hit.year), chips);
if (hit.overview) {
const overview = document.createElement("span");
overview.className = "row-overview";
overview.textContent = movie.overview;
overview.textContent = hit.overview;
row.append(overview);
}
item.append(row);
@@ -658,7 +714,7 @@ function tmdbRow(
if (item.querySelector(".add-panel")) {
return;
}
const panel = addPanel(movie, available, row);
const panel = addPanel(hit, available, row);
item.append(panel);
row.setAttribute("aria-expanded", "true");
panel.querySelector<HTMLElement>(".control")?.focus();
@@ -667,8 +723,19 @@ function tmdbRow(
return item;
}
/** The §9.2 add flow: inline, root and policy pre-filled, one confirm. */
function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTMLElement {
/**
* The §9.2 add flow: inline, root and policy pre-filled, one confirm. A
* series asks the one series-level question at add time — `auto_track`
* (§4.1) — and states what it does in one plain line.
*/
function addPanel(
title: TmdbMovie | TmdbSeries,
available: Root[],
row: HTMLButtonElement,
): HTMLElement {
const isSeries = title.kind === "series";
const kind = isSeries ? "tv" : "movie";
const roots = available.filter((root) => root.kind === kind);
const panel = document.createElement("div");
panel.className = "add-panel";
@@ -707,6 +774,34 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML
options.append(option);
}
// §4.1: auto_track is a rule about seasons metadata reveals, not intent.
// Future seasons are picked up by themselves; past seasons never are.
let autoTrack = true;
let autotrack: HTMLElement | null = null;
if (isSeries) {
const track = document.createElement("button");
track.type = "button";
track.className = "control control-quiet";
track.setAttribute("aria-pressed", "true");
const trackState = () => {
track.textContent = `auto-track future seasons: ${autoTrack ? "on" : "off"}`;
};
trackState();
track.addEventListener("click", () => {
autoTrack = !autoTrack;
track.setAttribute("aria-pressed", String(autoTrack));
trackState();
});
const trackNote = document.createElement("p");
trackNote.className = "add-note readout dim";
trackNote.textContent = "future seasons are tracked automatically; past seasons are not";
autotrack = document.createElement("div");
autotrack.className = "add-autotrack";
autotrack.append(track, trackNote);
}
const confirm = document.createElement("button");
confirm.type = "button";
confirm.className = "control";
@@ -720,7 +815,11 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML
if (roots.length === 0) {
confirm.disabled = true;
note.hidden = false;
note.textContent = "roots unavailable — daemon down or database missing";
// a failed fetch and an absent kind are different problems
note.textContent =
available.length === 0
? "roots unavailable — daemon down or database missing"
: `no ${isSeries ? "tv" : "movie"} roots configured — create one under settings`;
note.dataset.tone = "fault";
}
@@ -733,7 +832,9 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML
note.hidden = false;
delete note.dataset.tone;
note.textContent = "adding…";
void addMovie(movie, root.id).then((outcome) => {
const adding =
title.kind === "movie" ? addMovie(title, root.id) : addSeries(title, root.id, autoTrack);
void adding.then((outcome) => {
if (outcome.kind === "error") {
confirm.disabled = false;
note.textContent = `add failed — ${outcome.detail}`;
@@ -747,7 +848,9 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML
done.tabIndex = -1;
done.textContent = already
? "already in library"
: `added to ${root.audience} — wanted, search follows`;
: title.kind === "movie"
? `added to ${root.audience} — wanted, search follows`
: `added to ${root.audience} — future seasons ${autoTrack ? "" : "not "}tracked automatically`;
panel.replaceChildren(done);
done.focus();
row.disabled = true;
@@ -763,7 +866,11 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML
const actions = document.createElement("div");
actions.className = "add-actions";
actions.append(confirm, note);
panel.append(options, actions);
if (autotrack) {
panel.append(options, autotrack, actions);
} else {
panel.append(options, actions);
}
return panel;
}
-10
View File
@@ -129,16 +129,6 @@ export async function allRoots(): Promise<Root[]> {
return rootsCache;
}
/** The movie roots. The add flow pre-fills from these. */
export async function movieRoots(): Promise<Root[]> {
return (await allRoots()).filter((root) => root.kind === "movie");
}
/** The TV roots. The series add flow pre-fills from these. */
export async function tvRoots(): Promise<Root[]> {
return (await allRoots()).filter((root) => root.kind === "tv");
}
/** The stored series a successful POST /api/series returns. */
export interface ApiSeries {
id: number;
+14
View File
@@ -724,6 +724,12 @@ body {
text-overflow: ellipsis;
}
/* an episode hit's title sits beside its SxxEyy chip: a name, not a readout */
.row-sub {
font-size: var(--text-sm);
color: var(--ink-muted);
}
/* ---- add flow (inline, root and policy pre-filled) -------------------- */
.add-panel {
@@ -786,6 +792,14 @@ body {
margin: var(--space-3) 0 0;
}
/* the one series-level add-time question (§4.1): the choice and what it does */
.add-autotrack {
display: grid;
justify-content: start;
gap: var(--space-1);
margin: var(--space-3) 0 0;
}
.add-note {
margin: 0;
font-size: var(--text-xs);