feat(arr): show subtitle chips on title detail

Per media file: a chip per present language (origin, forced/SDH, sync
flag, dashed for machine translation) and a chip per still-missing
wanted language naming why (§15, #201). Movie and series pages both
wire it in through the new status endpoints.
This commit is contained in:
Miguel Palhas
2026-08-25 02:16:25 +01:00
parent e5092034d3
commit e684813d0c
3 changed files with 254 additions and 7 deletions
+98 -7
View File
@@ -105,6 +105,16 @@ import {
} from "./series"; } from "./series";
import { armedDelete, settingsMain } from "./settings"; import { armedDelete, settingsMain } from "./settings";
import "./style.css"; import "./style.css";
import {
type EpisodeSubtitleStatus,
type MissingSubtitle,
missingChipLabel,
movieSubtitleStatus,
type Subtitle,
type SubtitleStatus,
seriesSubtitleStatus,
subtitleChipLabel,
} from "./subtitles";
const POLL_MS = 15_000; const POLL_MS = 15_000;
@@ -758,6 +768,63 @@ function countsChip(
}); });
} }
/* ---- subtitle status (§15, §9.6, issue #201) --------------------------- */
/**
* One present-subtitle chip. Green like an eligible release (the want is
* met); a forced track never satisfies a want (§15) so it stays neutral
* instead. `data-mt` marks a machine translation with the same dashed
* border a waived release already carries — a translation is a relaxed
* case of "has a subtitle", not the clean one, and §15 wants it readable at
* a glance rather than by hovering for the origin word.
*/
function subtitleChip(subtitle: Subtitle): HTMLSpanElement {
return chip(subtitleChipLabel(subtitle), (span) => {
if (!subtitle.forced) {
span.dataset.verdict = "eligible";
}
if (subtitle.origin === "translated") {
span.dataset.mt = "true";
}
});
}
/** One missing-language chip: the same amber "wanted, not yet on disk" ramp §4.2 already uses. */
function missingSubtitleChip(missing: MissingSubtitle): HTMLSpanElement {
return chip(missingChipLabel(missing), (span) => {
span.dataset.movieState = "missing";
span.dataset.wanted = "true";
if (missing.reason === "failed" && missing.detail !== null) {
span.title = missing.detail;
}
});
}
/**
* The subtitle status row for one media file (§9.6): every present
* language, then every wanted language still missing, each carrying its own
* reason. `null` only when there is nothing to say — no subtitles and
* nothing wanted — which does not happen with the shipped default wanted
* set, but an operator could empty it from `/settings`.
*/
function subtitleStatusLine(status: SubtitleStatus | null): HTMLElement | null {
if (status === null) {
return null;
}
if (status.subtitles.length === 0 && status.missing.length === 0) {
return null;
}
const line = document.createElement("div");
line.className = "rel-line subtitle-line";
for (const subtitle of status.subtitles) {
line.append(subtitleChip(subtitle));
}
for (const missing of status.missing) {
line.append(missingSubtitleChip(missing));
}
return line;
}
function rowTitle(title: string, year: number | null): HTMLElement { function rowTitle(title: string, year: number | null): HTMLElement {
const wrap = document.createElement("span"); const wrap = document.createElement("span");
wrap.className = "row-id"; wrap.className = "row-id";
@@ -1339,6 +1406,7 @@ function movieMain(views: HideableView[]): MovieView {
let movieId: number | null = null; let movieId: number | null = null;
let current: LibraryMovie | null = null; let current: LibraryMovie | null = null;
let roots: Root[] = []; let roots: Root[] = [];
let subtitlesByFile = new Map<number, SubtitleStatus>();
let removed: ((parent: Route) => void) | null = null; let removed: ((parent: Route) => void) | null = null;
let origin: HTMLElement | null = null; let origin: HTMLElement | null = null;
let returnTo: HTMLElement | null = null; let returnTo: HTMLElement | null = null;
@@ -1663,6 +1731,10 @@ function movieMain(views: HideableView[]): MovieView {
); );
} }
item.append(line); item.append(line);
const subtitleLine = subtitleStatusLine(subtitlesByFile.get(file.id) ?? null);
if (subtitleLine !== null) {
item.append(subtitleLine);
}
diskRows.append(item); diskRows.append(item);
} }
} }
@@ -1865,9 +1937,10 @@ function movieMain(views: HideableView[]): MovieView {
sequence += 1; sequence += 1;
const ticket = sequence; const ticket = sequence;
window.clearTimeout(pollTimer); window.clearTimeout(pollTimer);
const [movie, filesOutcome, fetchedRoots] = await Promise.all([ const [movie, filesOutcome, subtitleOutcome, fetchedRoots] = await Promise.all([
fetchMovie(id), fetchMovie(id),
movieFiles(id), movieFiles(id),
movieSubtitleStatus(id),
allRoots().catch(() => roots), allRoots().catch(() => roots),
]); ]);
if (ticket !== sequence || movieId !== id) { if (ticket !== sequence || movieId !== id) {
@@ -1879,6 +1952,11 @@ function movieMain(views: HideableView[]): MovieView {
return; return;
} }
current = movie; current = movie;
subtitlesByFile = new Map(
subtitleOutcome.kind === "status"
? subtitleOutcome.statuses.map((status) => [status.media_file_id, status])
: [],
);
clearRichDetail(); clearRichDetail();
paintIdentity(); paintIdentity();
paintControls(); paintControls();
@@ -3129,6 +3207,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
let series: ApiSeries | null = null; let series: ApiSeries | null = null;
let seasons: ApiSeason[] | null = null; let seasons: ApiSeason[] | null = null;
let filesByEpisode = new Map<number, EpisodeFile>(); let filesByEpisode = new Map<number, EpisodeFile>();
let subtitlesByEpisode = new Map<number, EpisodeSubtitleStatus>();
// which seasons stand open survives the refetch every action triggers // which seasons stand open survives the refetch every action triggers
let expanded = new Set<number>(); let expanded = new Set<number>();
let origin: HTMLElement | null = null; let origin: HTMLElement | null = null;
@@ -3591,6 +3670,10 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
} }
item.append(tag, name, air, chips, actions); item.append(tag, name, air, chips, actions);
const subtitleLine = subtitleStatusLine(subtitlesByEpisode.get(episode.id) ?? null);
if (subtitleLine !== null) {
item.append(subtitleLine);
}
return item; return item;
} }
@@ -3722,12 +3805,15 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const ticket = sequence; const ticket = sequence;
window.clearTimeout(refreshPollTimer); window.clearTimeout(refreshPollTimer);
setStatus("reading series…"); setStatus("reading series…");
const [detail, seasonsOutcome, filesOutcome, fetchedRoots] = await Promise.all([ const [detail, seasonsOutcome, filesOutcome, subtitleOutcome, fetchedRoots] = await Promise.all(
fetchSeries(id), [
fetchSeasons(id), fetchSeries(id),
fetchSeriesFiles(id), fetchSeasons(id),
allRoots().catch(() => roots), fetchSeriesFiles(id),
]); seriesSubtitleStatus(id),
allRoots().catch(() => roots),
],
);
if (ticket !== sequence || seriesId !== id) { if (ticket !== sequence || seriesId !== id) {
return; return;
} }
@@ -3747,6 +3833,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
? filesOutcome.files.map((file) => [file.episode_id, file]) ? filesOutcome.files.map((file) => [file.episode_id, file])
: [], : [],
); );
subtitlesByEpisode = new Map(
subtitleOutcome.kind === "status"
? subtitleOutcome.statuses.map((status) => [status.episode_id, status])
: [],
);
paintHeader(); paintHeader();
clearRichDetail(); clearRichDetail();
renderSeasons(); renderSeasons();
+17
View File
@@ -1464,6 +1464,23 @@ body {
border-color: oklch(from var(--signal-warn) l c h / 55%); border-color: oklch(from var(--signal-warn) l c h / 55%);
} }
/* ---- subtitle status (§15, §9.6, issue #201) --------------------------- */
/* forces the subtitle row below a disk-row's or episode's own line, the
same 100%-basis trick .rel-name uses to keep the release name secondary */
.subtitle-line {
flex-basis: 100%;
padding-top: var(--space-1);
margin-top: var(--space-1);
border-top: 1px solid oklch(from var(--line) l c h / 30%);
}
/* a machine translation reads as machine-made at a glance (§15) — the same
dashed mark a waived release already carries for "not the clean case" */
.chip[data-mt="true"] {
border-style: dashed;
}
/* ---- attention queues (§5.2 + §5.7) ------------------------------------ */ /* ---- attention queues (§5.2 + §5.7) ------------------------------------ */
/* the badge is a signal, not a control: amber attention on the cyan word */ /* the badge is a signal, not a control: amber attention on the cyan word */
+139
View File
@@ -0,0 +1,139 @@
// Hand-written mirror of arr-api's /api/{movies,episodes,series}/{id}/subtitles/status
// schemas — same reasoning as search.ts: the generated client (src/api/) is
// uncommitted, so CI's tsc cannot see it.
import { errorDetail } from "./releases";
/** One subtitle arr knows about, as `/subtitles` and `/subtitles/status` render it. */
export interface Subtitle {
id: number;
media_file_id: number;
/** As `arr_core::Language` spells it: `pt-PT`, `pt-BR`, `en`. */
language: string;
/** `embedded`, `extracted`, `provider` or `translated` (§15). */
origin: string;
provider: string | null;
candidate_id: string | null;
/** The translation backend, when arr made this one. */
engine: string | null;
forced: boolean;
sdh: boolean;
/** `not_run`, `synced` or `rejected` — what `alass` did (§15). */
sync: string;
path: string | null;
}
/** A wanted language a media file still lacks, and why (§15, issue #201). */
export interface MissingSubtitle {
language: string;
/** `searching`, `no_candidates`, `capped` or `failed`. */
reason: string;
/** Set only when `reason` is `failed`. */
detail: string | null;
}
/** One media file's subtitles and the wanted languages it still lacks. */
export interface SubtitleStatus {
media_file_id: number;
subtitles: Subtitle[];
missing: MissingSubtitle[];
}
/** The series-wide bulk sibling of `SubtitleStatus` (one call, not one per episode). */
export interface EpisodeSubtitleStatus extends SubtitleStatus {
episode_id: number;
}
export type SubtitleStatusOutcome =
| { kind: "status"; statuses: SubtitleStatus[] }
| { kind: "error"; detail: string };
export type EpisodeSubtitleStatusOutcome =
| { kind: "status"; statuses: EpisodeSubtitleStatus[] }
| { kind: "error"; detail: string };
export async function movieSubtitleStatus(movieId: number): Promise<SubtitleStatusOutcome> {
try {
const response = await fetch(`/api/movies/${movieId}/subtitles/status`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "status", statuses: (await response.json()) as SubtitleStatus[] };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/** One call for the whole series (§9.6) — never one request per episode. */
export async function seriesSubtitleStatus(
seriesId: number,
): Promise<EpisodeSubtitleStatusOutcome> {
try {
const response = await fetch(`/api/series/${seriesId}/subtitles/status`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "status", statuses: (await response.json()) as EpisodeSubtitleStatus[] };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
const ORIGIN_LABELS: Record<string, string> = {
embedded: "embedded",
extracted: "extracted",
};
/** The origin half of a present-subtitle chip's label (§15's own vocabulary). */
function originLabel(subtitle: Subtitle): string {
if (subtitle.origin === "provider") {
return subtitle.provider ?? "provider";
}
if (subtitle.origin === "translated") {
return subtitle.engine !== null ? `${subtitle.engine} · MT` : "MT";
}
return ORIGIN_LABELS[subtitle.origin] ?? subtitle.origin;
}
/**
* A present subtitle's chip label: language, origin, then only the flags
* that are true. §15: a machine translation must read as machine-made at a
* glance, so `translated` always carries the `MT` marker rather than
* relying on colour alone.
*/
export function subtitleChipLabel(subtitle: Subtitle): string {
const parts = [subtitle.language, originLabel(subtitle)];
if (subtitle.sdh) {
parts.push("SDH");
}
if (subtitle.forced) {
parts.push("forced");
}
if (subtitle.sync === "rejected") {
parts.push("sync flagged");
}
return parts.join(" · ");
}
const REASON_LABELS: Record<string, string> = {
searching: "searching",
no_candidates: "no candidates",
capped: "capped",
failed: "failed",
};
/** How long a `failed` detail rides in the chip text before it is title-only. */
const DETAIL_CHARS = 60;
/** A missing language's chip label: language and why, in §201's own words. */
export function missingChipLabel(missing: MissingSubtitle): string {
const reason = REASON_LABELS[missing.reason] ?? missing.reason;
if (missing.reason !== "failed" || missing.detail === null) {
return `${missing.language} · ${reason}`;
}
const detail =
missing.detail.length > DETAIL_CHARS
? `${missing.detail.slice(0, DETAIL_CHARS)}`
: missing.detail;
return `${missing.language} · ${reason}${detail}`;
}