@@ -106,6 +106,8 @@ const fn sync_name(sync: arr_db::SubtitleSync) -> &'static str {
|
||||
/// Rejected candidates are in the same list rather than hidden: §9.3's rule
|
||||
/// is that every rejected row names the rule that killed it, so an
|
||||
/// over-strict filter is visible without reading names.
|
||||
// Five independent facts, not a state machine: every combination occurs.
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct SubtitleCandidate {
|
||||
/// The provider offering it, and its own handle for it. Both travel back
|
||||
@@ -116,6 +118,12 @@ pub struct SubtitleCandidate {
|
||||
/// Whether the provider matched the exact file by `moviehash` — §15's
|
||||
/// outright winner.
|
||||
pub hash_match: bool,
|
||||
/// Whether the candidate's release name is the one the file was
|
||||
/// imported under — §15's second ranking tier. Reported rather than
|
||||
/// left implicit in the order, because §9.3's manual view shows the
|
||||
/// facts that decided a row, and the release name of the file on disk
|
||||
/// is not otherwise on the wire.
|
||||
pub release_match: bool,
|
||||
pub release_name: Option<String>,
|
||||
pub group: Option<String>,
|
||||
pub source: Option<String>,
|
||||
@@ -448,6 +456,16 @@ fn language_of(tag: &str) -> Result<Language, ApiError> {
|
||||
Ok(arr_db::policy::language(tag))
|
||||
}
|
||||
|
||||
/// Whether two release names are the same one, matched exactly as
|
||||
/// `arr_core::subs`' ranking tier does — case-insensitively, and never when
|
||||
/// either side is unknown.
|
||||
fn same_release(candidate: Option<&str>, target: Option<&str>) -> bool {
|
||||
match (candidate, target) {
|
||||
(Some(candidate), Some(target)) => candidate.eq_ignore_ascii_case(target),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn subtitles_of(state: &AppState, media_file_id: i64) -> Result<Vec<Subtitle>, ApiError> {
|
||||
let files = db::files_for(pool(state)?, media_file_id).await?;
|
||||
Ok(files.into_iter().map(Subtitle::from).collect())
|
||||
@@ -818,6 +836,10 @@ pub async fn search(
|
||||
candidate_id: candidate.id.to_string(),
|
||||
language: candidate.language.to_string(),
|
||||
hash_match: candidate.hash_match,
|
||||
release_match: same_release(
|
||||
candidate.release_name.as_deref(),
|
||||
target.release_name.as_deref(),
|
||||
),
|
||||
release_name: candidate.release_name.clone(),
|
||||
group: candidate.group.clone(),
|
||||
source: candidate.source.map(|source| source.to_string()),
|
||||
@@ -1188,6 +1210,21 @@ mod tests {
|
||||
|
||||
const SRT: &str = "1\n00:00:01,000 --> 00:00:02,000\nolá\n";
|
||||
|
||||
/// §15's second ranking tier, as the manual view reads it: the same
|
||||
/// release name matches whatever its casing, and an unknown name on
|
||||
/// either side is never a match.
|
||||
#[test]
|
||||
fn release_names_match_case_insensitively_and_never_when_unknown() {
|
||||
assert!(super::same_release(
|
||||
Some("Dune.2021.2160p.WEB-DL-GROUP"),
|
||||
Some("dune.2021.2160p.web-dl-group")
|
||||
));
|
||||
assert!(!super::same_release(Some("Dune.2021"), Some("Dune.2024")));
|
||||
assert!(!super::same_release(Some("Dune.2021"), None));
|
||||
assert!(!super::same_release(None, Some("Dune.2021")));
|
||||
assert!(!super::same_release(None, None));
|
||||
}
|
||||
|
||||
/// Offers three candidates for whatever it is asked: one plain, one that
|
||||
/// matched by hash, one forced.
|
||||
#[derive(Debug)]
|
||||
@@ -1559,6 +1596,9 @@ mod tests {
|
||||
assert_eq!(candidates[2]["candidate_id"], "forced");
|
||||
assert_eq!(candidates[2]["verdict"], "rejected");
|
||||
assert_eq!(candidates[2]["rejected_rule"], "forced");
|
||||
// No grab record backs this file, so nothing can claim its release
|
||||
// name — the chip reads "no", never "unknown".
|
||||
assert_eq!(candidates[0]["release_match"], false);
|
||||
assert!(body["provider_errors"]
|
||||
.as_array()
|
||||
.expect("errors")
|
||||
|
||||
+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;
|
||||
|
||||
@@ -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) -------------------------------------------- */
|
||||
|
||||
@@ -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<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