From e684813d0ce4d491c252a089d406e97fbefe626a Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 02:16:25 +0100 Subject: [PATCH] feat(arr): show subtitle chips on title detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/src/main.ts | 105 +++++++++++++++++++++++++++++--- web/src/style.css | 17 ++++++ web/src/subtitles.ts | 139 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+), 7 deletions(-) create mode 100644 web/src/subtitles.ts diff --git a/web/src/main.ts b/web/src/main.ts index cb3714b..766daac 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -105,6 +105,16 @@ import { } from "./series"; import { armedDelete, settingsMain } from "./settings"; import "./style.css"; +import { + type EpisodeSubtitleStatus, + type MissingSubtitle, + missingChipLabel, + movieSubtitleStatus, + type Subtitle, + type SubtitleStatus, + seriesSubtitleStatus, + subtitleChipLabel, +} from "./subtitles"; 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 { const wrap = document.createElement("span"); wrap.className = "row-id"; @@ -1339,6 +1406,7 @@ function movieMain(views: HideableView[]): MovieView { let movieId: number | null = null; let current: LibraryMovie | null = null; let roots: Root[] = []; + let subtitlesByFile = new Map(); let removed: ((parent: Route) => void) | null = null; let origin: HTMLElement | null = null; let returnTo: HTMLElement | null = null; @@ -1663,6 +1731,10 @@ function movieMain(views: HideableView[]): MovieView { ); } item.append(line); + const subtitleLine = subtitleStatusLine(subtitlesByFile.get(file.id) ?? null); + if (subtitleLine !== null) { + item.append(subtitleLine); + } diskRows.append(item); } } @@ -1865,9 +1937,10 @@ function movieMain(views: HideableView[]): MovieView { sequence += 1; const ticket = sequence; window.clearTimeout(pollTimer); - const [movie, filesOutcome, fetchedRoots] = await Promise.all([ + const [movie, filesOutcome, subtitleOutcome, fetchedRoots] = await Promise.all([ fetchMovie(id), movieFiles(id), + movieSubtitleStatus(id), allRoots().catch(() => roots), ]); if (ticket !== sequence || movieId !== id) { @@ -1879,6 +1952,11 @@ function movieMain(views: HideableView[]): MovieView { return; } current = movie; + subtitlesByFile = new Map( + subtitleOutcome.kind === "status" + ? subtitleOutcome.statuses.map((status) => [status.media_file_id, status]) + : [], + ); clearRichDetail(); paintIdentity(); paintControls(); @@ -3129,6 +3207,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { let series: ApiSeries | null = null; let seasons: ApiSeason[] | null = null; let filesByEpisode = new Map(); + let subtitlesByEpisode = new Map(); // which seasons stand open survives the refetch every action triggers let expanded = new Set(); let origin: HTMLElement | null = null; @@ -3591,6 +3670,10 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { } item.append(tag, name, air, chips, actions); + const subtitleLine = subtitleStatusLine(subtitlesByEpisode.get(episode.id) ?? null); + if (subtitleLine !== null) { + item.append(subtitleLine); + } return item; } @@ -3722,12 +3805,15 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { const ticket = sequence; window.clearTimeout(refreshPollTimer); setStatus("reading series…"); - const [detail, seasonsOutcome, filesOutcome, fetchedRoots] = await Promise.all([ - fetchSeries(id), - fetchSeasons(id), - fetchSeriesFiles(id), - allRoots().catch(() => roots), - ]); + const [detail, seasonsOutcome, filesOutcome, subtitleOutcome, fetchedRoots] = await Promise.all( + [ + fetchSeries(id), + fetchSeasons(id), + fetchSeriesFiles(id), + seriesSubtitleStatus(id), + allRoots().catch(() => roots), + ], + ); if (ticket !== sequence || seriesId !== id) { return; } @@ -3747,6 +3833,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { ? filesOutcome.files.map((file) => [file.episode_id, file]) : [], ); + subtitlesByEpisode = new Map( + subtitleOutcome.kind === "status" + ? subtitleOutcome.statuses.map((status) => [status.episode_id, status]) + : [], + ); paintHeader(); clearRichDetail(); renderSeasons(); diff --git a/web/src/style.css b/web/src/style.css index 8a0ca95..b4469af 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1464,6 +1464,23 @@ body { 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) ------------------------------------ */ /* the badge is a signal, not a control: amber attention on the cyan word */ diff --git a/web/src/subtitles.ts b/web/src/subtitles.ts new file mode 100644 index 0000000..0daf53a --- /dev/null +++ b/web/src/subtitles.ts @@ -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 { + 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 { + 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 = { + 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 = { + 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}`; +}