Merge #197: budget provider and translator calls

Closes #197
This commit is contained in:
Miguel Palhas
2026-08-25 02:49:45 +01:00
11 changed files with 623 additions and 130 deletions
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "INSERT INTO subtitle_budget_spend (kind, name, day, spent)\n SELECT ?1, ?2, strftime('%Y-%m-%d', 'now'), ?3\n WHERE ?3 <= ?4\n ON CONFLICT (kind, name, day) DO UPDATE\n SET spent = subtitle_budget_spend.spent + excluded.spent\n WHERE subtitle_budget_spend.spent + excluded.spent <= ?4\n RETURNING spent AS \"spent!: i64\"",
"describe": {
"columns": [
{
"name": "spent!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_budget_spend",
"name": "spent"
}
}
}
],
"parameters": {
"Right": 4
},
"nullable": [
false
]
},
"hash": "6e27f3a4c642ac324ef5ae2017a54b93f1d95aa54c46ff7e169278a295d1a2cc"
}
@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "SELECT wanted_languages AS \"wanted_languages!: String\",\n providers_enabled AS \"providers_enabled!: String\",\n translation_engine AS \"translation_engine: String\",\n provider_daily_budgets AS \"provider_daily_budgets!: String\",\n translator_daily_budgets AS \"translator_daily_budgets!: String\"\n FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "wanted_languages!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "wanted_languages"
}
}
},
{
"name": "providers_enabled!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "providers_enabled"
}
}
},
{
"name": "translation_engine: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "translation_engine"
}
}
},
{
"name": "provider_daily_budgets!: String",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "provider_daily_budgets"
}
}
},
{
"name": "translator_daily_budgets!: String",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "translator_daily_budgets"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
false,
false
]
},
"hash": "77e55f2aa534468cf6e599d0c01679653553a68f2f1b9b5516f5d2fb42974b05"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT spent AS \"spent!: i64\" FROM subtitle_budget_spend\n WHERE kind = ? AND name = ? AND day = strftime('%Y-%m-%d', 'now')",
"describe": {
"columns": [
{
"name": "spent!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_budget_spend",
"name": "spent"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "9ee9ff74406e333df50076d1c456f0a9672173af252f36e5d75d2a9df99ab68d"
}
@@ -1,50 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT wanted_languages AS \"wanted_languages!: String\",\n providers_enabled AS \"providers_enabled!: String\",\n translation_engine AS \"translation_engine: String\"\n FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "wanted_languages!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "wanted_languages"
}
}
},
{
"name": "providers_enabled!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "providers_enabled"
}
}
},
{
"name": "translation_engine: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "translation_engine"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true
]
},
"hash": "caa03a8daf9064de5c4724fcfecf70cc13f9a188468395bf5558aa3068c8a4e4"
}
+242 -5
View File
@@ -20,12 +20,13 @@
//! machine translation too — the loop stops. There is no re-search and no
//! replacement; that is a manual action through the API.
use std::collections::{BTreeSet, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use arr_core::subs::{rank, SubtitleTarget, SubtitleVerdict};
use arr_core::{layout, Language};
use arr_db::subtitle_budget::{self as budget, BudgetKind};
use arr_db::subtitles::{self as db, PendingSubtitle, SubtitleOrigin, SubtitleState};
use arr_db::Db;
use arr_probe::Extractor;
@@ -60,13 +61,32 @@ struct Settings {
wanted: Vec<String>,
providers_enabled: BTreeSet<String>,
translation_engine: Option<String>,
/// Provider id -> daily download allowance (§15, #197). A name absent
/// from the map has no cap configured, which is unlimited (§10), not
/// zero.
provider_daily_budgets: BTreeMap<String, i64>,
/// Engine name -> daily character allowance (§15, #197). Same absent ==
/// unlimited rule as `provider_daily_budgets`.
translator_daily_budgets: BTreeMap<String, i64>,
}
impl Settings {
fn provider_allowance(&self, provider: &str) -> Option<i64> {
self.provider_daily_budgets.get(provider).copied()
}
fn translator_allowance(&self, engine: &str) -> Option<i64> {
self.translator_daily_budgets.get(engine).copied()
}
}
async fn load_settings(database: &Db) -> Result<Settings, SubtitleError> {
let row = sqlx::query!(
r#"SELECT wanted_languages AS "wanted_languages!: String",
providers_enabled AS "providers_enabled!: String",
translation_engine AS "translation_engine: String"
r#"SELECT wanted_languages AS "wanted_languages!: String",
providers_enabled AS "providers_enabled!: String",
translation_engine AS "translation_engine: String",
provider_daily_budgets AS "provider_daily_budgets!: String",
translator_daily_budgets AS "translator_daily_budgets!: String"
FROM subtitle_settings WHERE id = 1"#
)
.fetch_one(database.pool())
@@ -75,10 +95,18 @@ async fn load_settings(database: &Db) -> Result<Settings, SubtitleError> {
.map_err(|error| SubtitleError::Settings(error.to_string()))?;
let providers_enabled: BTreeSet<String> = serde_json::from_str(&row.providers_enabled)
.map_err(|error| SubtitleError::Settings(error.to_string()))?;
let provider_daily_budgets: BTreeMap<String, i64> =
serde_json::from_str(&row.provider_daily_budgets)
.map_err(|error| SubtitleError::Settings(error.to_string()))?;
let translator_daily_budgets: BTreeMap<String, i64> =
serde_json::from_str(&row.translator_daily_budgets)
.map_err(|error| SubtitleError::Settings(error.to_string()))?;
Ok(Settings {
wanted,
providers_enabled,
translation_engine: row.translation_engine,
provider_daily_budgets,
translator_daily_budgets,
})
}
@@ -456,6 +484,42 @@ impl Worker {
self.in_flight.lock().await.remove(&key);
}
/// Rank `candidates` and claim the winner's provider download budget
/// (§15, #197), retrying without a provider that is at its cap until one
/// is affordable or none remain. Returns the claimed winner, if any, and
/// whether a provider was skipped for budget along the way — the caller
/// needs that even on a miss, since it changes whether "nothing found"
/// means no provider has it or just that arr would not pay for it today.
async fn claim_within_budget(
&self,
database: &Db,
target: &Target,
hash: Option<&str>,
mut candidates: Vec<Candidate>,
languages: &[Language],
settings: &Settings,
) -> Result<(Option<Candidate>, bool), SubtitleError> {
let mut budget_capped = false;
while let Some(winner) = choose(target, hash, &candidates, languages) {
let provider_name = winner.provider.to_string();
let allowance = settings.provider_allowance(&provider_name);
if budget::try_spend(
database.pool(),
BudgetKind::Provider,
&provider_name,
1,
allowance,
)
.await?
{
return Ok((Some(winner), budget_capped));
}
budget_capped = true;
candidates.retain(|candidate| candidate.provider != winner.provider);
}
Ok((None, budget_capped))
}
async fn close(
&self,
database: &Db,
@@ -506,10 +570,36 @@ impl Worker {
}
let hash = moviehash(&target.path, target.size).await;
if let Some(winner) = choose(&target, hash.as_deref(), &offered, &languages) {
let (winner, budget_capped) = self
.claim_within_budget(
database,
&target,
hash.as_deref(),
offered,
&languages,
settings,
)
.await?;
if let Some(winner) = winner {
return self.fetch(database, &target, wanted_tag, winner).await;
}
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?;
return Ok(Closed::Recorded {
state: SubtitleState::Capped,
reason,
});
}
// A provider error is not an empty answer (§15): the candidate may
// exist where nothing could be asked, so translating now would
// satisfy the language forever with a worse subtitle. Back off and
@@ -713,6 +803,32 @@ impl Worker {
}
};
// Translators bill per character, not per call (§15), and the text
// going out is known before any of it is sent — no need to ask the
// backend afterwards (#197).
let characters = i64::try_from(
cues.iter()
.map(|cue| cue.text.chars().count())
.sum::<usize>(),
)
.unwrap_or(i64::MAX);
let allowance = settings.translator_allowance(&engine);
if !budget::try_spend(
database.pool(),
BudgetKind::Translator,
&engine,
characters,
allowance,
)
.await?
{
return record(
SubtitleState::Capped,
"translator daily budget exhausted".to_owned(),
)
.await;
}
let broken_name = format!("translator {engine}");
let translated =
match arr_subs::translate::translate(backend.as_ref(), &cues, &source_language, wanted)
@@ -1262,6 +1378,28 @@ mod tests {
.unwrap();
}
async fn configure_provider_budget(&self, provider: &str, allowance: i64) {
sqlx::query(
"UPDATE subtitle_settings SET provider_daily_budgets = json_set(provider_daily_budgets, ?, ?)",
)
.bind(format!("$.{provider}"))
.bind(allowance)
.execute(self.database.pool())
.await
.unwrap();
}
async fn configure_translator_budget(&self, engine: &str, allowance: i64) {
sqlx::query(
"UPDATE subtitle_settings SET translator_daily_budgets = json_set(translator_daily_budgets, ?, ?)",
)
.bind(format!("$.{engine}"))
.bind(allowance)
.execute(self.database.pool())
.await
.unwrap();
}
async fn attempt(&self, language: &str) -> Option<db::SubtitleAttempt> {
db::attempts_for(self.database.pool(), self.media_file_id)
.await
@@ -1471,6 +1609,60 @@ mod tests {
assert_eq!(searches.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn a_provider_at_its_daily_budget_is_a_queue_state_not_a_download() {
let fixture = Fixture::new(&no_tracks()).await;
fixture.configure(r#"["pt-PT"]"#, None).await;
fixture.configure_provider_budget("opensubtitles", 0).await;
let provider = StubProvider::new(
"opensubtitles",
vec![candidate(
"opensubtitles",
"42",
Language::PortuguesePortugal,
)],
);
let action = action(vec![Arc::new(provider)], Vec::new());
action.run(&fixture.database).await.unwrap();
let attempt = fixture.attempt("pt-PT").await.unwrap();
assert_eq!(attempt.state, SubtitleState::Capped);
assert!(attempt
.last_failure
.unwrap()
.contains("provider daily budget"));
assert!(fixture.files().await.is_empty(), "nothing was downloaded");
}
#[tokio::test]
async fn a_cheaper_candidate_is_downloaded_when_the_ranked_winner_is_over_budget() {
let fixture = Fixture::new(&no_tracks()).await;
fixture.configure(r#"["pt-PT"]"#, None).await;
fixture.configure_provider_budget("opensubtitles", 0).await;
let winner = Candidate {
hash_match: true,
..candidate("opensubtitles", "hash", Language::PortuguesePortugal)
};
let opensubtitles = StubProvider::new("opensubtitles", vec![winner]);
let podnapisi = StubProvider::new(
"podnapisi",
vec![candidate("podnapisi", "7", Language::PortuguesePortugal)],
);
let action = action(
vec![Arc::new(opensubtitles), Arc::new(podnapisi)],
Vec::new(),
);
action.run(&fixture.database).await.unwrap();
let files = fixture.files().await;
assert_eq!(files.len(), 1);
assert_eq!(files[0].provider.as_deref(), Some("podnapisi"));
let attempt = fixture.attempt("pt-PT").await.unwrap();
assert_eq!(attempt.state, SubtitleState::Satisfied);
}
#[tokio::test]
async fn no_candidate_translates_immediately_from_a_fetched_subtitle() {
let fixture = Fixture::new(&no_tracks()).await;
@@ -1552,6 +1744,51 @@ mod tests {
);
}
#[tokio::test]
async fn a_translator_at_its_daily_character_budget_is_a_queue_state() {
let fixture = Fixture::new(&no_tracks()).await;
fixture.configure(r#"["pt-PT"]"#, Some("openai")).await;
fixture.configure_translator_budget("openai", 0).await;
let source = fixture
.directory
.path()
.join("Movie (2024) - [1080p].en.srt");
tokio::fs::write(&source, SRT).await.unwrap();
db::record_file(
fixture.database.pool(),
&arr_db::NewSubtitleFile::fetched(
fixture.media_file_id,
"en",
"opensubtitles",
"1",
&source.to_string_lossy(),
),
)
.await
.unwrap();
let action = action(
vec![Arc::new(StubProvider::new("opensubtitles", Vec::new()))],
vec![Arc::new(StubBackend)],
);
action.run(&fixture.database).await.unwrap();
let attempt = fixture.attempt("pt-PT").await.unwrap();
assert_eq!(attempt.state, SubtitleState::Capped);
assert!(attempt
.last_failure
.unwrap()
.contains("translator daily budget"));
assert!(
fixture
.files()
.await
.iter()
.all(|file| file.origin != SubtitleOrigin::Translated),
"nothing was translated"
);
}
#[tokio::test]
async fn a_text_embedded_track_is_extracted_as_the_translation_source() {
let fixture = Fixture::new(&serde_json::json!({ "sub_tracks": [
@@ -0,0 +1,19 @@
-- #197. Token bucket state for §15's provider and translator budgets. The
-- allowance itself is `subtitle_settings.provider_daily_budgets` /
-- `translator_daily_budgets` (#198); this table only tracks what has already
-- been spent today, so a bucket with no allowance configured reads as
-- unlimited rather than zero (0025's own comment on that column).
--
-- One row per (kind, name, day); a new day is a fresh row rather than a
-- reset column, so the refill is "today has no row yet" and needs no cron.
CREATE TABLE subtitle_budget_spend (
kind TEXT NOT NULL CHECK (kind IN ('provider', 'translator')),
name TEXT NOT NULL,
-- UTC calendar day, `YYYY-MM-DD`.
day TEXT NOT NULL,
-- A provider unit is one download; a translator unit is one character of
-- source text sent (DESIGN.md §15: translators bill per character).
spent INTEGER NOT NULL DEFAULT 0 CHECK (spent >= 0),
PRIMARY KEY (kind, name, day)
) STRICT;
+2
View File
@@ -7,10 +7,12 @@ use std::path::Path;
pub mod blacklist;
pub mod policy;
pub mod subtitle_budget;
pub mod subtitles;
pub use blacklist::Blacklist;
pub use policy::{MoviePolicy, PolicyColumns, PolicyError, TitlePolicy};
pub use subtitle_budget::BudgetKind;
pub use subtitles::{
NewSubtitleFile, PendingSubtitle, SubtitleAttempt, SubtitleFile, SubtitleOrigin, SubtitleState,
SubtitleSync,
+222
View File
@@ -0,0 +1,222 @@
//! Token buckets for §15's provider and translator daily budgets (#197).
//!
//! One row per (kind, name, today). The allowance lives in
//! `subtitle_settings` (#198) and is passed in by the caller rather than
//! read here, because the reconcile loop already loads settings once per
//! tick; this module only tracks and gates spend against it.
//!
//! [`try_spend`] is the single gate, and it is the only one: check and
//! increment happen in one statement, so concurrent closes racing the same
//! bucket (§8 runs up to four at once) cannot both slip a spend past the cap
//! between a read and a write. [`spent_today`] is read-only, kept apart for
//! callers that only need to report what a bucket holds.
use sqlx::SqlitePool;
/// Which bucket a spend counts against.
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
#[sqlx(rename_all = "lowercase")]
pub enum BudgetKind {
/// One unit per download (DESIGN.md §15's example: a single-digit daily
/// download cap on `OpenSubtitles`' free tier).
Provider,
/// One unit per character of source text sent, not per call (DESIGN.md
/// §15: translators bill per character).
Translator,
}
/// What has already been spent from `kind`/`name`'s bucket today.
///
/// # Errors
///
/// If the query fails.
pub async fn spent_today(
pool: &SqlitePool,
kind: BudgetKind,
name: &str,
) -> Result<i64, sqlx::Error> {
Ok(sqlx::query_scalar!(
r#"SELECT spent AS "spent!: i64" FROM subtitle_budget_spend
WHERE kind = ? AND name = ? AND day = strftime('%Y-%m-%d', 'now')"#,
kind,
name
)
.fetch_optional(pool)
.await?
.unwrap_or(0))
}
/// Try to spend `amount` from `kind`/`name`'s bucket today, atomically.
///
/// `None` allowance means unlimited (§10): always spends, and nothing is
/// recorded — there is no cap to account spend against. With an allowance,
/// the insert-or-increment and the cap check are one statement, so a spend
/// that would cross `allowance` neither applies nor is recorded.
///
/// # Errors
///
/// If the query fails.
pub async fn try_spend(
pool: &SqlitePool,
kind: BudgetKind,
name: &str,
amount: i64,
allowance: Option<i64>,
) -> Result<bool, sqlx::Error> {
let Some(allowance) = allowance else {
return Ok(true);
};
let spent = sqlx::query_scalar!(
r#"INSERT INTO subtitle_budget_spend (kind, name, day, spent)
SELECT ?1, ?2, strftime('%Y-%m-%d', 'now'), ?3
WHERE ?3 <= ?4
ON CONFLICT (kind, name, day) DO UPDATE
SET spent = subtitle_budget_spend.spent + excluded.spent
WHERE subtitle_budget_spend.spent + excluded.spent <= ?4
RETURNING spent AS "spent!: i64""#,
kind,
name,
amount,
allowance
)
.fetch_optional(pool)
.await?;
Ok(spent.is_some())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::{spent_today, try_spend, BudgetKind};
use crate::Db;
async fn database() -> (Db, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let database = Db::connect(dir.path().join("test.db")).await.unwrap();
database.migrate().await.unwrap();
(database, dir)
}
#[tokio::test]
async fn an_unconfigured_allowance_is_unlimited_and_untracked() {
let (db, _dir) = database().await;
let spent = try_spend(db.pool(), BudgetKind::Provider, "opensubtitles", 1, None)
.await
.unwrap();
assert!(spent);
assert_eq!(
spent_today(db.pool(), BudgetKind::Provider, "opensubtitles")
.await
.unwrap(),
0,
"nothing to cap means nothing to account"
);
}
#[tokio::test]
async fn spend_accumulates_until_the_allowance_is_reached() {
let (db, _dir) = database().await;
let allowance = Some(5);
for _ in 0..5 {
assert!(try_spend(
db.pool(),
BudgetKind::Provider,
"opensubtitles",
1,
allowance
)
.await
.unwrap());
}
assert_eq!(
spent_today(db.pool(), BudgetKind::Provider, "opensubtitles")
.await
.unwrap(),
5
);
let refused = try_spend(
db.pool(),
BudgetKind::Provider,
"opensubtitles",
1,
allowance,
)
.await
.unwrap();
assert!(!refused, "the sixth call is over the cap");
assert_eq!(
spent_today(db.pool(), BudgetKind::Provider, "opensubtitles")
.await
.unwrap(),
5,
"a refused spend is not recorded"
);
}
#[tokio::test]
async fn a_spend_that_would_cross_the_cap_is_refused_whole() {
let (db, _dir) = database().await;
let allowance = Some(100);
// A translator bucket counts characters, not calls, so one spend can
// be far larger than what remains.
assert!(
try_spend(db.pool(), BudgetKind::Translator, "deepl", 90, allowance)
.await
.unwrap()
);
let refused = try_spend(db.pool(), BudgetKind::Translator, "deepl", 20, allowance)
.await
.unwrap();
assert!(!refused, "90 + 20 would cross 100");
assert_eq!(
spent_today(db.pool(), BudgetKind::Translator, "deepl")
.await
.unwrap(),
90,
"the refused spend left the bucket untouched"
);
assert!(
try_spend(db.pool(), BudgetKind::Translator, "deepl", 10, allowance)
.await
.unwrap()
);
}
#[tokio::test]
async fn buckets_are_independent_per_kind_and_name() {
let (db, _dir) = database().await;
let allowance = Some(1);
assert!(try_spend(
db.pool(),
BudgetKind::Provider,
"opensubtitles",
1,
allowance
)
.await
.unwrap());
// Same name, other kind: untouched.
assert_eq!(
spent_today(db.pool(), BudgetKind::Translator, "opensubtitles")
.await
.unwrap(),
0
);
// Same kind, other name: untouched.
assert_eq!(
spent_today(db.pool(), BudgetKind::Provider, "podnapisi")
.await
.unwrap(),
0
);
}
}
+2 -32
View File
@@ -10,13 +10,13 @@
//! One request translates a whole batch: the texts go out as a JSON array
//! and come back as a same-length array of translations, order preserved,
//! so cue numbering never leaves [`crate::translate`]. Characters sent are
//! what `DeepL` bills, so they are counted and reported for #197's budget.
//! what `DeepL` bills; #197's budget counts those itself before this ever
//! runs, rather than polling this client for them afterwards.
//!
//! The auth key arrives from bootstrap config or environment per §10 —
//! `ARR_TRANSLATE_DEEPL_API_KEY`, plumbed through `arr-daemon`'s config —
//! never a database row.
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use reqwest::{Client, StatusCode, Url};
@@ -64,7 +64,6 @@ pub struct DeepL {
base_url: Url,
config: DeepLConfig,
id: BackendId,
billed: AtomicU64,
}
// Hand-written: the key must never reach a log line.
@@ -109,7 +108,6 @@ impl DeepL {
base_url: url,
config,
id: BackendId::new("deepl"),
billed: AtomicU64::new(0),
})
}
@@ -186,10 +184,6 @@ impl Backend for DeepL {
deep_code(target, false).is_some()
}
fn characters_billed(&self) -> u64 {
self.billed.load(Ordering::Relaxed)
}
fn translate<'a>(&'a self, batch: &'a Batch) -> TranslateFuture<'a> {
Box::pin(async move {
let texts: Vec<String> = batch.cues.iter().map(|cue| cue.text.clone()).collect();
@@ -277,11 +271,6 @@ impl Backend for DeepL {
});
}
self.billed.fetch_add(
texts.iter().map(|text| text.chars().count() as u64).sum(),
Ordering::Relaxed,
);
Ok(parsed
.translations
.into_iter()
@@ -455,25 +444,6 @@ mod tests {
assert_eq!(body["source_lang"], "PT");
}
#[tokio::test]
async fn characters_sent_are_reported_as_billed() {
let server = MockServer::start().await;
mount_translate(&server, 200, &reply_body(&[r#""x""#])).await;
let client = deepl(server.uri());
assert_eq!(client.characters_billed(), 0);
client
.translate(&batch(
Language::Other("en".to_owned()),
Language::PortugueseBrazil,
&["four"],
))
.await
.expect("the mock answers");
assert_eq!(client.characters_billed(), 4);
}
#[tokio::test]
async fn a_wrong_translation_count_is_a_mismatch_not_a_subtitle() {
let server = MockServer::start().await;
+3 -34
View File
@@ -13,15 +13,14 @@
//!
//! * replies HTML-escape characters even with `format: "text"`
//! (`&quot;`, `&#39;`), so they are decoded before returning;
//! * per-character usage is not echoed back, so characters sent are counted
//! on this side and reported for #197's budget.
//! * per-character usage is not echoed back. #197's budget does not need it
//! from here either — it counts source characters itself before this ever
//! runs.
//!
//! The API key arrives from bootstrap config or environment per §10 —
//! `ARR_TRANSLATE_GOOGLE_API_KEY`, plumbed through `arr-daemon`'s config —
//! never a database row.
use std::sync::atomic::{AtomicU64, Ordering};
use reqwest::{Client, StatusCode, Url};
use serde::{Deserialize, Serialize};
@@ -58,7 +57,6 @@ pub struct Google {
base_url: Url,
config: GoogleConfig,
id: BackendId,
billed: AtomicU64,
}
// Hand-written: the key must never reach a log line.
@@ -97,7 +95,6 @@ impl Google {
base_url: url,
config,
id: BackendId::new("google"),
billed: AtomicU64::new(0),
})
}
}
@@ -200,10 +197,6 @@ impl Backend for Google {
google_code(target).is_some()
}
fn characters_billed(&self) -> u64 {
self.billed.load(Ordering::Relaxed)
}
fn translate<'a>(&'a self, batch: &'a Batch) -> TranslateFuture<'a> {
Box::pin(async move {
let texts: Vec<String> = batch.cues.iter().map(|cue| cue.text.clone()).collect();
@@ -285,11 +278,6 @@ impl Backend for Google {
});
}
self.billed.fetch_add(
texts.iter().map(|text| text.chars().count() as u64).sum(),
Ordering::Relaxed,
);
Ok(parsed
.data
.translations
@@ -523,25 +511,6 @@ mod tests {
assert_eq!(body["source"], "pt");
}
#[tokio::test]
async fn characters_sent_are_reported_as_billed() {
let server = MockServer::start().await;
mount_translate(&server, 200, &reply_body(&[r#""x""#])).await;
let client = google(server.uri());
assert_eq!(client.characters_billed(), 0);
client
.translate(&batch(
Language::Other("en".to_owned()),
Language::PortugueseBrazil,
&["four"],
))
.await
.expect("the mock answers");
assert_eq!(client.characters_billed(), 4);
}
#[tokio::test]
async fn a_wrong_translation_count_is_a_mismatch_not_a_subtitle() {
let server = MockServer::start().await;
+7 -9
View File
@@ -114,6 +114,13 @@ pub type TranslateFuture<'a> =
/// A backend translates one batch and reports what happened. It does not
/// chunk, does not validate its own replies — [`translate`] does both, so a
/// backend cannot skip them — and never sees a timestamp.
///
/// It also does not report what it billed. §15's translator budget (#197)
/// spends against source characters sent, which the caller already knows
/// before any batch goes out — a cumulative counter polled off the backend
/// cannot attribute one call's cost when several run concurrently on the
/// same instance (§8 runs up to four closes at once), so nothing here tracks
/// it.
pub trait Backend: fmt::Debug + Send + Sync {
/// The name this backend answers to in settings and the UI.
fn id(&self) -> BackendId;
@@ -128,15 +135,6 @@ pub trait Backend: fmt::Debug + Send + Sync {
BATCH_CHAR_BUDGET
}
/// Characters this backend has billed so far, cumulative over its life.
///
/// Translators charge per character (DESIGN.md §15); the daily budget
/// (#197) spends against what was actually billed rather than an
/// estimate of it. A backend that cannot say reports zero.
fn characters_billed(&self) -> u64 {
0
}
/// Translate one batch.
///
/// The reply is expected to carry every cue number the batch carried,