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:
+94
-3
@@ -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),
|
fetchSeries(id),
|
||||||
fetchSeasons(id),
|
fetchSeasons(id),
|
||||||
fetchSeriesFiles(id),
|
fetchSeriesFiles(id),
|
||||||
|
seriesSubtitleStatus(id),
|
||||||
allRoots().catch(() => roots),
|
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();
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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