Files
arr/web/src/search.ts
T
Miguel Palhas 043ebffa1c 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.
2026-08-23 18:00:54 +01:00

252 lines
6.9 KiB
TypeScript

// Hand-written mirror of arr-api's /api/search, /api/roots, POST /api/movies
// and POST /api/series schemas — same reasoning as health.ts: the generated
// client (src/api/) is uncommitted, so CI's tsc cannot see it.
export type SearchInputKind = "text" | "tmdb_id" | "imdb_id" | "magnet" | "torrent_url";
export interface LibraryMovie {
kind: "movie";
id: number;
tmdb_id: number;
title: string;
year: number | null;
original_language: string | null;
root_id: number;
wanted: boolean;
state: "missing" | "downloading" | "available";
blocked: boolean;
/** The §5.7 rule relaxed to allow an import, when a file carries one. */
waiver?: unknown;
}
export interface LibrarySeriesHit {
kind: "series";
id: number;
tmdb_id: number;
title: string;
year: number | null;
original_language: string | null;
root_id: number;
blocked: boolean;
}
export interface LibraryEpisodeHit {
kind: "episode";
episode_id: number;
series_id: number;
series_title: string;
/** `SxxEyy`, for context next to the episode title. */
tag: string;
/** The episode title — what the search matched on. */
title: string;
}
export type LibraryResult = LibraryMovie | LibrarySeriesHit | LibraryEpisodeHit;
export interface TmdbMovie {
kind: "movie";
tmdb_id: number;
title: string;
original_title: string;
original_language: string;
year: number | null;
overview: string | null;
}
export interface TmdbSeries {
kind: "series";
tmdb_id: number;
title: string;
original_language: string;
year: number | null;
overview: string | null;
}
export type TmdbResult = TmdbMovie | TmdbSeries;
export interface SearchResponse {
kind: SearchInputKind;
library: LibraryResult[];
tmdb: TmdbResult[];
manual: string | null;
}
export interface Root {
id: number;
kind: string;
audience: string;
path: string;
policy_id: number;
policy_name: string;
}
export type SearchOutcome =
| { kind: "results"; response: SearchResponse }
| { kind: "error"; detail: string }
| { kind: "aborted" };
export async function searchTitles(query: string, signal: AbortSignal): Promise<SearchOutcome> {
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal });
if (!response.ok) {
const detail = await errorDetail(response);
return { kind: "error", detail };
}
return { kind: "results", response: (await response.json()) as SearchResponse };
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
return { kind: "aborted" };
}
return { kind: "error", detail: "daemon unreachable" };
}
}
/** A single movie by id — used to restore the release deck from a URL alone. */
export async function fetchMovie(movieId: number): Promise<LibraryMovie | null> {
try {
const response = await fetch(`/api/movies/${movieId}`);
if (!response.ok) {
return null;
}
return (await response.json()) as LibraryMovie;
} catch {
return null;
}
}
let rootsCache: Root[] | null = null;
/** Every root, fetched once. Audience chips resolve root ids through these. */
export async function allRoots(): Promise<Root[]> {
if (rootsCache) {
return rootsCache;
}
const response = await fetch("/api/roots");
if (!response.ok) {
throw new Error(await errorDetail(response));
}
rootsCache = (await response.json()) as Root[];
return rootsCache;
}
/** The stored series a successful POST /api/series returns. */
export interface ApiSeries {
id: number;
tmdb_id: number;
tvdb_id: number | null;
title: string;
year: number | null;
original_language: string | null;
root_id: number;
auto_track: boolean;
overrides: Record<string, unknown>;
upstream_ended: boolean;
blocked: boolean;
status: string;
wanted_episodes: number;
available_episodes: number;
}
export type AddOutcome =
| { kind: "added"; movie: LibraryMovie }
| { kind: "conflict" }
| { kind: "error"; detail: string };
export async function addMovie(movie: TmdbMovie, rootId: number): Promise<AddOutcome> {
try {
const response = await fetch("/api/movies", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
tmdb_id: movie.tmdb_id,
title: movie.title,
year: movie.year,
original_language: movie.original_language,
root_id: rootId,
}),
});
if (response.status === 409) {
return { kind: "conflict" };
}
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "added", movie: (await response.json()) as LibraryMovie };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
export type AddSeriesOutcome =
| { kind: "added"; series: ApiSeries }
| { kind: "conflict" }
| { kind: "error"; detail: string };
/** Add a series to a TV root, with the §4.1 initial auto_track choice. */
export async function addSeries(
series: TmdbSeries,
rootId: number,
autoTrack: boolean,
): Promise<AddSeriesOutcome> {
try {
const response = await fetch("/api/series", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
tmdb_id: series.tmdb_id,
title: series.title,
year: series.year,
original_language: series.original_language,
root_id: rootId,
auto_track: autoTrack,
}),
});
if (response.status === 409) {
return { kind: "conflict" };
}
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "added", series: (await response.json()) as ApiSeries };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
async function errorDetail(response: Response): Promise<string> {
try {
const body = (await response.json()) as { error?: string };
return body.error ?? `http ${response.status}`;
} catch {
return `http ${response.status}`;
}
}
export interface ManualIntake {
kind: "magnet" | "torrent_url";
/** The display name a magnet advertises, when it carries one. */
name: string | null;
/** The btih infohash, when the magnet carries one. */
infohash: string | null;
raw: string;
}
/** Best-effort read of a pasted magnet or .torrent URL, for display only. */
export function parseManualInput(kind: "magnet" | "torrent_url", raw: string): ManualIntake {
if (kind === "torrent_url") {
return { kind, name: raw.split("/").pop() ?? null, infohash: null, raw };
}
let name: string | null = null;
let infohash: string | null = null;
try {
const params = new URLSearchParams(raw.slice(raw.indexOf("?") + 1));
name = params.get("dn");
const urn = params.get("xt") ?? "";
const match = /^urn:btih:([0-9a-zA-Z]+)$/.exec(urn);
infohash = match?.[1]?.toLowerCase() ?? null;
} catch {
// display-only parsing; a malformed magnet still shows raw
}
return { kind, name, infohash, raw };
}