feat(web): series and episodes in unified search
In-library hits now read as whatever they are: movies open the release deck as before, series hits render display-only until issue 129's detail view gives them a link target, and an episode hit reads as its series plus SxxEyy plus the episode title. TMDB results show TV alongside film, and the add panel filters roots by kind; a series adds the one §4.1 add-time choice, auto_track, stated plainly: future seasons are tracked automatically, past seasons are not.
This commit is contained in:
+127
-20
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user