From e850ddff810218bb5d0780e6cd28d65a4378ba11 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 02:45:32 +0100 Subject: [PATCH] feat(arr): manual subtitle fetch and translate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/src/main.ts | 709 +++++++++++++++++++++++++++++++++++++++++-- web/src/style.css | 127 ++++++++ web/src/subtitles.ts | 231 ++++++++++++++ 3 files changed, 1046 insertions(+), 21 deletions(-) diff --git a/web/src/main.ts b/web/src/main.ts index 766daac..04cb8dc 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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, +): 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 { + 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(); + const subtitleSections = new Map(); 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(); let subtitlesByEpisode = new Map(); + const subtitleSections = new Map(); // which seasons stand open survives the refetch every action triggers let expanded = new Set(); 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; diff --git a/web/src/style.css b/web/src/style.css index b4469af..34517fc 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1481,6 +1481,111 @@ body { 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) ------------------------------------ */ /* the badge is a signal, not a control: amber attention on the cyan word */ @@ -1908,6 +2013,28 @@ body { .cw-seed { 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) -------------------------------------------- */ diff --git a/web/src/subtitles.ts b/web/src/subtitles.ts index 0daf53a..3f9070b 100644 --- a/web/src/subtitles.ts +++ b/web/src/subtitles.ts @@ -137,3 +137,234 @@ export function missingChipLabel(missing: MissingSubtitle): string { : missing.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 { + 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 { + 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 { + 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 { + 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(); + 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 = { + forced: "forced", +}; + +export function subtitleRuleLabel(rule: string | null): string { + if (rule === null) { + return "unclassified"; + } + return SUBTITLE_RULE_LABEL[rule] ?? rule.replaceAll("_", " "); +}