104 lines
3.5 KiB
TypeScript
104 lines
3.5 KiB
TypeScript
// Hand-written mirror of arr-api's /api/queues/attention schema — same
|
||
// reasoning as search.ts: the generated client (src/api/) is uncommitted,
|
||
// so CI's tsc cannot see it.
|
||
|
||
import { type ActionOutcome, queueSearch } from "./releases";
|
||
import type { LibraryMovie } from "./search";
|
||
|
||
/** A queue entry: the movie plus its §6.2 search history. */
|
||
export interface AttentionMovie extends LibraryMovie {
|
||
search_attempts: number;
|
||
last_searched_at: string | null;
|
||
}
|
||
|
||
/** A series in a TV attention queue, with what put it there (§9.5). */
|
||
export interface SeriesAttention {
|
||
series_id: number;
|
||
tmdb_id: number;
|
||
title: string;
|
||
year: number | null;
|
||
episodes: number[];
|
||
seasons: number[];
|
||
}
|
||
|
||
export interface AttentionQueues {
|
||
no_pt_source: AttentionMovie[];
|
||
needs_decision: AttentionMovie[];
|
||
tv_no_pt_source: SeriesAttention[];
|
||
tv_needs_decision: SeriesAttention[];
|
||
}
|
||
|
||
export type AttentionOutcome =
|
||
| { kind: "results"; queues: AttentionQueues }
|
||
| { kind: "error"; detail: string };
|
||
|
||
export async function fetchAttention(): Promise<AttentionOutcome> {
|
||
try {
|
||
const response = await fetch("/api/queues/attention");
|
||
if (!response.ok) {
|
||
return { kind: "error", detail: await errorDetail(response) };
|
||
}
|
||
return { kind: "results", queues: (await response.json()) as AttentionQueues };
|
||
} catch {
|
||
return { kind: "error", detail: "daemon unreachable" };
|
||
}
|
||
}
|
||
|
||
export type AllowEnglishOutcome =
|
||
| { kind: "done"; searchQueued: boolean }
|
||
| { kind: "error"; detail: string; overrideWritten: boolean };
|
||
|
||
/**
|
||
* The §5.2 one click: write `allow_english_audio` on the title, then queue
|
||
* the normal search. Stored verdicts are stale until the daemon
|
||
* reclassifies, so emptying the queue is the search's job, not a re-read's.
|
||
*/
|
||
export async function allowEnglishAudio(movieId: number): Promise<AllowEnglishOutcome> {
|
||
try {
|
||
const current = await fetch(`/api/movies/${movieId}`);
|
||
if (!current.ok) {
|
||
return { kind: "error", detail: await errorDetail(current), overrideWritten: false };
|
||
}
|
||
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, allow_english_audio: true } }),
|
||
});
|
||
if (!patch.ok) {
|
||
return { kind: "error", detail: await errorDetail(patch), overrideWritten: false };
|
||
}
|
||
} catch {
|
||
return { kind: "error", detail: "daemon unreachable", overrideWritten: false };
|
||
}
|
||
const search: ActionOutcome = await queueSearch(movieId);
|
||
if (search.kind === "error") {
|
||
return { kind: "error", detail: `search not queued — ${search.detail}`, overrideWritten: true };
|
||
}
|
||
return { kind: "done", searchQueued: true };
|
||
}
|
||
|
||
/** Entries across all four lanes — the rail badge's number. */
|
||
export function attentionTotal(queues: AttentionQueues): number {
|
||
return (
|
||
queues.no_pt_source.length +
|
||
queues.needs_decision.length +
|
||
queues.tv_no_pt_source.length +
|
||
queues.tv_needs_decision.length
|
||
);
|
||
}
|
||
|
||
/** `searched 3×`, or null before the first attempt ever lands here. */
|
||
export function attemptsLabel(attempts: number): string | null {
|
||
return attempts > 0 ? `searched ${attempts}×` : null;
|
||
}
|
||
|
||
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}`;
|
||
}
|
||
}
|