1e03873209
An empty season deck was three truths wearing one message, and the one
it chose to blame was wrong: a season on the per-episode lane sat on
"sweeping indexers…" for the full wait and then blamed a backoff for a
pack search that was never going to run.
`GET /api/series/{id}/seasons/{n}/pack-state` says which lane the
season takes and why, from `season_grab_reason` in arr-core, plus the
failed-pack tally and when #181's window reopens. Seasons gain
`last_pack_search_at`, written only by a season-scoped sweep, so a
pack search that ran and found nothing is a settled answer rather than
a pending one.
The deck then says the true thing in each case, and a season held off
the pack lane by a failure offers the retry that waives its window.
Refs #182
413 lines
13 KiB
TypeScript
413 lines
13 KiB
TypeScript
// 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<ApiSeries | null> {
|
|
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<ApiEpisode | null> {
|
|
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<SeasonsOutcome> {
|
|
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<ActionOutcome> {
|
|
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<SeriesFilesOutcome> {
|
|
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<ActionOutcome> {
|
|
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<ActionOutcome> {
|
|
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<string>();
|
|
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<ActionOutcome> {
|
|
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<ActionOutcome> {
|
|
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<ActionOutcome> {
|
|
return del(`/api/episodes/${episodeId}/files`);
|
|
}
|
|
|
|
async function del(url: string): Promise<ActionOutcome> {
|
|
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<ReleasesOutcome>;
|
|
grab: (releaseId: number) => Promise<ActionOutcome>;
|
|
search: () => Promise<ActionOutcome>;
|
|
/**
|
|
* Why the deck is empty (#182). Seasons only: an episode deck has one
|
|
* lane, so it has nothing to disambiguate.
|
|
*/
|
|
packState?: () => Promise<PackStateOutcome>;
|
|
}
|
|
|
|
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<ReleasesOutcome> {
|
|
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<ActionOutcome> {
|
|
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<WaiveOutcome> {
|
|
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<string, unknown> };
|
|
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<SeriesMetadataOutcome> {
|
|
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);
|
|
}
|