043ebffa1c
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.
252 lines
6.9 KiB
TypeScript
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 };
|
|
}
|