diff --git a/.sqlx/query-6e27f3a4c642ac324ef5ae2017a54b93f1d95aa54c46ff7e169278a295d1a2cc.json b/.sqlx/query-6e27f3a4c642ac324ef5ae2017a54b93f1d95aa54c46ff7e169278a295d1a2cc.json new file mode 100644 index 0000000..b8ae164 --- /dev/null +++ b/.sqlx/query-6e27f3a4c642ac324ef5ae2017a54b93f1d95aa54c46ff7e169278a295d1a2cc.json @@ -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" +} diff --git a/.sqlx/query-77e55f2aa534468cf6e599d0c01679653553a68f2f1b9b5516f5d2fb42974b05.json b/.sqlx/query-77e55f2aa534468cf6e599d0c01679653553a68f2f1b9b5516f5d2fb42974b05.json new file mode 100644 index 0000000..28090cc --- /dev/null +++ b/.sqlx/query-77e55f2aa534468cf6e599d0c01679653553a68f2f1b9b5516f5d2fb42974b05.json @@ -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" +} diff --git a/.sqlx/query-9ee9ff74406e333df50076d1c456f0a9672173af252f36e5d75d2a9df99ab68d.json b/.sqlx/query-9ee9ff74406e333df50076d1c456f0a9672173af252f36e5d75d2a9df99ab68d.json new file mode 100644 index 0000000..254207c --- /dev/null +++ b/.sqlx/query-9ee9ff74406e333df50076d1c456f0a9672173af252f36e5d75d2a9df99ab68d.json @@ -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" +} diff --git a/.sqlx/query-caa03a8daf9064de5c4724fcfecf70cc13f9a188468395bf5558aa3068c8a4e4.json b/.sqlx/query-caa03a8daf9064de5c4724fcfecf70cc13f9a188468395bf5558aa3068c8a4e4.json deleted file mode 100644 index fe59ef1..0000000 --- a/.sqlx/query-caa03a8daf9064de5c4724fcfecf70cc13f9a188468395bf5558aa3068c8a4e4.json +++ /dev/null @@ -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" -} diff --git a/crates/arr-daemon/src/subtitles.rs b/crates/arr-daemon/src/subtitles.rs index 72d88be..f014e27 100644 --- a/crates/arr-daemon/src/subtitles.rs +++ b/crates/arr-daemon/src/subtitles.rs @@ -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, providers_enabled: BTreeSet, translation_engine: Option, + /// 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, + /// Engine name -> daily character allowance (§15, #197). Same absent == + /// unlimited rule as `provider_daily_budgets`. + translator_daily_budgets: BTreeMap, +} + +impl Settings { + fn provider_allowance(&self, provider: &str) -> Option { + self.provider_daily_budgets.get(provider).copied() + } + + fn translator_allowance(&self, engine: &str) -> Option { + self.translator_daily_budgets.get(engine).copied() + } } async fn load_settings(database: &Db) -> Result { 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 { .map_err(|error| SubtitleError::Settings(error.to_string()))?; let providers_enabled: BTreeSet = serde_json::from_str(&row.providers_enabled) .map_err(|error| SubtitleError::Settings(error.to_string()))?; + let provider_daily_budgets: BTreeMap = + serde_json::from_str(&row.provider_daily_budgets) + .map_err(|error| SubtitleError::Settings(error.to_string()))?; + let translator_daily_budgets: BTreeMap = + 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, + languages: &[Language], + settings: &Settings, + ) -> Result<(Option, 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::(), + ) + .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::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": [ diff --git a/crates/arr-db/migrations/0027_subtitle_budgets.sql b/crates/arr-db/migrations/0027_subtitle_budgets.sql new file mode 100644 index 0000000..dc058f0 --- /dev/null +++ b/crates/arr-db/migrations/0027_subtitle_budgets.sql @@ -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; diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index 4faa1eb..c530100 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -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, diff --git a/crates/arr-db/src/subtitle_budget.rs b/crates/arr-db/src/subtitle_budget.rs new file mode 100644 index 0000000..ea55d57 --- /dev/null +++ b/crates/arr-db/src/subtitle_budget.rs @@ -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 { + 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, +) -> Result { + 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 + ); + } +} diff --git a/crates/arr-subs/src/deepl.rs b/crates/arr-subs/src/deepl.rs index 4920698..e18c81f 100644 --- a/crates/arr-subs/src/deepl.rs +++ b/crates/arr-subs/src/deepl.rs @@ -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 = 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; diff --git a/crates/arr-subs/src/google.rs b/crates/arr-subs/src/google.rs index 88c84ca..244e1dc 100644 --- a/crates/arr-subs/src/google.rs +++ b/crates/arr-subs/src/google.rs @@ -13,15 +13,14 @@ //! //! * replies HTML-escape characters even with `format: "text"` //! (`"`, `'`), 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 = 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; diff --git a/crates/arr-subs/src/translate.rs b/crates/arr-subs/src/translate.rs index 50b29c2..d448047 100644 --- a/crates/arr-subs/src/translate.rs +++ b/crates/arr-subs/src/translate.rs @@ -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,