feat(arr): manual subtitle fetch and translate
The manual surface §9.3 describes, applied to subtitles (§15). It opens inline under the file's own row and borrows the release deck's layout language rather than inventing a second one: fixed-width chips lead, the release name is secondary, eligible shows and rejected collapses to a count naming the rule. The translate lane takes any subtitle already on the file as a source, including an extracted embedded track — the thing Bazarr cannot do — and lists only the engines this binary was compiled with.
This commit is contained in:
+688
-21
@@ -107,13 +107,28 @@ import { armedDelete, settingsMain } from "./settings";
|
||||
import "./style.css";
|
||||
import {
|
||||
type EpisodeSubtitleStatus,
|
||||
formatCandidateFlags,
|
||||
formatDownloads,
|
||||
formatHashMatch,
|
||||
formatReleaseMatch,
|
||||
formatUploaderRating,
|
||||
grabSubtitle,
|
||||
languageChoices,
|
||||
type MissingSubtitle,
|
||||
missingChipLabel,
|
||||
movieSubtitleStatus,
|
||||
type Subtitle,
|
||||
type SubtitleCandidate,
|
||||
type SubtitleOptions,
|
||||
type SubtitleSearchResults,
|
||||
type SubtitleStatus,
|
||||
searchSubtitles,
|
||||
seriesSubtitleStatus,
|
||||
subtitleChipLabel,
|
||||
subtitleOptions,
|
||||
subtitleRuleLabel,
|
||||
translateSubtitle,
|
||||
translationSources,
|
||||
} from "./subtitles";
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
@@ -800,29 +815,631 @@ function missingSubtitleChip(missing: MissingSubtitle): HTMLSpanElement {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- manual subtitle deck (§9.3, §15, issue #203) ---------------------- */
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* The subtitle status row for one media file and the manual panel it opens.
|
||||
* `update` repaints the chips after a manual fetch or translation changed
|
||||
* what is on disk, without rebuilding the panel underneath — an operator
|
||||
* mid-search does not lose the search.
|
||||
*/
|
||||
function subtitleStatusLine(status: SubtitleStatus | null): HTMLElement | null {
|
||||
if (status === null) {
|
||||
return null;
|
||||
interface SubtitleSection {
|
||||
line: HTMLElement;
|
||||
panel: HTMLElement;
|
||||
update: (status: SubtitleStatus) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidate deck's columns: §9.3's fixed widths carrying the facts
|
||||
* §15's ranking actually decides on, in ranking order — a `moviehash` match
|
||||
* wins outright, then the release name, then rating and download count as
|
||||
* tiebreakers.
|
||||
*/
|
||||
const SUBTITLE_COLUMNS = [
|
||||
["cw-prov", "provider"],
|
||||
["cw-hash", "hash"],
|
||||
["cw-match", "release"],
|
||||
["cw-rate", "rating"],
|
||||
["cw-dl", "downloads"],
|
||||
["cw-flag", "flags"],
|
||||
] as const;
|
||||
|
||||
function subtitleColhead(): HTMLElement {
|
||||
const head = document.createElement("div");
|
||||
head.className = "colhead";
|
||||
head.setAttribute("aria-hidden", "true");
|
||||
for (const [width, label] of SUBTITLE_COLUMNS) {
|
||||
const cell = document.createElement("span");
|
||||
cell.className = `cw ${width}`;
|
||||
cell.textContent = label;
|
||||
head.append(cell);
|
||||
}
|
||||
if (status.subtitles.length === 0 && status.missing.length === 0) {
|
||||
return null;
|
||||
return head;
|
||||
}
|
||||
|
||||
/**
|
||||
* §9.3's bucket structure with the middle bucket left out: `arr_core::subs`
|
||||
* has no `waived` verdict, because nothing about a subtitle is worth
|
||||
* overriding by hand.
|
||||
*/
|
||||
interface SubtitleBucketsDom {
|
||||
root: HTMLElement;
|
||||
eligible: { section: HTMLElement; count: HTMLElement; rows: HTMLUListElement };
|
||||
rejected: CollapsedBucketDom;
|
||||
}
|
||||
|
||||
function buildSubtitleBuckets(): SubtitleBucketsDom {
|
||||
const root = document.createElement("div");
|
||||
root.className = "subs-buckets";
|
||||
root.hidden = true;
|
||||
|
||||
const eligibleSection = document.createElement("section");
|
||||
eligibleSection.className = "deck-group";
|
||||
eligibleSection.hidden = true;
|
||||
const eligibleHead = document.createElement("header");
|
||||
eligibleHead.className = "deck-head";
|
||||
const eligibleName = document.createElement("h4");
|
||||
eligibleName.className = "deck-label";
|
||||
eligibleName.textContent = "eligible";
|
||||
const eligibleCount = document.createElement("span");
|
||||
eligibleCount.className = "deck-count readout";
|
||||
eligibleHead.append(eligibleName, eligibleCount);
|
||||
const eligibleRows = document.createElement("ul");
|
||||
eligibleRows.className = "deck-rows";
|
||||
eligibleSection.append(eligibleHead, subtitleColhead(), eligibleRows);
|
||||
|
||||
const rejectedSection = document.createElement("section");
|
||||
rejectedSection.className = "deck-group";
|
||||
rejectedSection.hidden = true;
|
||||
const rejectedHead = document.createElement("header");
|
||||
rejectedHead.className = "deck-head bucket-head";
|
||||
const rejectedName = document.createElement("h4");
|
||||
rejectedName.className = "deck-label";
|
||||
rejectedName.textContent = "rejected";
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "bucket-toggle readout";
|
||||
toggle.setAttribute("aria-expanded", "false");
|
||||
rejectedHead.append(rejectedName, toggle);
|
||||
const wrap = document.createElement("div");
|
||||
wrap.hidden = true;
|
||||
const rejectedRows = document.createElement("ul");
|
||||
rejectedRows.className = "deck-rows";
|
||||
wrap.append(subtitleColhead(), rejectedRows);
|
||||
rejectedSection.append(rejectedHead, wrap);
|
||||
|
||||
const rejected: CollapsedBucketDom = {
|
||||
section: rejectedSection,
|
||||
toggle,
|
||||
wrap,
|
||||
rows: rejectedRows,
|
||||
};
|
||||
wireCollapsedToggle(rejected);
|
||||
root.append(eligibleSection, rejectedSection);
|
||||
return {
|
||||
root,
|
||||
eligible: { section: eligibleSection, count: eligibleCount, rows: eligibleRows },
|
||||
rejected,
|
||||
};
|
||||
}
|
||||
|
||||
/** A note line that carries one sentence of state, in the deck's own voice. */
|
||||
function subtitleNote(): HTMLParagraphElement {
|
||||
const note = document.createElement("p");
|
||||
note.className = "rel-note subs-note";
|
||||
note.setAttribute("role", "status");
|
||||
return note;
|
||||
}
|
||||
|
||||
function say(note: HTMLElement, text: string, tone?: "fault") {
|
||||
note.textContent = text;
|
||||
if (tone === undefined) {
|
||||
delete note.dataset.tone;
|
||||
} else {
|
||||
note.dataset.tone = tone;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A language picker as pressed controls rather than a select: three or four
|
||||
* options, all worth seeing at once, and the same affordance the root picker
|
||||
* and the library view toggles already use.
|
||||
*/
|
||||
interface LanguagePicker {
|
||||
element: HTMLElement;
|
||||
selected: () => string | null;
|
||||
/** Bars one language — the translate lane cannot target its own source. */
|
||||
bar: (language: string | null) => void;
|
||||
paint: (languages: string[]) => void;
|
||||
}
|
||||
|
||||
function languagePicker(label: string): LanguagePicker {
|
||||
const group = document.createElement("div");
|
||||
group.className = "subs-langs";
|
||||
group.setAttribute("role", "group");
|
||||
group.setAttribute("aria-label", label);
|
||||
let choice: string | null = null;
|
||||
let barred: string | null = null;
|
||||
let buttons: HTMLButtonElement[] = [];
|
||||
|
||||
function repaint() {
|
||||
for (const button of buttons) {
|
||||
const language = button.dataset.language ?? "";
|
||||
button.setAttribute("aria-pressed", String(language === choice));
|
||||
button.disabled = language === barred;
|
||||
}
|
||||
}
|
||||
|
||||
function paint(languages: string[]) {
|
||||
buttons = languages.map((language) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "control control-quiet";
|
||||
button.dataset.language = language;
|
||||
button.textContent = language;
|
||||
button.addEventListener("click", () => {
|
||||
choice = language;
|
||||
repaint();
|
||||
});
|
||||
return button;
|
||||
});
|
||||
choice = languages.find((language) => language !== barred) ?? null;
|
||||
group.replaceChildren(...buttons);
|
||||
repaint();
|
||||
}
|
||||
|
||||
return {
|
||||
element: group,
|
||||
selected: () => choice,
|
||||
bar: (language) => {
|
||||
barred = language;
|
||||
if (choice === barred) {
|
||||
choice = buttons.map((b) => b.dataset.language ?? "").find((l) => l !== barred) ?? null;
|
||||
}
|
||||
repaint();
|
||||
},
|
||||
paint,
|
||||
};
|
||||
}
|
||||
|
||||
/** A labelled select, the same field vocabulary `/settings` already uses. */
|
||||
function subtitleField(
|
||||
label: string,
|
||||
narrow = false,
|
||||
): { element: HTMLElement; select: HTMLSelectElement } {
|
||||
const field = document.createElement("label");
|
||||
field.className = narrow ? "field subs-field subs-field-narrow" : "field subs-field";
|
||||
const caption = document.createElement("span");
|
||||
caption.className = "field-label readout dim";
|
||||
caption.textContent = label;
|
||||
const select = document.createElement("select");
|
||||
select.className = "form-input";
|
||||
field.append(caption, select);
|
||||
return { element: field, select };
|
||||
}
|
||||
|
||||
/**
|
||||
* One candidate row: chips lead, the release name is secondary evidence
|
||||
* (§9.3). A `moviehash` match is the only chip that earns the eligible
|
||||
* green — it is the one fact that wins outright (§15), and colouring the
|
||||
* tiebreakers too would flatten the ranking the row is trying to show.
|
||||
*/
|
||||
function candidateRow(
|
||||
candidate: SubtitleCandidate,
|
||||
bucket: "eligible" | "rejected",
|
||||
fetchCandidate: (candidate: SubtitleCandidate, note: HTMLElement) => Promise<boolean>,
|
||||
): HTMLLIElement {
|
||||
const item = document.createElement("li");
|
||||
item.className = "rel";
|
||||
|
||||
const line = document.createElement("div");
|
||||
line.className = "rel-line";
|
||||
const columns: [string, string, string][] = [
|
||||
["cw-prov", candidate.provider, `offered by ${candidate.provider}`],
|
||||
[
|
||||
"cw-hash",
|
||||
formatHashMatch(candidate),
|
||||
candidate.hash_match
|
||||
? "matched to this exact file by moviehash"
|
||||
: "no moviehash match against this file",
|
||||
],
|
||||
[
|
||||
"cw-match",
|
||||
formatReleaseMatch(candidate),
|
||||
candidate.release_match
|
||||
? "same release name as the file on disk"
|
||||
: "a different release name from the file on disk",
|
||||
],
|
||||
[
|
||||
"cw-rate",
|
||||
formatUploaderRating(candidate.rating),
|
||||
candidate.rating === null
|
||||
? "no uploader rating from this provider"
|
||||
: `uploader rating ${formatUploaderRating(candidate.rating)} out of 10`,
|
||||
],
|
||||
[
|
||||
"cw-dl",
|
||||
formatDownloads(candidate.download_count),
|
||||
candidate.download_count === null
|
||||
? "no download count from this provider"
|
||||
: `downloaded ${candidate.download_count} times`,
|
||||
],
|
||||
[
|
||||
"cw-flag",
|
||||
formatCandidateFlags(candidate),
|
||||
formatCandidateFlags(candidate) === "—"
|
||||
? "a plain subtitle, neither SDH nor forced"
|
||||
: `flagged ${formatCandidateFlags(candidate)}`,
|
||||
],
|
||||
];
|
||||
for (const [width, value, description] of columns) {
|
||||
line.append(
|
||||
chip(value, (span) => {
|
||||
span.classList.add("cw", width);
|
||||
span.setAttribute("aria-label", description);
|
||||
span.title = description;
|
||||
if (value === "—") {
|
||||
span.classList.add("dim");
|
||||
}
|
||||
if (width === "cw-hash" && candidate.hash_match) {
|
||||
span.dataset.verdict = "eligible";
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (bucket === "rejected") {
|
||||
line.append(
|
||||
chip(`rejected · ${subtitleRuleLabel(candidate.rejected_rule)}`, (span) => {
|
||||
span.dataset.verdict = "rejected";
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "rel-name readout";
|
||||
if (candidate.release_name === null) {
|
||||
name.classList.add("dim");
|
||||
name.textContent = "no release name given";
|
||||
} else {
|
||||
name.textContent = candidate.release_name;
|
||||
name.title = candidate.release_name;
|
||||
}
|
||||
line.append(name);
|
||||
item.append(line);
|
||||
|
||||
const note = subtitleNote();
|
||||
note.hidden = true;
|
||||
|
||||
if (bucket === "eligible") {
|
||||
const grab = document.createElement("button");
|
||||
grab.type = "button";
|
||||
grab.className = "control rel-grab";
|
||||
grab.textContent = "grab";
|
||||
grab.addEventListener("click", () => {
|
||||
grab.disabled = true;
|
||||
note.hidden = false;
|
||||
void fetchCandidate(candidate, note).then((ok) => {
|
||||
grab.disabled = ok;
|
||||
});
|
||||
});
|
||||
item.append(grab);
|
||||
}
|
||||
item.append(note);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* The manual subtitle surface for one media file (§9.3, §15): a fetch lane
|
||||
* over the providers and a translate lane over what is already on the file.
|
||||
* It is an inline panel rather than a route or a modal — the decision it
|
||||
* supports is about one file, and the file's own row is where that decision
|
||||
* is made.
|
||||
*/
|
||||
function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleSection {
|
||||
const mediaFileId = status.media_file_id;
|
||||
let subtitles = status.subtitles;
|
||||
let missing = status.missing;
|
||||
|
||||
const line = document.createElement("div");
|
||||
line.className = "rel-line subtitle-line";
|
||||
for (const subtitle of status.subtitles) {
|
||||
line.append(subtitleChip(subtitle));
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "subs-panel";
|
||||
panel.id = `subs-panel-${mediaFileId}`;
|
||||
panel.hidden = true;
|
||||
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "control control-quiet subs-toggle";
|
||||
toggle.textContent = "subtitles";
|
||||
toggle.setAttribute("aria-expanded", "false");
|
||||
toggle.setAttribute("aria-controls", panel.id);
|
||||
|
||||
function paintChips() {
|
||||
line.replaceChildren();
|
||||
for (const subtitle of subtitles) {
|
||||
line.append(subtitleChip(subtitle));
|
||||
}
|
||||
for (const gap of missing) {
|
||||
line.append(missingSubtitleChip(gap));
|
||||
}
|
||||
line.append(toggle);
|
||||
}
|
||||
for (const missing of status.missing) {
|
||||
line.append(missingSubtitleChip(missing));
|
||||
|
||||
/* ---- fetch lane ---- */
|
||||
|
||||
const fetchLane = document.createElement("section");
|
||||
fetchLane.className = "deck-group subs-lane";
|
||||
const fetchHead = document.createElement("header");
|
||||
fetchHead.className = "deck-head";
|
||||
const fetchLabel = document.createElement("h3");
|
||||
fetchLabel.className = "deck-label";
|
||||
fetchLabel.textContent = "fetch";
|
||||
const fetchCount = document.createElement("span");
|
||||
fetchCount.className = "deck-count readout";
|
||||
fetchHead.append(fetchLabel, fetchCount);
|
||||
|
||||
const searchLanguages = languagePicker("language to search for");
|
||||
const searchButton = document.createElement("button");
|
||||
searchButton.type = "button";
|
||||
searchButton.className = "control";
|
||||
searchButton.textContent = "search";
|
||||
const searchControls = document.createElement("div");
|
||||
searchControls.className = "subs-controls";
|
||||
searchControls.append(searchLanguages.element, searchButton);
|
||||
const searchNote = subtitleNote();
|
||||
say(searchNote, "nothing is asked of a provider until you search.");
|
||||
const buckets = buildSubtitleBuckets();
|
||||
fetchLane.append(fetchHead, searchControls, searchNote, buckets.root);
|
||||
|
||||
/* ---- translate lane ---- */
|
||||
|
||||
const translateLane = document.createElement("section");
|
||||
translateLane.className = "deck-group subs-lane";
|
||||
const translateHead = document.createElement("header");
|
||||
translateHead.className = "deck-head";
|
||||
const translateLabel = document.createElement("h3");
|
||||
translateLabel.className = "deck-label";
|
||||
translateLabel.textContent = "translate";
|
||||
translateHead.append(translateLabel);
|
||||
|
||||
const source = subtitleField("source");
|
||||
const targetLanguages = languagePicker("language to translate into");
|
||||
const engine = subtitleField("engine", true);
|
||||
const translateButton = document.createElement("button");
|
||||
translateButton.type = "button";
|
||||
translateButton.className = "control";
|
||||
translateButton.textContent = "translate";
|
||||
const translateControls = document.createElement("div");
|
||||
translateControls.className = "subs-controls";
|
||||
translateControls.append(
|
||||
source.element,
|
||||
targetLanguages.element,
|
||||
engine.element,
|
||||
translateButton,
|
||||
);
|
||||
const translateHint = subtitleNote();
|
||||
const translateNote = subtitleNote();
|
||||
translateNote.hidden = true;
|
||||
translateLane.append(translateHead, translateControls, translateHint, translateNote);
|
||||
|
||||
panel.append(fetchLane, translateLane);
|
||||
|
||||
/** Keeps the target picker off the source's own language — a same-language
|
||||
* translation is refused by the API and is never what was meant. */
|
||||
function paintSources() {
|
||||
const sources = translationSources(subtitles);
|
||||
source.select.replaceChildren(
|
||||
...sources.map((subtitle) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(subtitle.id);
|
||||
option.textContent = subtitleChipLabel(subtitle);
|
||||
return option;
|
||||
}),
|
||||
);
|
||||
const empty = sources.length === 0;
|
||||
source.select.disabled = empty;
|
||||
translateButton.disabled = empty || engine.select.disabled;
|
||||
if (empty) {
|
||||
const option = document.createElement("option");
|
||||
option.textContent = "nothing to translate from";
|
||||
source.select.append(option);
|
||||
say(
|
||||
translateHint,
|
||||
"no subtitle with text on this file yet — fetch one above, or wait for an embedded text track to be extracted. An image-based track carries bitmaps, not text, and is never a source.",
|
||||
);
|
||||
} else if (translateHint.dataset.tone === undefined) {
|
||||
say(
|
||||
translateHint,
|
||||
"any subtitle already on the file is a legal source — a fetched one, an extracted embedded track, even another machine translation.",
|
||||
);
|
||||
}
|
||||
targetLanguages.bar(
|
||||
sources.find((s) => String(s.id) === source.select.value)?.language ?? null,
|
||||
);
|
||||
}
|
||||
return line;
|
||||
|
||||
source.select.addEventListener("change", () => paintSources());
|
||||
|
||||
let optionsLoaded = false;
|
||||
|
||||
async function loadOptions() {
|
||||
if (optionsLoaded) {
|
||||
return;
|
||||
}
|
||||
optionsLoaded = true;
|
||||
const outcome = await subtitleOptions();
|
||||
const options: SubtitleOptions =
|
||||
outcome.kind === "options"
|
||||
? outcome.options
|
||||
: { wanted_languages: [], translation_engine: null, available_engines: [] };
|
||||
const languages = languageChoices(options.wanted_languages);
|
||||
searchLanguages.paint(languages);
|
||||
targetLanguages.paint(languages);
|
||||
engine.select.replaceChildren(
|
||||
...options.available_engines.map((name) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = name;
|
||||
option.textContent = name;
|
||||
return option;
|
||||
}),
|
||||
);
|
||||
if (options.available_engines.length === 0) {
|
||||
const option = document.createElement("option");
|
||||
option.textContent = "none compiled in";
|
||||
engine.select.append(option);
|
||||
engine.select.disabled = true;
|
||||
} else if (
|
||||
options.translation_engine !== null &&
|
||||
options.available_engines.includes(options.translation_engine)
|
||||
) {
|
||||
engine.select.value = options.translation_engine;
|
||||
}
|
||||
paintSources();
|
||||
if (outcome.kind === "error") {
|
||||
say(
|
||||
translateHint,
|
||||
`subtitle settings unreadable — ${outcome.detail}. The language choices fall back to Portuguese and English.`,
|
||||
"fault",
|
||||
);
|
||||
} else if (options.available_engines.length === 0) {
|
||||
say(
|
||||
translateHint,
|
||||
"no translation engine is compiled into this binary — each backend is its own cargo feature.",
|
||||
"fault",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** One click grabs, syncs and writes (§15) — then the chips repaint. */
|
||||
async function fetchCandidate(candidate: SubtitleCandidate, note: HTMLElement): Promise<boolean> {
|
||||
say(note, "fetching, syncing, writing…");
|
||||
const outcome = await grabSubtitle(mediaFileId, candidate);
|
||||
if (outcome.kind === "error") {
|
||||
const stale = /not found/i.test(outcome.detail)
|
||||
? " — search again, a candidate id does not outlive its search"
|
||||
: "";
|
||||
say(note, `grab failed — ${outcome.detail}${stale}`, "fault");
|
||||
return false;
|
||||
}
|
||||
say(
|
||||
note,
|
||||
outcome.subtitle.sync === "rejected"
|
||||
? "written — alass was implausible, so the unsynced original was kept and the file is flagged"
|
||||
: "fetched, synced and written",
|
||||
);
|
||||
refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
function paintCandidates(results: SubtitleSearchResults) {
|
||||
buckets.root.hidden = false;
|
||||
buckets.eligible.rows.replaceChildren();
|
||||
buckets.rejected.rows.replaceChildren();
|
||||
buckets.rejected.wrap.hidden = true;
|
||||
|
||||
const eligible = results.candidates.filter((c) => c.verdict === "eligible");
|
||||
const rejected = results.candidates.filter((c) => c.verdict !== "eligible");
|
||||
|
||||
buckets.eligible.section.hidden = false;
|
||||
buckets.eligible.count.textContent = String(eligible.length);
|
||||
if (eligible.length === 0) {
|
||||
const none = document.createElement("li");
|
||||
none.className = "rel rel-none readout dim";
|
||||
none.textContent = "none — every candidate names the rule that rejected it below";
|
||||
buckets.eligible.rows.append(none);
|
||||
}
|
||||
for (const candidate of eligible) {
|
||||
buckets.eligible.rows.append(candidateRow(candidate, "eligible", fetchCandidate));
|
||||
}
|
||||
buckets.rejected.section.hidden = rejected.length === 0;
|
||||
for (const candidate of rejected) {
|
||||
buckets.rejected.rows.append(candidateRow(candidate, "rejected", fetchCandidate));
|
||||
}
|
||||
if (rejected.length > 0) {
|
||||
setToggle(buckets.rejected, rejected.length);
|
||||
}
|
||||
}
|
||||
|
||||
/** Providers that could not answer are named, so "no candidates" and
|
||||
* "nobody could be asked" never read the same. */
|
||||
function searchSummary(results: SubtitleSearchResults, language: string): string {
|
||||
const failures = results.provider_errors
|
||||
.map((failure) => `${failure.provider} could not answer — ${failure.error}`)
|
||||
.join(" · ");
|
||||
if (results.candidates.length === 0) {
|
||||
const nothing = `no ${language} candidate from any provider that answered`;
|
||||
return failures === "" ? nothing : `${nothing} · ${failures}`;
|
||||
}
|
||||
return failures === "" ? "" : failures;
|
||||
}
|
||||
|
||||
searchButton.addEventListener("click", () => {
|
||||
const language = searchLanguages.selected();
|
||||
if (language === null) {
|
||||
return;
|
||||
}
|
||||
searchButton.disabled = true;
|
||||
fetchCount.textContent = "";
|
||||
say(searchNote, `searching providers for ${language}…`);
|
||||
void searchSubtitles(mediaFileId, language).then((outcome) => {
|
||||
searchButton.disabled = false;
|
||||
if (outcome.kind === "error") {
|
||||
buckets.root.hidden = true;
|
||||
say(searchNote, `search failed — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
paintCandidates(outcome.results);
|
||||
fetchCount.textContent = String(outcome.results.candidates.length);
|
||||
const summary = searchSummary(outcome.results, language);
|
||||
say(searchNote, summary, outcome.results.provider_errors.length > 0 ? "fault" : undefined);
|
||||
searchNote.hidden = summary === "";
|
||||
});
|
||||
});
|
||||
|
||||
translateButton.addEventListener("click", () => {
|
||||
const target = targetLanguages.selected();
|
||||
const sourceId = Number(source.select.value);
|
||||
if (target === null || !Number.isFinite(sourceId) || source.select.disabled) {
|
||||
return;
|
||||
}
|
||||
translateButton.disabled = true;
|
||||
translateNote.hidden = false;
|
||||
say(translateNote, `translating into ${target}…`);
|
||||
void translateSubtitle(mediaFileId, {
|
||||
source_subtitle_id: sourceId,
|
||||
target_language: target,
|
||||
engine: engine.select.value,
|
||||
}).then((outcome) => {
|
||||
translateButton.disabled = false;
|
||||
translateNote.hidden = false;
|
||||
if (outcome.kind === "error") {
|
||||
say(translateNote, `translation failed — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
say(translateNote, `written next to the video as a machine translation into ${target}`);
|
||||
refresh();
|
||||
});
|
||||
});
|
||||
|
||||
toggle.addEventListener("click", () => {
|
||||
panel.hidden = !panel.hidden;
|
||||
toggle.setAttribute("aria-expanded", String(!panel.hidden));
|
||||
if (!panel.hidden) {
|
||||
void loadOptions();
|
||||
}
|
||||
});
|
||||
|
||||
paintChips();
|
||||
return {
|
||||
line,
|
||||
panel,
|
||||
update: (next: SubtitleStatus) => {
|
||||
subtitles = next.subtitles;
|
||||
missing = next.missing;
|
||||
paintChips();
|
||||
paintSources();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rowTitle(title: string, year: number | null): HTMLElement {
|
||||
@@ -1407,6 +2024,7 @@ function movieMain(views: HideableView[]): MovieView {
|
||||
let current: LibraryMovie | null = null;
|
||||
let roots: Root[] = [];
|
||||
let subtitlesByFile = new Map<number, SubtitleStatus>();
|
||||
const subtitleSections = new Map<number, SubtitleSection>();
|
||||
let removed: ((parent: Route) => void) | null = null;
|
||||
let origin: HTMLElement | null = null;
|
||||
let returnTo: HTMLElement | null = null;
|
||||
@@ -1687,7 +2305,29 @@ function movieMain(views: HideableView[]): MovieView {
|
||||
|
||||
/* ---- on disk: ffprobe truth and honest waivers (§5.6, §5.7) ---- */
|
||||
|
||||
/**
|
||||
* Re-read this movie's subtitle status after a manual fetch or
|
||||
* translation, and repaint the chips in place. The panels stay mounted,
|
||||
* so an operator who grabbed from a search still has the search.
|
||||
*/
|
||||
function refreshSubtitles() {
|
||||
const id = movieId;
|
||||
if (id === null) {
|
||||
return;
|
||||
}
|
||||
void movieSubtitleStatus(id).then((outcome) => {
|
||||
if (outcome.kind === "error" || movieId !== id) {
|
||||
return;
|
||||
}
|
||||
subtitlesByFile = new Map(outcome.statuses.map((status) => [status.media_file_id, status]));
|
||||
for (const status of outcome.statuses) {
|
||||
subtitleSections.get(status.media_file_id)?.update(status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function paintFiles(outcome: FilesOutcome) {
|
||||
subtitleSections.clear();
|
||||
diskRows.replaceChildren();
|
||||
filesSection.hidden = false;
|
||||
if (outcome.kind === "error") {
|
||||
@@ -1731,9 +2371,11 @@ function movieMain(views: HideableView[]): MovieView {
|
||||
);
|
||||
}
|
||||
item.append(line);
|
||||
const subtitleLine = subtitleStatusLine(subtitlesByFile.get(file.id) ?? null);
|
||||
if (subtitleLine !== null) {
|
||||
item.append(subtitleLine);
|
||||
const status = subtitlesByFile.get(file.id);
|
||||
if (status !== undefined) {
|
||||
const section = subtitleSection(status, refreshSubtitles);
|
||||
subtitleSections.set(status.media_file_id, section);
|
||||
item.append(section.line, section.panel);
|
||||
}
|
||||
diskRows.append(item);
|
||||
}
|
||||
@@ -3208,6 +3850,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
let seasons: ApiSeason[] | null = null;
|
||||
let filesByEpisode = new Map<number, EpisodeFile>();
|
||||
let subtitlesByEpisode = new Map<number, EpisodeSubtitleStatus>();
|
||||
const subtitleSections = new Map<number, SubtitleSection>();
|
||||
// which seasons stand open survives the refetch every action triggers
|
||||
let expanded = new Set<number>();
|
||||
let origin: HTMLElement | null = null;
|
||||
@@ -3670,9 +4313,11 @@ 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);
|
||||
const status = subtitlesByEpisode.get(episode.id);
|
||||
if (status !== undefined) {
|
||||
const section = subtitleSection(status, refreshSubtitles);
|
||||
subtitleSections.set(status.media_file_id, section);
|
||||
item.append(section.line, section.panel);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
@@ -3713,7 +4358,29 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the series' subtitle status after a manual fetch or translation
|
||||
* and repaint the chips in place — one call for the whole series (§9.6),
|
||||
* and the open panel survives it.
|
||||
*/
|
||||
function refreshSubtitles() {
|
||||
const id = seriesId;
|
||||
if (id === null) {
|
||||
return;
|
||||
}
|
||||
void seriesSubtitleStatus(id).then((outcome) => {
|
||||
if (outcome.kind === "error" || seriesId !== id) {
|
||||
return;
|
||||
}
|
||||
subtitlesByEpisode = new Map(outcome.statuses.map((status) => [status.episode_id, status]));
|
||||
for (const status of outcome.statuses) {
|
||||
subtitleSections.get(status.media_file_id)?.update(status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderSeasons() {
|
||||
subtitleSections.clear();
|
||||
seasonsList.replaceChildren();
|
||||
if (!seasons) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user