9fee07f080
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>
478 lines
14 KiB
TypeScript
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}`;
|
|
}
|
|
}
|