Files
arr/web/src/releases.ts
T
Miguel Palhas 9fee07f080 feat: let a size rejection be waived
A release below §5.5's floor was rejected with no way through, so a
policy wrong about one title left three Rick and Morty S09 packs
visible and none grabbable.

`allow_below_floor` relaxes the floor for one title into a soft fail,
never a pass: the release is waived, so automatic grabbing still skips
it and the import records a §5.7 waiver. The deck offers the one click
on a rejected row where the rule has an override, which is exactly what
§9.3's override is for.

Stored verdicts are re-derived when a title's overrides change — the
deck and the daemon's grab gate both read that column, so without it
the row the operator just acted on would keep reading `rejected`.

Closes #210

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 22:15:14 +01:00

478 lines
14 KiB
TypeScript

// Hand-written mirror of arr-api's /api/movies/{id}/releases, grab and
// override schemas — same reasoning as search.ts: the generated client
// (src/api/) is uncommitted, so CI's tsc cannot see it.
export type Bucket = "eligible" | "waived" | "rejected";
/** What the release name claims (arr-parse `NameClaims`, §5.6). */
export interface ParsedClaims {
resolution?: string | null;
source?: string | null;
codec?: string | null;
hdr?: string[];
languages?: string[];
}
export interface MovieRelease {
id: number;
indexer_id: number;
guid: string;
name: string;
size: number;
seeders: number | null;
publish_date: string | null;
download_url: string;
parsed: ParsedClaims;
score: number | null;
verdict: string | null;
rejected_rule: string | null;
}
export type ReleasesOutcome =
| { kind: "results"; releases: MovieRelease[] }
| { kind: "error"; detail: string };
export async function movieReleases(movieId: number): Promise<ReleasesOutcome> {
try {
const response = await fetch(`/api/movies/${movieId}/releases`);
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" };
}
}
/** The slice of a movie that says whether a sweep ran and whether one is due. */
export interface MovieSearchState {
wanted: boolean;
blocked: boolean;
state: string;
last_searched_at: string | null;
}
export type MovieStateOutcome =
| { kind: "state"; movie: MovieSearchState }
| { kind: "error"; detail: string };
export async function movieSearchState(movieId: number): Promise<MovieStateOutcome> {
try {
const response = await fetch(`/api/movies/${movieId}`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "state", movie: (await response.json()) as MovieSearchState };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/**
* Whether the daemon's reconcile loop will sweep this title on its own
* (§6.2 targeted search: wanted, not blocked, nothing on disk or in flight).
*/
export function sweepExpected(movie: MovieSearchState): boolean {
return movie.wanted && !movie.blocked && movie.state === "missing";
}
/** `last_searched_at` as a coarse age — evidence the empty verdict is real. */
export function formatSweepAge(lastSearchedAt: string, now = Date.now()): string {
const swept = Date.parse(lastSearchedAt);
if (Number.isNaN(swept)) {
return "earlier";
}
const minutes = Math.round((now - swept) / 60_000);
if (minutes < 2) {
return "just now";
}
if (minutes < 60) {
return `${minutes} min ago`;
}
if (minutes < 48 * 60) {
return `${Math.round(minutes / 60)} h ago`;
}
return `${Math.round(minutes / (24 * 60))} d ago`;
}
/** Which lane a season's missing episodes take (#182, §6.2). */
export type SeasonLane = "season_pack" | "per_episode";
/** What holds a season off the pack lane (#182). */
export type PackLaneReason = "no_episodes" | "still_airing" | "episodes_on_disk" | "pack_backoff";
/**
* Why the season deck holds what it holds, from
* `/api/series/{id}/seasons/{n}/pack-state`. An empty deck is three states,
* not one: a pack sweep still running, a pack sweep that found nothing, and
* a season on the per-episode lane, where no pack sweep is coming.
*/
export interface SeasonPackState {
lane: SeasonLane;
reason: PackLaneReason | null;
pack_failures: number;
pack_retry_at: string | null;
last_pack_search_at: string | null;
}
export type PackStateOutcome =
| { kind: "state"; state: SeasonPackState }
| { kind: "error"; detail: string };
export async function seasonPackState(
seriesId: number,
seasonNumber: number,
): Promise<PackStateOutcome> {
try {
const response = await fetch(`/api/series/${seriesId}/seasons/${seasonNumber}/pack-state`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "state", state: (await response.json()) as SeasonPackState };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/**
* `pack_retry_at` as a coarse wait. The deck says how long the lane stays
* quiet, so "it retries on its own" is a promise with a date on it.
*/
export function formatRetryWait(retryAt: string, now = Date.now()): string {
const reopens = Date.parse(retryAt);
if (Number.isNaN(reopens)) {
return "later";
}
const minutes = Math.round((reopens - now) / 60_000);
if (minutes < 2) {
return "any moment";
}
if (minutes < 60) {
return `${minutes} min`;
}
if (minutes < 48 * 60) {
return `${Math.round(minutes / 60)} h`;
}
return `${Math.round(minutes / (24 * 60))} d`;
}
/** One library file as `/api/movies/{id}/files` reports it (§5.6, §5.7). */
export interface MovieFile {
id: number;
path: string;
size: number;
probed: unknown;
waiver: string | null;
}
export type FilesOutcome =
| { kind: "files"; files: MovieFile[] }
| { kind: "error"; detail: string };
/**
* What this title actually put on disk. The removal confirmation names it
* rather than promising in the abstract: the service knows only what it
* wrote (§2), so this list is the whole truth about what a files-too remove
* would unlink.
*/
export async function movieFiles(movieId: number): Promise<FilesOutcome> {
try {
const response = await fetch(`/api/movies/${movieId}/files`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "files", files: (await response.json()) as MovieFile[] };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/**
* The §7.4 folder these files share — one folder per title, so a single path
* names everything a files-too remove takes. `null` when the files disagree,
* which the panel then says instead of naming one folder falsely.
*/
export function libraryFolder(files: { path: string }[]): string | null {
const folders = new Set(files.map((file) => file.path.slice(0, file.path.lastIndexOf("/"))));
if (folders.size !== 1) {
return null;
}
const folder = [...folders][0];
return folder === undefined || folder === "" ? null : folder;
}
export function totalSize(files: { size: number }[]): number {
return files.reduce((sum, file) => sum + file.size, 0);
}
export type ActionOutcome = { kind: "done" } | { kind: "error"; detail: string };
/**
* Remove the title from the library. Both the row and its §7.4 folder go.
* The torrent keeps seeding — the reaper owns that lifecycle (§7.3) and a
* hardlinked file loses only its library name.
*/
export async function removeMovie(movieId: number): Promise<ActionOutcome> {
try {
const response = await fetch(`/api/movies/${movieId}`, {
method: "DELETE",
});
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/** §6.2 manual trigger: one targeted sweep, user-initiated. */
export async function queueSearch(movieId: number): Promise<ActionOutcome> {
try {
const response = await fetch(`/api/movies/${movieId}/search`, { method: "POST" });
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
export async function grabRelease(movieId: number, releaseId: number): Promise<ActionOutcome> {
try {
const response = await fetch(`/api/movies/${movieId}/releases/${releaseId}/grab`, {
method: "POST",
});
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/**
* The per-title override one click on a waived row writes (§5.2, §9.3).
* Mapping a rule name to its override is bookkeeping, not policy — the
* verdict itself always comes from the API.
*
* `size` relaxes §5.5's floor for this title only, and only into a waiver:
* the release stays out of automatic grabbing and imports on the record as
* a §5.7 waiver. No band is right for every title, which is why the
* override exists at all.
*/
export function waiverOverride(rule: string | null): Record<string, unknown> | null {
switch (rule) {
case "required_audio":
return { allow_english_audio: true };
case "resolution":
return { only_4k: false };
case "size":
return { allow_below_floor: true };
default:
return null;
}
}
/** Whether one click on this row has an override to write (§9.3). */
export function overridable(release: { rejected_rule: string | null }): boolean {
return waiverOverride(release.rejected_rule) !== null;
}
export type WaiveOutcome =
| { kind: "done"; overrideWritten: boolean }
| { kind: "error"; detail: string; overrideWritten: boolean };
/** One click on a waived row: write the override, then grab (§9.3). */
export async function waiveAndGrab(
movieId: number,
releaseId: number,
rule: string | null,
): Promise<WaiveOutcome> {
const override = waiverOverride(rule);
let overrideWritten = false;
if (override) {
try {
const current = await fetch(`/api/movies/${movieId}`);
if (!current.ok) {
return { kind: "error", detail: await errorDetail(current), overrideWritten };
}
const movie = (await current.json()) as { overrides: Record<string, unknown> };
const patch = await fetch(`/api/movies/${movieId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ overrides: { ...movie.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 grabRelease(movieId, releaseId);
if (grabbed.kind === "error") {
return { kind: "error", detail: grabbed.detail, overrideWritten };
}
return { kind: "done", overrideWritten };
}
export function bucketOf(release: MovieRelease): Bucket {
if (release.verdict === "eligible") {
return "eligible";
}
if (release.verdict === "waived") {
return "waived";
}
return "rejected";
}
/**
* The probed attributes a file row shows as chips (§5.6, §7.4) — ffprobe
* truth, not name claims. Shared by movie and episode file rows; the probe
* JSON arrives untyped from the API, so the shape is narrowed here.
*/
export function probedAttributeTags(probed: unknown): string[] {
if (probed === null || typeof probed !== "object") {
return [];
}
const probe = probed as {
resolution?: string | null;
source?: string | null;
hdr?: string | null;
audio_tracks?: { language?: string | null }[] | null;
};
const tags: string[] = [];
if (probe.resolution) {
tags.push(probe.resolution);
}
if (probe.source) {
tags.push(probe.source);
}
if (probe.hdr && probe.hdr !== "SDR") {
tags.push(probe.hdr);
}
for (const track of probe.audio_tracks ?? []) {
if (track.language) {
tags.push(track.language);
}
}
return tags;
}
/* ---- chip formatting: parsed attributes to fixed-width mono values ---- */
const SOURCE_LABEL: Record<string, string> = {
Cam: "cam",
Telesync: "ts",
Telecine: "tc",
Screener: "scr",
Dvd: "dvd",
Hdtv: "hdtv",
WebRip: "webrip",
WebDl: "web-dl",
BluRay: "bluray",
Remux: "remux",
};
const HDR_LABEL: Record<string, string> = {
DolbyVision: "DV",
Hdr10Plus: "HDR10+",
Hdr10: "HDR10",
Hdr: "HDR",
Hlg: "HLG",
Sdr: "SDR",
};
const LANGUAGE_LABEL: Record<string, string> = {
PtBr: "pt-BR",
PtPt: "pt-PT",
Portuguese: "pt",
English: "en",
French: "fr",
German: "de",
Spanish: "es",
Italian: "it",
Multi: "multi",
Dual: "dual",
};
/** §5.7 rule names as short chip words. */
const RULE_LABEL: Record<string, string> = {
required_audio: "audio",
dub_blacklist: "dub",
portuguese_unverified: "pt unverified",
dolby_vision_profile: "dv profile",
resolution: "resolution",
source: "source",
size: "size",
};
export function formatScore(score: number | null): string {
return score === null ? "—" : String(Math.round(score));
}
export function formatResolution(parsed: ParsedClaims): string {
return parsed.resolution ?? "—";
}
export function formatSource(parsed: ParsedClaims): string {
if (!parsed.source) {
return "—";
}
return SOURCE_LABEL[parsed.source] ?? parsed.source.toLowerCase();
}
export function formatHdr(parsed: ParsedClaims): string {
const markers = parsed.hdr ?? [];
if (markers.length === 0) {
return "—";
}
return markers.map((marker) => HDR_LABEL[marker] ?? marker).join(" ");
}
export function formatAudio(parsed: ParsedClaims): string {
const markers = parsed.languages ?? [];
if (markers.length === 0) {
return "—";
}
return markers.map((marker) => LANGUAGE_LABEL[marker] ?? marker.toLowerCase()).join(" ");
}
export function formatSize(bytes: number): string {
if (bytes <= 0) {
return "—";
}
const gib = bytes / 2 ** 30;
if (gib >= 100) {
return `${Math.round(gib)}G`;
}
if (gib >= 1) {
return `${gib.toFixed(1)}G`;
}
return `${Math.round(bytes / 2 ** 20)}M`;
}
export function formatSeeders(seeders: number | null): string {
return seeders === null ? "—" : String(seeders);
}
export function ruleLabel(rule: string | null): string {
if (rule === null) {
return "unclassified";
}
return RULE_LABEL[rule] ?? rule.replaceAll("_", " ");
}
export 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}`;
}
}