fix(subs): retry a capped language next day
A `capped` row was excluded from the work list outright, and nothing ever moved it back, so a language that hit a daily allowance was never attempted again. §15 says budgets drain a backlog over days, which only holds if the cap expires. `pending` now returns a capped row once the UTC day it was capped in has passed — the same boundary `subtitle_budget_spend.day` uses. The day rule is in the query rather than the caller because `limit` is a window over the whole list: a hundred newest files capped this morning would otherwise hold every slot and hide the older gaps until tomorrow. Capping no longer bumps `attempts`. That counter is the failure ladder, and a budget that refused to spend never asked a provider. Two reporting fixes alongside, both found chasing the same bug: - The subtitle queue dropped `last_failure` on a capped row, so all four limits that write `capped` rendered identically. The sentence is the only thing that tells a provider budget from a 429. - A translation engine with no API key reported itself as "not compiled into this binary", sending the operator to rebuild an image that was already correct. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO subtitle_attempts\n (media_file_id, language, state, attempts, last_attempt_at, last_failure)\n VALUES (?, ?, 'capped', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)\n ON CONFLICT (media_file_id, language) DO UPDATE\n SET state = 'capped',\n attempts = MAX(subtitle_attempts.attempts, 1),\n last_attempt_at = excluded.last_attempt_at,\n last_failure = excluded.last_failure,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1ec1a7cbb47b92e765abc3b3524824cef5ec767b42c0976c82ef7ffdc99cdf72"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT a.media_file_id AS \"media_file_id!: i64\",\n a.language AS \"language!: String\",\n a.state AS \"state!: SubtitleState\",\n a.attempts AS \"attempts!: i64\",\n a.last_attempt_at,\n a.last_failure,\n f.path AS \"media_file_path!: String\"\n FROM subtitle_attempts a\n JOIN media_files f ON f.id = a.media_file_id\n WHERE a.state IN ('wanted', 'failed')\n ORDER BY f.created_at DESC, a.last_attempt_at, a.language\n LIMIT ?",
|
||||
"query": "SELECT a.media_file_id AS \"media_file_id!: i64\",\n a.language AS \"language!: String\",\n a.state AS \"state!: SubtitleState\",\n a.attempts AS \"attempts!: i64\",\n a.last_attempt_at,\n a.last_failure,\n f.path AS \"media_file_path!: String\"\n FROM subtitle_attempts a\n JOIN media_files f ON f.id = a.media_file_id\n WHERE (a.state IN ('wanted', 'failed')\n OR (a.state = 'capped'\n AND (a.last_attempt_at IS NULL\n OR a.last_attempt_at\n < strftime('%Y-%m-%dT00:00:00.000Z', 'now'))))\n ORDER BY f.created_at DESC, a.last_attempt_at, a.language\n LIMIT ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -94,5 +94,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "47ad9162ba373dfbdf061898f5f148e65de261b494453804db912a919a78c2c5"
|
||||
"hash": "ba91d1e5cb04d728f2f3d2a18333897cac5bbc66e22d678a7a008e7c0efba380"
|
||||
}
|
||||
@@ -692,7 +692,9 @@ fn reason_of(attempt: &arr_db::SubtitleAttempt) -> (String, Option<String>) {
|
||||
("searching".to_owned(), None)
|
||||
}
|
||||
arr_db::SubtitleState::Unavailable => ("no_candidates".to_owned(), None),
|
||||
arr_db::SubtitleState::Capped => ("capped".to_owned(), None),
|
||||
// Two budgets and a provider 429 all write `capped`; the sentence is
|
||||
// the only thing that says which.
|
||||
arr_db::SubtitleState::Capped => ("capped".to_owned(), attempt.last_failure.clone()),
|
||||
arr_db::SubtitleState::Failed => ("failed".to_owned(), attempt.last_failure.clone()),
|
||||
}
|
||||
}
|
||||
@@ -1218,11 +1220,7 @@ pub async fn translate(
|
||||
let engine = engine_name(&state, input.engine.as_deref()).await?;
|
||||
let backend = state
|
||||
.translation_backend(&engine)
|
||||
.ok_or_else(|| {
|
||||
ApiError::SubtitleUpstream(format!(
|
||||
"translation engine '{engine}' is not compiled into this binary"
|
||||
))
|
||||
})?
|
||||
.ok_or_else(|| ApiError::SubtitleUpstream(arr_subs::unusable_engine(&engine)))?
|
||||
.clone();
|
||||
|
||||
let destination = target.sidecar(&target_language, true)?;
|
||||
@@ -1487,7 +1485,7 @@ fn attempt_reason(
|
||||
("searching".to_owned(), None)
|
||||
}
|
||||
arr_db::SubtitleState::Unavailable => ("no_candidates".to_owned(), None),
|
||||
arr_db::SubtitleState::Capped => ("capped".to_owned(), None),
|
||||
arr_db::SubtitleState::Capped => ("capped".to_owned(), last_failure),
|
||||
arr_db::SubtitleState::Failed => ("failed".to_owned(), last_failure),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,14 +663,7 @@ impl Worker {
|
||||
|
||||
if budget_capped {
|
||||
let reason = "provider daily budget exhausted".to_owned();
|
||||
db::record_attempt(
|
||||
database.pool(),
|
||||
media_file_id,
|
||||
wanted_tag,
|
||||
SubtitleState::Capped,
|
||||
Some(&reason),
|
||||
)
|
||||
.await?;
|
||||
db::record_capped(database.pool(), media_file_id, wanted_tag, &reason).await?;
|
||||
return Ok(Closed::Recorded {
|
||||
state: SubtitleState::Capped,
|
||||
reason,
|
||||
@@ -695,14 +688,7 @@ impl Worker {
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
db::record_attempt(
|
||||
database.pool(),
|
||||
media_file_id,
|
||||
wanted_tag,
|
||||
state,
|
||||
Some(&reason),
|
||||
)
|
||||
.await?;
|
||||
record_settled(database, media_file_id, wanted_tag, state, &reason).await?;
|
||||
return Ok(Closed::Recorded { state, reason });
|
||||
}
|
||||
|
||||
@@ -751,14 +737,7 @@ impl Worker {
|
||||
SubtitleState::Failed
|
||||
};
|
||||
let reason = error.to_string();
|
||||
db::record_attempt(
|
||||
database.pool(),
|
||||
media_file_id,
|
||||
wanted_tag,
|
||||
state,
|
||||
Some(&reason),
|
||||
)
|
||||
.await?;
|
||||
record_settled(database, media_file_id, wanted_tag, state, &reason).await?;
|
||||
return Ok(Closed::Recorded { state, reason });
|
||||
}
|
||||
};
|
||||
@@ -820,14 +799,7 @@ impl Worker {
|
||||
) -> Result<Closed, SubtitleError> {
|
||||
let media_file_id = target.media_file_id;
|
||||
let record = |state: SubtitleState, reason: String| async move {
|
||||
db::record_attempt(
|
||||
database.pool(),
|
||||
media_file_id,
|
||||
wanted_tag,
|
||||
state,
|
||||
Some(&reason),
|
||||
)
|
||||
.await?;
|
||||
record_settled(database, media_file_id, wanted_tag, state, &reason).await?;
|
||||
Ok(Closed::Recorded { state, reason })
|
||||
};
|
||||
|
||||
@@ -858,11 +830,7 @@ impl Worker {
|
||||
.find(|backend| backend.id().as_str() == engine)
|
||||
.map(Arc::clone)
|
||||
else {
|
||||
return record(
|
||||
SubtitleState::Failed,
|
||||
format!("translation engine '{engine}' is not compiled into this binary"),
|
||||
)
|
||||
.await;
|
||||
return record(SubtitleState::Failed, arr_subs::unusable_engine(&engine)).await;
|
||||
};
|
||||
|
||||
let cues = match read_cues(&source_path).await {
|
||||
@@ -953,14 +921,7 @@ impl Worker {
|
||||
} = translation;
|
||||
let media_file_id = target.media_file_id;
|
||||
let record = |state: SubtitleState, reason: String| async move {
|
||||
db::record_attempt(
|
||||
database.pool(),
|
||||
media_file_id,
|
||||
wanted_tag,
|
||||
state,
|
||||
Some(&reason),
|
||||
)
|
||||
.await?;
|
||||
record_settled(database, media_file_id, wanted_tag, state, &reason).await?;
|
||||
Ok(Closed::Recorded { state, reason })
|
||||
};
|
||||
|
||||
@@ -1364,7 +1325,12 @@ impl SubtitleAction {
|
||||
if !settings.wanted.contains(&language) {
|
||||
continue;
|
||||
}
|
||||
if !backoff_elapsed(gap.attempt.attempts, gap.attempt.last_attempt_at.as_deref()) {
|
||||
// A cap is a wait on a daily allowance, not a failure (§15), so
|
||||
// it never rides the failure ladder — `pending` already dropped
|
||||
// any whose allowance has not reset yet.
|
||||
if gap.attempt.state != SubtitleState::Capped
|
||||
&& !backoff_elapsed(gap.attempt.attempts, gap.attempt.last_attempt_at.as_deref())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
@@ -1414,6 +1380,33 @@ impl SubtitleAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a finished attempt, keeping a cap off the failure ladder.
|
||||
///
|
||||
/// The call sites that decide between [`SubtitleState::Capped`] and
|
||||
/// [`SubtitleState::Failed`] at runtime go through here so neither has to
|
||||
/// remember which of the two writers applies.
|
||||
async fn record_settled(
|
||||
database: &Db,
|
||||
media_file_id: i64,
|
||||
wanted_tag: &str,
|
||||
state: SubtitleState,
|
||||
reason: &str,
|
||||
) -> Result<(), SubtitleError> {
|
||||
if state == SubtitleState::Capped {
|
||||
db::record_capped(database.pool(), media_file_id, wanted_tag, reason).await?;
|
||||
} else {
|
||||
db::record_attempt(
|
||||
database.pool(),
|
||||
media_file_id,
|
||||
wanted_tag,
|
||||
state,
|
||||
Some(reason),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start tracking `language` for every probed media file (§15: the wanted
|
||||
/// set is global). Existing rows are left alone — this never resets a
|
||||
/// backoff or reopens a settled language.
|
||||
@@ -2153,12 +2146,40 @@ mod tests {
|
||||
let attempt = fixture.attempt("pt-PT").await.unwrap();
|
||||
assert_eq!(attempt.state, SubtitleState::Capped);
|
||||
|
||||
// `capped` rows are not in the work list, so the provider is left
|
||||
// alone until something else moves the row.
|
||||
// The allowance resets on the UTC day, so nothing is retried within
|
||||
// the one it ran out on.
|
||||
action.run(&fixture.database).await.unwrap();
|
||||
assert_eq!(searches.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_capped_language_is_retried_once_the_allowance_resets() {
|
||||
let fixture = Fixture::new(&no_tracks()).await;
|
||||
fixture.configure(r#"["pt-PT"]"#, None).await;
|
||||
let provider = StubProvider::failing("opensubtitles", Fail::RateLimited);
|
||||
let searches = Arc::clone(&provider.searches);
|
||||
let action = action(vec![Arc::new(provider)], Vec::new());
|
||||
|
||||
action.run(&fixture.database).await.unwrap();
|
||||
assert_eq!(
|
||||
fixture.attempt("pt-PT").await.unwrap().state,
|
||||
SubtitleState::Capped
|
||||
);
|
||||
|
||||
sqlx::query("UPDATE subtitle_attempts SET last_attempt_at = ?")
|
||||
.bind("2020-01-01T00:00:00.000Z")
|
||||
.execute(fixture.database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
action.run(&fixture.database).await.unwrap();
|
||||
assert_eq!(
|
||||
searches.load(Ordering::SeqCst),
|
||||
2,
|
||||
"§15 drains the backlog over days, which needs the cap to expire"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_provider_at_its_daily_budget_is_a_queue_state_not_a_download() {
|
||||
let fixture = Fixture::new(&no_tracks()).await;
|
||||
|
||||
@@ -499,6 +499,45 @@ pub async fn record_attempt(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record that an attempt stopped on a daily allowance.
|
||||
///
|
||||
/// Separate from [`record_attempt`] because `attempts` drives the failure
|
||||
/// backoff and a capped attempt is not a failure (§15): the budget refused
|
||||
/// to spend, so no provider was asked and the ladder must not advance. The
|
||||
/// count is raised to 1 only to satisfy the table's
|
||||
/// `(attempts = 0) = (last_attempt_at IS NULL)` check.
|
||||
///
|
||||
/// `last_attempt_at` is what [`pending`] reads to decide the allowance has
|
||||
/// since reset.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// If the upsert fails.
|
||||
pub async fn record_capped(
|
||||
pool: &SqlitePool,
|
||||
media_file_id: i64,
|
||||
language: &str,
|
||||
reason: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO subtitle_attempts
|
||||
(media_file_id, language, state, attempts, last_attempt_at, last_failure)
|
||||
VALUES (?, ?, 'capped', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)
|
||||
ON CONFLICT (media_file_id, language) DO UPDATE
|
||||
SET state = 'capped',
|
||||
attempts = MAX(subtitle_attempts.attempts, 1),
|
||||
last_attempt_at = excluded.last_attempt_at,
|
||||
last_failure = excluded.last_failure,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
|
||||
media_file_id,
|
||||
language,
|
||||
reason
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark a language satisfied without counting an attempt.
|
||||
///
|
||||
/// An embedded track satisfies a language with nothing spent, so satisfaction
|
||||
@@ -590,8 +629,18 @@ pub async fn attempts_for(
|
||||
/// first (§15), least recently attempted first within one import.
|
||||
///
|
||||
/// Newest first is what lets enabling this on an existing library drain the
|
||||
/// backlog over days instead of hammering every provider at once. `capped`
|
||||
/// and `unavailable` rows are excluded — neither is work the loop can do now.
|
||||
/// backlog over days instead of hammering every provider at once — which is
|
||||
/// only true if `capped` rows come back, so they are work again once their
|
||||
/// allowance has reset. `unavailable` is excluded outright: it is the end of
|
||||
/// the line and nothing about tomorrow changes it.
|
||||
///
|
||||
/// Budgets are keyed on the UTC calendar day
|
||||
/// (`subtitle_budget_spend.day`), so a cap expires at the next midnight and
|
||||
/// the same boundary decides it here. It MUST be decided in the query and
|
||||
/// not by the caller: `limit` is a window over the whole list, and a row
|
||||
/// that cannot be worked yet would otherwise hold a slot in it — a hundred
|
||||
/// newest files capped this morning would hide every older gap until
|
||||
/// tomorrow.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -607,7 +656,11 @@ pub async fn pending(pool: &SqlitePool, limit: i64) -> Result<Vec<PendingSubtitl
|
||||
f.path AS "media_file_path!: String"
|
||||
FROM subtitle_attempts a
|
||||
JOIN media_files f ON f.id = a.media_file_id
|
||||
WHERE a.state IN ('wanted', 'failed')
|
||||
WHERE (a.state IN ('wanted', 'failed')
|
||||
OR (a.state = 'capped'
|
||||
AND (a.last_attempt_at IS NULL
|
||||
OR a.last_attempt_at
|
||||
< strftime('%Y-%m-%dT00:00:00.000Z', 'now'))))
|
||||
ORDER BY f.created_at DESC, a.last_attempt_at, a.language
|
||||
LIMIT ?"#,
|
||||
limit
|
||||
@@ -636,8 +689,8 @@ pub async fn pending(pool: &SqlitePool, limit: i64) -> Result<Vec<PendingSubtitl
|
||||
mod tests {
|
||||
use super::{
|
||||
attempts_for, delete_file, files_for, mark_satisfied, mark_synced, pending, record_attempt,
|
||||
record_file, sidecar_for, want, NewSubtitleFile, SubtitleOrigin, SubtitleState,
|
||||
SubtitleSync,
|
||||
record_capped, record_file, sidecar_for, want, NewSubtitleFile, SubtitleOrigin,
|
||||
SubtitleState, SubtitleSync,
|
||||
};
|
||||
use crate::Db;
|
||||
use sqlx::SqlitePool;
|
||||
@@ -1074,7 +1127,7 @@ mod tests {
|
||||
want(db.pool(), file, "en").await.unwrap();
|
||||
}
|
||||
mark_satisfied(db.pool(), new, "en").await.unwrap();
|
||||
record_attempt(db.pool(), old, "en", SubtitleState::Capped, None)
|
||||
record_attempt(db.pool(), old, "en", SubtitleState::Unavailable, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1086,12 +1139,96 @@ mod tests {
|
||||
assert_eq!(
|
||||
seen,
|
||||
vec![("/m/new.mkv", "pt-PT"), ("/m/old.mkv", "pt-PT")],
|
||||
"satisfied and capped languages are not work"
|
||||
"satisfied and unavailable languages are not work"
|
||||
);
|
||||
|
||||
assert_eq!(pending(db.pool(), 1).await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// Backdate a cap into an earlier UTC day, which is what the budget
|
||||
/// resetting looks like from here.
|
||||
async fn cap_yesterday(pool: &SqlitePool, file: i64, language: &str) {
|
||||
record_capped(pool, file, language, "provider daily budget exhausted")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"UPDATE subtitle_attempts SET last_attempt_at = '2020-01-01T00:00:00.000Z'
|
||||
WHERE media_file_id = ? AND language = ?",
|
||||
)
|
||||
.bind(file)
|
||||
.bind(language)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_capped_language_returns_once_its_allowance_resets() {
|
||||
let (db, _dir) = database().await;
|
||||
let file = media_file(db.pool(), "/m/a.mkv", "2026-01-01T00:00:00.000Z").await;
|
||||
want(db.pool(), file, "pt-PT").await.unwrap();
|
||||
record_capped(db.pool(), file, "pt-PT", "provider daily budget exhausted")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
pending(db.pool(), 10).await.unwrap().is_empty(),
|
||||
"the allowance it ran out of is still the current one"
|
||||
);
|
||||
|
||||
cap_yesterday(db.pool(), file, "pt-PT").await;
|
||||
|
||||
let work = pending(db.pool(), 10).await.unwrap();
|
||||
assert_eq!(work.len(), 1, "a cap is a wait, not a settlement");
|
||||
assert_eq!(work[0].attempt.state, SubtitleState::Capped);
|
||||
assert_eq!(
|
||||
work[0].attempt.last_failure.as_deref(),
|
||||
Some("provider daily budget exhausted"),
|
||||
"which limit hit is the only thing that tells the two budgets apart"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_cap_that_cannot_be_worked_yet_holds_no_slot() {
|
||||
let (db, _dir) = database().await;
|
||||
let new = media_file(db.pool(), "/m/new.mkv", "2026-06-01T00:00:00.000Z").await;
|
||||
let old = media_file(db.pool(), "/m/old.mkv", "2026-01-01T00:00:00.000Z").await;
|
||||
want(db.pool(), new, "pt-PT").await.unwrap();
|
||||
want(db.pool(), old, "pt-PT").await.unwrap();
|
||||
record_capped(db.pool(), new, "pt-PT", "provider daily budget exhausted")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// `limit` is a window over the whole list and the newest file sorts
|
||||
// first, so a cap left in it would hide the older gap for the day.
|
||||
let work = pending(db.pool(), 1).await.unwrap();
|
||||
assert_eq!(
|
||||
work.iter()
|
||||
.map(|row| row.media_file_path.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["/m/old.mkv"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capping_does_not_advance_the_failure_backoff() {
|
||||
let (db, _dir) = database().await;
|
||||
let file = media_file(db.pool(), "/m/b.mkv", "2026-01-01T00:00:00.000Z").await;
|
||||
want(db.pool(), file, "pt-PT").await.unwrap();
|
||||
for _ in 0..5 {
|
||||
record_capped(db.pool(), file, "pt-PT", "provider daily budget exhausted")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let row = attempts_for(db.pool(), file).await.unwrap().remove(0);
|
||||
assert_eq!(
|
||||
row.attempts, 1,
|
||||
"the budget refused to spend, so no provider was asked"
|
||||
);
|
||||
assert!(row.last_attempt_at.is_some(), "but the wait is timed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_media_file_takes_its_subtitles_with_it() {
|
||||
let (db, _dir) = database().await;
|
||||
|
||||
@@ -111,6 +111,24 @@ pub fn compiled_engines() -> Vec<&'static str> {
|
||||
engines
|
||||
}
|
||||
|
||||
/// Why a selected engine has no backend behind it.
|
||||
///
|
||||
/// A feature left out of the build and a key left out of the environment
|
||||
/// both end with the engine absent from the registry, and reporting the
|
||||
/// second as the first sends the operator to rebuild an image that was
|
||||
/// already correct.
|
||||
#[must_use]
|
||||
pub fn unusable_engine(engine: &str) -> String {
|
||||
if compiled_engines().contains(&engine) {
|
||||
format!(
|
||||
"translation engine '{engine}' is compiled in but unconfigured — \
|
||||
set its API key or command template"
|
||||
)
|
||||
} else {
|
||||
format!("translation engine '{engine}' is not compiled into this binary")
|
||||
}
|
||||
}
|
||||
|
||||
/// One subtitle source: OpenSubtitles.com (DESIGN.md §15).
|
||||
///
|
||||
/// Boxed futures rather than `async fn`, so that the trait stays object safe
|
||||
|
||||
@@ -141,13 +141,16 @@ const REASON_LABELS: Record<string, string> = {
|
||||
sync_rejected: "sync flagged",
|
||||
};
|
||||
|
||||
/** How long a `failed` detail rides in the chip text before it is title-only. */
|
||||
/** How long a detail rides in the chip text before it is title-only. */
|
||||
const DETAIL_CHARS = 60;
|
||||
|
||||
/** The reasons that carry a sentence worth putting on the chip. */
|
||||
const DETAILED_REASONS = new Set(["failed", "capped"]);
|
||||
|
||||
/** A missing language's chip label: language and why, in §201's own words. */
|
||||
export function missingChipLabel(missing: MissingSubtitle): string {
|
||||
const reason = REASON_LABELS[missing.reason] ?? missing.reason;
|
||||
if (missing.reason !== "failed" || missing.detail === null) {
|
||||
if (!DETAILED_REASONS.has(missing.reason) || missing.detail === null) {
|
||||
return `${missing.language} · ${reason}`;
|
||||
}
|
||||
const detail =
|
||||
|
||||
Reference in New Issue
Block a user