// Hand-written mirror of arr-api's series detail, season, episode and // release schemas — same reasoning as search.ts: the generated client // (src/api/) is uncommitted, so CI's tsc cannot see it. import type { MetadataTrailer } from "./movie"; import type { ActionOutcome, MovieRelease, PackStateOutcome, ReleasesOutcome, WaiveOutcome, } from "./releases"; import { errorDetail, probedAttributeTags, seasonPackState, waiverOverride } from "./releases"; /** §4.2 derived status — displayed, never editable. */ export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended"; export interface ApiSeries { id: number; tmdb_id: number; title: string; year: number | null; root_id: number; blocked: boolean; /** Null until a metadata refresh has stamped it (#160, #177). */ metadata_refreshed_at: string | null; status: SeriesStatus; wanted_episodes: number; available_episodes: number; } export interface ApiEpisode { id: number; series_id: number; season_id: number; season_number: number; number: number; title: string; air_date: string | null; wanted: boolean; state: string; /** issue 122: gone upstream while a file of its own remained. */ vanished: boolean; } export interface ApiSeason { id: number; number: number; tracked: boolean; /** Gone upstream while a file under it remained — a conflict, not a state. */ vanished: boolean; episodes: ApiEpisode[]; } export type SeasonsOutcome = | { kind: "seasons"; seasons: ApiSeason[] } | { kind: "error"; detail: string }; export async function fetchSeries(seriesId: number): Promise { try { const response = await fetch(`/api/series/${seriesId}`); return response.ok ? ((await response.json()) as ApiSeries) : null; } catch { return null; } } /** One episode by row id — the `/episodes/{id}/releases` deep link's lookup. */ export async function fetchEpisode(episodeId: number): Promise { try { const response = await fetch(`/api/episodes/${episodeId}`); return response.ok ? ((await response.json()) as ApiEpisode) : null; } catch { return null; } } export async function fetchSeasons(seriesId: number): Promise { try { const response = await fetch(`/api/series/${seriesId}/seasons`); if (!response.ok) { return { kind: "error", detail: await errorDetail(response) }; } return { kind: "seasons", seasons: (await response.json()) as ApiSeason[] }; } catch { return { kind: "error", detail: "daemon unreachable" }; } } /** Issue #177: resend the on-demand refresh a page gave up polling for. */ export async function refreshSeriesMetadata(seriesId: number): Promise { try { const response = await fetch(`/api/series/${seriesId}/refresh-metadata`, { method: "POST" }); if (!response.ok) { return { kind: "error", detail: await errorDetail(response) }; } return { kind: "done" }; } catch { return { kind: "error", detail: "daemon unreachable" }; } } /** One imported episode file as `/api/series/{id}/files` reports it (§7.4). */ export interface EpisodeFile { episode_id: number; path: string; size: number; probed: { resolution?: string | null; source?: string | null; hdr?: string | null; audio_tracks?: { language?: string | null }[] | null; } | null; waiver: string | null; } export type SeriesFilesOutcome = | { kind: "files"; files: EpisodeFile[] } | { kind: "error"; detail: string }; export async function fetchSeriesFiles(seriesId: number): Promise { try { const response = await fetch(`/api/series/${seriesId}/files`); if (!response.ok) { return { kind: "error", detail: await errorDetail(response) }; } return { kind: "files", files: (await response.json()) as EpisodeFile[] }; } catch { return { kind: "error", detail: "daemon unreachable" }; } } /** * The season's tracked toggle (§4.1): turning it on marks every revealed * episode wanted; turning it off withdraws nothing. */ export async function setSeasonTracked( seriesId: number, seasonNumber: number, tracked: boolean, ): Promise { try { const response = await fetch(`/api/series/${seriesId}/seasons/${seasonNumber}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ tracked }), }); if (!response.ok) { return { kind: "error", detail: await errorDetail(response) }; } return { kind: "done" }; } catch { return { kind: "error", detail: "daemon unreachable" }; } } export async function setEpisodeWanted(episodeId: number, wanted: boolean): Promise { try { const response = await fetch(`/api/episodes/${episodeId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ wanted }), }); if (!response.ok) { return { kind: "error", detail: await errorDetail(response) }; } return { kind: "done" }; } catch { return { kind: "error", detail: "daemon unreachable" }; } } /* ---- removal (issues 174 + 175) ---------------------------------------- */ /** * The §7.4 title folder these episode files share. Season subfolders differ * between files, so the shared folder is the one carrying the `[tmdbid-…]` * tag every §7.4 title folder name has. `null` when the files disagree, * which the panel then says instead of naming one folder falsely. */ export function seriesFolder(files: { path: string }[]): string | null { const folders = new Set(); for (const file of files) { const parts = file.path.split("/"); const titleAt = parts.findIndex((part) => part.includes("[tmdbid-")); const folder = titleAt > 0 ? parts.slice(0, titleAt + 1).join("/") : file.path.slice(0, file.path.lastIndexOf("/")); if (folder === "") { return null; } folders.add(folder); } if (folders.size !== 1) { return null; } return [...folders][0] ?? null; } /** * Remove the series from the library. The row and its §7.4 title folder go. * Torrents keep seeding — the reaper owns that lifecycle (§7.3). */ export function removeSeries(seriesId: number): Promise { return del(`/api/series/${seriesId}`); } /** * #174: the season's files go and its episodes stop being wanted. The season * stays listed — TMDB owns that metadata and the next refresh would recreate * it anyway. */ export function removeSeasonFiles(seriesId: number, seasonNumber: number): Promise { return del(`/api/series/${seriesId}/seasons/${seasonNumber}/files`); } /** #174, one episode: the file goes and the episode stops being wanted. */ export function removeEpisodeFiles(episodeId: number): Promise { return del(`/api/episodes/${episodeId}/files`); } async function del(url: string): Promise { try { const response = await fetch(url, { method: "DELETE" }); if (!response.ok) { return { kind: "error", detail: await errorDetail(response) }; } return { kind: "done" }; } catch { return { kind: "error", detail: "daemon unreachable" }; } } /* ---- manual triggers and decks ---------------------------------------- */ /** §6.2 manual search, one targeted sweep, for a season or an episode. */ export interface TvTarget { releases: () => Promise; grab: (releaseId: number) => Promise; search: () => Promise; /** * Why the deck is empty (#182). Seasons only: an episode deck has one * lane, so it has nothing to disambiguate. */ packState?: () => Promise; } export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget { const base = `/api/series/${seriesId}/seasons/${seasonNumber}`; return { releases: () => fetchJson(`${base}/releases`), grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`), search: () => post(`${base}/search`), packState: () => seasonPackState(seriesId, seasonNumber), }; } export function episodeTarget(episodeId: number): TvTarget { const base = `/api/episodes/${episodeId}`; return { releases: () => fetchJson(`${base}/releases`), grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`), search: () => post(`${base}/search`), }; } async function fetchJson(url: string): Promise { try { const response = await fetch(url); if (!response.ok) { return { kind: "error", detail: await errorDetail(response) }; } return { kind: "results", releases: (await response.json()) as MovieRelease[] }; } catch { return { kind: "error", detail: "daemon unreachable" }; } } async function post(url: string): Promise { try { const response = await fetch(url, { method: "POST" }); if (!response.ok) { return { kind: "error", detail: await errorDetail(response) }; } return { kind: "done" }; } catch { return { kind: "error", detail: "daemon unreachable" }; } } /** * The one-click override on a waived TV row (§5.1, §9.3). Overrides sit on * the series — `episode_policy` and `season_policy` both read them there — * so the relaxed rule is written once, then the grab proceeds. */ export async function waiveAndGrabTv( seriesId: number, target: TvTarget, releaseId: number, rule: string | null, ): Promise { const override = waiverOverride(rule); let overrideWritten = false; if (override) { try { const current = await fetch(`/api/series/${seriesId}`); if (!current.ok) { return { kind: "error", detail: await errorDetail(current), overrideWritten }; } const series = (await current.json()) as { overrides: Record }; const patch = await fetch(`/api/series/${seriesId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ overrides: { ...series.overrides, ...override } }), }); if (!patch.ok) { return { kind: "error", detail: await errorDetail(patch), overrideWritten }; } overrideWritten = true; } catch { return { kind: "error", detail: "daemon unreachable", overrideWritten }; } } const grabbed = await target.grab(releaseId); if (grabbed.kind === "error") { return { kind: "error", detail: grabbed.detail, overrideWritten }; } return { kind: "done", overrideWritten }; } /* ---- §9.6 rich detail --------------------------------------------------- */ /** Rich detail for one library series' §9.6 page (#146). */ export interface SeriesMetadata { tmdb_id: number; overview: string | null; tagline: string | null; genres: string[]; /** Minutes per episode. */ runtime: number | null; status: string; poster_path: string | null; backdrop_path: string | null; /** `null` when TMDB has no votes for the title (#156). */ vote_average: number | null; vote_count: number; homepage: string | null; /** §9.6 links out to TVDB for series; absent when the id is unknown. */ tvdb_id: number | null; trailer: MetadataTrailer | null; } export type SeriesMetadataOutcome = | { kind: "metadata"; metadata: SeriesMetadata } | { kind: "error"; detail: string }; /** One request for everything above the season tree (§9.6). */ export async function seriesMetadata(seriesId: number): Promise { try { const response = await fetch(`/api/series/${seriesId}/metadata`); if (!response.ok) { return { kind: "error", detail: await errorDetail(response) }; } return { kind: "metadata", metadata: (await response.json()) as SeriesMetadata }; } catch { return { kind: "error", detail: "daemon unreachable" }; } } /* ---- display helpers --------------------------------------------------- */ /** Wanted / on-disk counts for one season, whatever its number. */ export function seasonCounts(season: ApiSeason): { wanted: number; available: number } { let wanted = 0; let available = 0; for (const episode of season.episodes) { if (episode.wanted) { wanted += 1; if (episode.state === "available") { available += 1; } } } return { wanted, available }; } /** * §6.2: do not search before the thing exists. No air date yet counts as * not aired — TMDB has not dated it, so neither will we. */ export function isUnaired(airDate: string | null, now = new Date()): boolean { if (airDate === null) { return true; } const parsed = Date.parse(airDate); if (Number.isNaN(parsed)) { return true; } const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); return parsed > today.getTime() + 86_399_999; } /** `2026-08-10` as the readout spells it; unknown stays a dash. */ export function formatAirDate(airDate: string | null): string { return airDate ?? "—"; } /** The probed attributes an episode file row shows as chips (§5.6, §7.4). */ export function fileAttributeTags(file: EpisodeFile): string[] { return probedAttributeTags(file.probed); }