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:
+98
-7
@@ -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<number, SubtitleStatus>();
|
||||
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<number, EpisodeFile>();
|
||||
let subtitlesByEpisode = new Map<number, EpisodeSubtitleStatus>();
|
||||
// which seasons stand open survives the refetch every action triggers
|
||||
let expanded = new Set<number>();
|
||||
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();
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user