|
|
|
@@ -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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|