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:
+687
-20
@@ -107,13 +107,28 @@ import { armedDelete, settingsMain } from "./settings";
|
|||||||
import "./style.css";
|
import "./style.css";
|
||||||
import {
|
import {
|
||||||
type EpisodeSubtitleStatus,
|
type EpisodeSubtitleStatus,
|
||||||
|
formatCandidateFlags,
|
||||||
|
formatDownloads,
|
||||||
|
formatHashMatch,
|
||||||
|
formatReleaseMatch,
|
||||||
|
formatUploaderRating,
|
||||||
|
grabSubtitle,
|
||||||
|
languageChoices,
|
||||||
type MissingSubtitle,
|
type MissingSubtitle,
|
||||||
missingChipLabel,
|
missingChipLabel,
|
||||||
movieSubtitleStatus,
|
movieSubtitleStatus,
|
||||||
type Subtitle,
|
type Subtitle,
|
||||||
|
type SubtitleCandidate,
|
||||||
|
type SubtitleOptions,
|
||||||
|
type SubtitleSearchResults,
|
||||||
type SubtitleStatus,
|
type SubtitleStatus,
|
||||||
|
searchSubtitles,
|
||||||
seriesSubtitleStatus,
|
seriesSubtitleStatus,
|
||||||
subtitleChipLabel,
|
subtitleChipLabel,
|
||||||
|
subtitleOptions,
|
||||||
|
subtitleRuleLabel,
|
||||||
|
translateSubtitle,
|
||||||
|
translationSources,
|
||||||
} from "./subtitles";
|
} from "./subtitles";
|
||||||
|
|
||||||
const POLL_MS = 15_000;
|
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
|
* The subtitle status row for one media file and the manual panel it opens.
|
||||||
* language, then every wanted language still missing, each carrying its own
|
* `update` repaints the chips after a manual fetch or translation changed
|
||||||
* reason. `null` only when there is nothing to say — no subtitles and
|
* what is on disk, without rebuilding the panel underneath — an operator
|
||||||
* nothing wanted — which does not happen with the shipped default wanted
|
* mid-search does not lose the search.
|
||||||
* set, but an operator could empty it from `/settings`.
|
|
||||||
*/
|
*/
|
||||||
function subtitleStatusLine(status: SubtitleStatus | null): HTMLElement | null {
|
interface SubtitleSection {
|
||||||
if (status === null) {
|
line: HTMLElement;
|
||||||
return null;
|
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 head;
|
||||||
return null;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §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");
|
const line = document.createElement("div");
|
||||||
line.className = "rel-line subtitle-line";
|
line.className = "rel-line subtitle-line";
|
||||||
for (const subtitle of status.subtitles) {
|
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));
|
line.append(subtitleChip(subtitle));
|
||||||
}
|
}
|
||||||
for (const missing of status.missing) {
|
for (const gap of missing) {
|
||||||
line.append(missingSubtitleChip(missing));
|
line.append(missingSubtitleChip(gap));
|
||||||
}
|
}
|
||||||
return line;
|
line.append(toggle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
function rowTitle(title: string, year: number | null): HTMLElement {
|
||||||
@@ -1407,6 +2024,7 @@ function movieMain(views: HideableView[]): MovieView {
|
|||||||
let current: LibraryMovie | null = null;
|
let current: LibraryMovie | null = null;
|
||||||
let roots: Root[] = [];
|
let roots: Root[] = [];
|
||||||
let subtitlesByFile = new Map<number, SubtitleStatus>();
|
let subtitlesByFile = new Map<number, SubtitleStatus>();
|
||||||
|
const subtitleSections = new Map<number, SubtitleSection>();
|
||||||
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;
|
||||||
@@ -1687,7 +2305,29 @@ function movieMain(views: HideableView[]): MovieView {
|
|||||||
|
|
||||||
/* ---- on disk: ffprobe truth and honest waivers (§5.6, §5.7) ---- */
|
/* ---- 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) {
|
function paintFiles(outcome: FilesOutcome) {
|
||||||
|
subtitleSections.clear();
|
||||||
diskRows.replaceChildren();
|
diskRows.replaceChildren();
|
||||||
filesSection.hidden = false;
|
filesSection.hidden = false;
|
||||||
if (outcome.kind === "error") {
|
if (outcome.kind === "error") {
|
||||||
@@ -1731,9 +2371,11 @@ function movieMain(views: HideableView[]): MovieView {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
item.append(line);
|
item.append(line);
|
||||||
const subtitleLine = subtitleStatusLine(subtitlesByFile.get(file.id) ?? null);
|
const status = subtitlesByFile.get(file.id);
|
||||||
if (subtitleLine !== null) {
|
if (status !== undefined) {
|
||||||
item.append(subtitleLine);
|
const section = subtitleSection(status, refreshSubtitles);
|
||||||
|
subtitleSections.set(status.media_file_id, section);
|
||||||
|
item.append(section.line, section.panel);
|
||||||
}
|
}
|
||||||
diskRows.append(item);
|
diskRows.append(item);
|
||||||
}
|
}
|
||||||
@@ -3208,6 +3850,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
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>();
|
let subtitlesByEpisode = new Map<number, EpisodeSubtitleStatus>();
|
||||||
|
const subtitleSections = new Map<number, SubtitleSection>();
|
||||||
// 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;
|
||||||
@@ -3670,9 +4313,11 @@ 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);
|
const status = subtitlesByEpisode.get(episode.id);
|
||||||
if (subtitleLine !== null) {
|
if (status !== undefined) {
|
||||||
item.append(subtitleLine);
|
const section = subtitleSection(status, refreshSubtitles);
|
||||||
|
subtitleSections.set(status.media_file_id, section);
|
||||||
|
item.append(section.line, section.panel);
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
@@ -3713,7 +4358,29 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
return item;
|
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() {
|
function renderSeasons() {
|
||||||
|
subtitleSections.clear();
|
||||||
seasonsList.replaceChildren();
|
seasonsList.replaceChildren();
|
||||||
if (!seasons) {
|
if (!seasons) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1481,6 +1481,111 @@ body {
|
|||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- manual subtitle deck (§9.3, §15, issue #203) ---------------------- */
|
||||||
|
|
||||||
|
/* the panel opens under the file's own row, sharing the release deck's
|
||||||
|
layout language rather than inventing a second one */
|
||||||
|
.subs-panel {
|
||||||
|
flex-basis: 100%;
|
||||||
|
/* a flex item will not shrink below its min-content unless told to; the
|
||||||
|
deck's fixed columns would otherwise push the row into horizontal
|
||||||
|
scroll, which §9.3 forbids at every viewport */
|
||||||
|
min-width: 0;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-3) var(--space-1);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subs-panel .deck-group {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subs-panel .deck-group:last-child {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the lane's own controls: pickers first, then the verb */
|
||||||
|
.subs-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2) var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-1) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subs-langs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subs-field {
|
||||||
|
flex: 1 1 12rem;
|
||||||
|
min-width: 9rem;
|
||||||
|
max-width: 18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the engine name is one short word; the source label is a whole subtitle */
|
||||||
|
.subs-field-narrow {
|
||||||
|
flex: 0 1 9rem;
|
||||||
|
max-width: 11rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subs-note {
|
||||||
|
margin: var(--space-2) var(--space-1) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* a row's own note takes its own line, so reporting an outcome never moves
|
||||||
|
the grab control out from under the pointer that just used it */
|
||||||
|
.rel > .subs-note {
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subs-buckets {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* nested one level under the lane heading, so the buckets read as parts of
|
||||||
|
the fetch lane rather than as siblings of it */
|
||||||
|
.subs-buckets .deck-group {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the buckets sit under the lane heading, not beside it */
|
||||||
|
.subs-buckets .deck-label {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* §9.3's fixed columns, subtitle facts: provider and the ranking tiers */
|
||||||
|
.cw-prov {
|
||||||
|
width: 7.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cw-hash {
|
||||||
|
width: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cw-match {
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cw-rate {
|
||||||
|
width: 4.25rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cw-dl {
|
||||||
|
width: 6rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cw-flag {
|
||||||
|
width: 5.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- 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 */
|
||||||
@@ -1908,6 +2013,28 @@ body {
|
|||||||
.cw-seed {
|
.cw-seed {
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* §15's tiebreakers are the first columns to go: hash and release match
|
||||||
|
decide the row, rating and download count only order what is left */
|
||||||
|
.cw-rate,
|
||||||
|
.cw-dl {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* what is left wraps to two lines at this width, so an aligned header
|
||||||
|
would name the wrong cell. The remaining chips say what they are on
|
||||||
|
their own, and the header is shed rather than allowed to lie. */
|
||||||
|
.subs-buckets .colhead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cw-prov {
|
||||||
|
width: 6.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cw-flag {
|
||||||
|
width: 4.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- settings (issue #116) -------------------------------------------- */
|
/* ---- settings (issue #116) -------------------------------------------- */
|
||||||
|
|||||||
@@ -137,3 +137,234 @@ export function missingChipLabel(missing: MissingSubtitle): string {
|
|||||||
: missing.detail;
|
: missing.detail;
|
||||||
return `${missing.language} · ${reason} — ${detail}`;
|
return `${missing.language} · ${reason} — ${detail}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- manual search and translation (§9.3, §15, issue #203) ------------- */
|
||||||
|
|
||||||
|
/** One candidate a provider offered, with the verdict that placed it. */
|
||||||
|
export interface SubtitleCandidate {
|
||||||
|
provider: string;
|
||||||
|
candidate_id: string;
|
||||||
|
language: string;
|
||||||
|
/** The provider matched the exact file by `moviehash` — §15's outright winner. */
|
||||||
|
hash_match: boolean;
|
||||||
|
/** The candidate's release name is the one the file was imported under. */
|
||||||
|
release_match: boolean;
|
||||||
|
release_name: string | null;
|
||||||
|
group: string | null;
|
||||||
|
source: string | null;
|
||||||
|
rating: number | null;
|
||||||
|
download_count: number | null;
|
||||||
|
forced: boolean;
|
||||||
|
sdh: boolean;
|
||||||
|
/** `eligible` or `rejected`, the same words the release deck uses. */
|
||||||
|
verdict: string;
|
||||||
|
/** The rule that rejected it, `null` when eligible. */
|
||||||
|
rejected_rule: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A provider that was asked and could not answer. */
|
||||||
|
export interface SubtitleProviderError {
|
||||||
|
provider: string;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleSearchResults {
|
||||||
|
candidates: SubtitleCandidate[];
|
||||||
|
provider_errors: SubtitleProviderError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubtitleSearchOutcome =
|
||||||
|
| { kind: "results"; results: SubtitleSearchResults }
|
||||||
|
| { kind: "error"; detail: string };
|
||||||
|
|
||||||
|
export async function searchSubtitles(
|
||||||
|
mediaFileId: number,
|
||||||
|
language: string,
|
||||||
|
): Promise<SubtitleSearchOutcome> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/media-files/${mediaFileId}/subtitles/search`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ language }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return { kind: "error", detail: await errorDetail(response) };
|
||||||
|
}
|
||||||
|
return { kind: "results", results: (await response.json()) as SubtitleSearchResults };
|
||||||
|
} catch {
|
||||||
|
return { kind: "error", detail: "daemon unreachable" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubtitleWriteOutcome =
|
||||||
|
| { kind: "done"; subtitle: Subtitle }
|
||||||
|
| { kind: "error"; detail: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One click: the daemon fetches the candidate, runs `alass` over it and
|
||||||
|
* writes the sidecar (§15). The candidate's own flags travel with it —
|
||||||
|
* nothing on the server remembers a search.
|
||||||
|
*/
|
||||||
|
export async function grabSubtitle(
|
||||||
|
mediaFileId: number,
|
||||||
|
candidate: SubtitleCandidate,
|
||||||
|
): Promise<SubtitleWriteOutcome> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/media-files/${mediaFileId}/subtitles/grab`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: candidate.provider,
|
||||||
|
candidate_id: candidate.candidate_id,
|
||||||
|
language: candidate.language,
|
||||||
|
forced: candidate.forced,
|
||||||
|
sdh: candidate.sdh,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return { kind: "error", detail: await errorDetail(response) };
|
||||||
|
}
|
||||||
|
return { kind: "done", subtitle: (await response.json()) as Subtitle };
|
||||||
|
} catch {
|
||||||
|
return { kind: "error", detail: "daemon unreachable" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Translate one subtitle already on the file into another language (§15). */
|
||||||
|
export async function translateSubtitle(
|
||||||
|
mediaFileId: number,
|
||||||
|
input: { source_subtitle_id: number; target_language: string; engine: string },
|
||||||
|
): Promise<SubtitleWriteOutcome> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/media-files/${mediaFileId}/subtitles/translate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return { kind: "error", detail: await errorDetail(response) };
|
||||||
|
}
|
||||||
|
return { kind: "done", subtitle: (await response.json()) as Subtitle };
|
||||||
|
} catch {
|
||||||
|
return { kind: "error", detail: "daemon unreachable" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slice of `/api/settings/subtitles` a manual action needs: which
|
||||||
|
* languages the operator cares about, and which translation engines this
|
||||||
|
* binary actually has compiled in (§15 — the engine list is a property of
|
||||||
|
* the running binary, not a fixed menu).
|
||||||
|
*/
|
||||||
|
export interface SubtitleOptions {
|
||||||
|
wanted_languages: string[];
|
||||||
|
translation_engine: string | null;
|
||||||
|
available_engines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubtitleOptionsOutcome =
|
||||||
|
| { kind: "options"; options: SubtitleOptions }
|
||||||
|
| { kind: "error"; detail: string };
|
||||||
|
|
||||||
|
export async function subtitleOptions(): Promise<SubtitleOptionsOutcome> {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/settings/subtitles");
|
||||||
|
if (!response.ok) {
|
||||||
|
return { kind: "error", detail: await errorDetail(response) };
|
||||||
|
}
|
||||||
|
return { kind: "options", options: (await response.json()) as SubtitleOptions };
|
||||||
|
} catch {
|
||||||
|
return { kind: "error", detail: "daemon unreachable" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two languages §15 wants for every file, always offered even when the
|
||||||
|
* operator has emptied the wanted set — a manual fetch is exactly the case
|
||||||
|
* where the wanted set is not the question.
|
||||||
|
*/
|
||||||
|
const BASE_LANGUAGES = ["pt-PT", "pt-BR", "en"];
|
||||||
|
|
||||||
|
/** Wanted languages first, in the operator's own order, then the §15 pair. */
|
||||||
|
export function languageChoices(wanted: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const choices: string[] = [];
|
||||||
|
for (const language of [...wanted, ...BASE_LANGUAGES]) {
|
||||||
|
if (language !== "" && !seen.has(language)) {
|
||||||
|
seen.add(language);
|
||||||
|
choices.push(language);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return choices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subtitles on a file that can be translated from: anything with text
|
||||||
|
* on disk (§15). An embedded track that was never extracted carries no
|
||||||
|
* text and is not offered.
|
||||||
|
*/
|
||||||
|
export function translationSources(subtitles: Subtitle[]): Subtitle[] {
|
||||||
|
return subtitles.filter((subtitle) => subtitle.path !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- candidate chip formatting (§9.3 columns) -------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two match columns name themselves rather than reading "match", so a
|
||||||
|
* row still says what it is at phone widths, where §9.3's column header is
|
||||||
|
* shed rather than squeezed.
|
||||||
|
*/
|
||||||
|
export function formatHashMatch(candidate: SubtitleCandidate): string {
|
||||||
|
return candidate.hash_match ? "hash" : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatReleaseMatch(candidate: SubtitleCandidate): string {
|
||||||
|
return candidate.release_match ? "release" : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUploaderRating(rating: number | null): string {
|
||||||
|
return rating === null ? "—" : rating.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download counts run from single digits to seven, and the column is fixed
|
||||||
|
* width (§9.3), so they are abbreviated rather than truncated — a clipped
|
||||||
|
* number reads as a smaller one.
|
||||||
|
*/
|
||||||
|
export function formatDownloads(count: number | null): string {
|
||||||
|
if (count === null) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
if (count < 1000) {
|
||||||
|
return String(count);
|
||||||
|
}
|
||||||
|
if (count < 1_000_000) {
|
||||||
|
const thousands = count / 1000;
|
||||||
|
return `${thousands < 10 ? thousands.toFixed(1) : Math.round(thousands)}k`;
|
||||||
|
}
|
||||||
|
return `${(count / 1_000_000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the flags that are true; §15's own words. */
|
||||||
|
export function formatCandidateFlags(candidate: SubtitleCandidate): string {
|
||||||
|
const flags: string[] = [];
|
||||||
|
if (candidate.sdh) {
|
||||||
|
flags.push("SDH");
|
||||||
|
}
|
||||||
|
if (candidate.forced) {
|
||||||
|
flags.push("forced");
|
||||||
|
}
|
||||||
|
return flags.length === 0 ? "—" : flags.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §15 rule names as short chip words — the mirror of releases.ts' `ruleLabel`. */
|
||||||
|
const SUBTITLE_RULE_LABEL: Record<string, string> = {
|
||||||
|
forced: "forced",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function subtitleRuleLabel(rule: string | null): string {
|
||||||
|
if (rule === null) {
|
||||||
|
return "unclassified";
|
||||||
|
}
|
||||||
|
return SUBTITLE_RULE_LABEL[rule] ?? rule.replaceAll("_", " ");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user