Files
arr/web/src/library.ts
T
Miguel Palhas 5e2bb5dff7 feat(api): carry poster and rating through search and lists
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.
2026-08-23 22:45:08 +01:00

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