// 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 { 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 { 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 }; 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 { try { const body = (await response.json()) as { error?: string }; return body.error ?? `http ${response.status}`; } catch { return `http ${response.status}`; } }