5e2bb5dff7
Widen the unified search so both §9.2 halves can render a thumbnail and a rating: TMDB results gain vote_average/vote_count from the search bodies, library results read the #145 artwork columns, and episodes take their series' art. GET /api/movies and /api/series gain the same two fields for the #151 grid. Mirrors in web updated.
110 lines
3.3 KiB
TypeScript
110 lines
3.3 KiB
TypeScript
// Hand-written mirror of arr-api's /api/series and /api/movies list schemas —
|
|
// same reasoning as search.ts: the generated client (src/api/) is
|
|
// uncommitted, so CI's tsc cannot see it.
|
|
|
|
import type { LibraryMovie } from "./search";
|
|
|
|
/** §4.2 derived status — displayed, never editable. */
|
|
export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended";
|
|
|
|
export interface LibrarySeries {
|
|
id: number;
|
|
tmdb_id: number;
|
|
title: string;
|
|
year: number | null;
|
|
original_language: string | null;
|
|
root_id: number;
|
|
auto_track: boolean;
|
|
upstream_ended: boolean;
|
|
blocked: boolean;
|
|
/** Stored artwork (§9.6) — a grid tile renders without a TMDB call. */
|
|
poster_path: string | null;
|
|
/** TMDB's rating, out of 10; `null` when TMDB has no votes for it. */
|
|
vote_average: number | null;
|
|
status: SeriesStatus;
|
|
wanted_episodes: number;
|
|
available_episodes: number;
|
|
}
|
|
|
|
export type LibraryOutcome =
|
|
| { kind: "results"; series: LibrarySeries[]; movies: LibraryMovie[] }
|
|
| { kind: "error"; detail: string };
|
|
|
|
export async function fetchLibrary(): Promise<LibraryOutcome> {
|
|
try {
|
|
const [series, movies] = await Promise.all([fetch("/api/series"), fetch("/api/movies")]);
|
|
if (!series.ok) {
|
|
return { kind: "error", detail: await errorDetail(series) };
|
|
}
|
|
if (!movies.ok) {
|
|
return { kind: "error", detail: await errorDetail(movies) };
|
|
}
|
|
return {
|
|
kind: "results",
|
|
series: (await series.json()) as LibrarySeries[],
|
|
movies: (await movies.json()) as LibraryMovie[],
|
|
};
|
|
} catch {
|
|
return { kind: "error", detail: "daemon unreachable" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* §4.2: the default list shows `airing` and `incomplete`; everything else
|
|
* collapses behind one toggle. A satisfied title leaves by itself.
|
|
*/
|
|
export function seriesNeedsAttention(series: LibrarySeries): boolean {
|
|
return series.status === "airing" || series.status === "incomplete";
|
|
}
|
|
|
|
/**
|
|
* The movie analogue of §4.2's default set: wanted and not yet on disk.
|
|
* Available and unwanted titles are satisfied and collapse with the rest.
|
|
*/
|
|
export function movieNeedsAttention(movie: LibraryMovie): boolean {
|
|
return movie.wanted && movie.state !== "available";
|
|
}
|
|
|
|
/**
|
|
* The §5.7 waiver, worded honestly: a file imported under
|
|
* `allow_english_audio` reads as "english, no dub", not as a clean match.
|
|
* The import pipeline records the relaxed rule; both the bare rule name and
|
|
* an object carrying one are accepted.
|
|
*/
|
|
export function waiverLabel(waiver: unknown): string | null {
|
|
const rule = waiverRule(waiver);
|
|
if (rule === null) {
|
|
return null;
|
|
}
|
|
switch (rule) {
|
|
case "required_audio":
|
|
return "english, no dub";
|
|
case "resolution":
|
|
return "below wanted resolution";
|
|
default:
|
|
return `waived · ${rule.replaceAll("_", " ")}`;
|
|
}
|
|
}
|
|
|
|
function waiverRule(waiver: unknown): string | null {
|
|
if (waiver === null || waiver === undefined) {
|
|
return null;
|
|
}
|
|
if (typeof waiver === "string") {
|
|
return waiver;
|
|
}
|
|
if (typeof waiver === "object" && "rule" in waiver && typeof waiver.rule === "string") {
|
|
return waiver.rule;
|
|
}
|
|
return "unknown_rule";
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
}
|