fix(subs): retry a capped language next day
ci / web (push) Successful in 1m49s
e2e / e2e (push) Failing after 2m58s
ci / rust (push) Successful in 3m1s
ci / image (push) Successful in 3m47s

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:
Miguel Palhas
2026-09-20 18:43:08 +01:00
co-authored by Claude
parent dab3c06dd7
commit 70ba6ac376
7 changed files with 255 additions and 66 deletions
@@ -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"
}
@@ -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"
}
+5 -7
View File
@@ -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),
}
}
+69 -48
View File
@@ -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;
+144 -7
View File
@@ -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;
+18
View File
@@ -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
+5 -2
View File
@@ -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 =