//! The runtime-editable half of subtitle configuration (`DESIGN.md` §15, //! §10, issue #198). Provider credentials, translator keys, the //! remote-command template and the `alass`/`ffmpeg` paths never reach here — //! those are config/env, per §10, and this surface would leak them into a //! `sqlite3 .backup` on a timer if it did. //! //! A single row rather than a CRUD collection: the wanted set, enabled //! providers and translation engine are global, not per root (§15). use std::collections::BTreeMap; use axum::extract::rejection::JsonRejection; use axum::extract::State; use axum::Json; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::movies::{pool, ApiError, ErrorBody}; use crate::policies::parsed; use crate::state::AppState; /// The subtitle settings row, plus which translation engines this binary /// actually has compiled in. #[derive(Debug, Clone, Serialize, ToSchema)] pub struct SubtitleSettings { pub wanted_languages: Vec, pub providers_enabled: Vec, pub translation_engine: Option, pub provider_daily_budgets: BTreeMap, pub translator_daily_budgets: BTreeMap, pub remote_command_timeout_seconds: u32, /// Engines DESIGN.md §15 knows about that this binary compiled in. /// `translation_engine` is always a member of this list or `null` — a /// backend whose cargo feature is missing is never selectable. pub available_engines: Vec, } /// The payload for replacing the settings row. Same shape as /// [`SubtitleSettings`] minus `available_engines`, which is a fact about the /// binary, not something an operator sets. #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct SubtitleSettingsInput { pub wanted_languages: Vec, pub providers_enabled: Vec, pub translation_engine: Option, #[serde(default)] pub provider_daily_budgets: BTreeMap, #[serde(default)] pub translator_daily_budgets: BTreeMap, pub remote_command_timeout_seconds: u32, } impl SubtitleSettingsInput { /// Validate against the vocabulary the running binary actually knows. /// Every failure names its field so a rejected edit is fixable without /// reading the schema. fn validate(&self) -> Result<(), String> { if self.wanted_languages.is_empty() { return Err("wanted_languages: must not be empty".into()); } if self.wanted_languages.iter().any(String::is_empty) { return Err("wanted_languages: language tags must not be empty".into()); } let mut seen = std::collections::BTreeSet::new(); for lang in &self.wanted_languages { if !seen.insert(lang.as_str()) { return Err(format!("wanted_languages: '{lang}' appears twice")); } } if self.providers_enabled.iter().any(String::is_empty) { return Err("providers_enabled: provider ids must not be empty".into()); } let mut seen = std::collections::BTreeSet::new(); for provider in &self.providers_enabled { if !seen.insert(provider.as_str()) { return Err(format!("providers_enabled: '{provider}' appears twice")); } } if let Some(engine) = &self.translation_engine { if !arr_subs::ENGINES.contains(&engine.as_str()) { return Err(format!( "translation_engine: '{engine}' is not a known engine" )); } if !arr_subs::compiled_engines().contains(&engine.as_str()) { return Err(format!( "translation_engine: '{engine}' is not compiled into this binary" )); } } if self.remote_command_timeout_seconds == 0 { return Err("remote_command_timeout_seconds: must be greater than zero".into()); } Ok(()) } fn into_columns(self) -> Result { fn json(value: impl serde::Serialize) -> Result { serde_json::to_string(&value).map_err(|error| { tracing::error!(%error, "subtitle settings serialisation failed"); ApiError::Database("serialisation failed".into()) }) } Ok(SettingsColumns { wanted_languages: json(&self.wanted_languages)?, providers_enabled: json(&self.providers_enabled)?, translation_engine: self.translation_engine, provider_daily_budgets: json(&self.provider_daily_budgets)?, translator_daily_budgets: json(&self.translator_daily_budgets)?, remote_command_timeout_seconds: i64::from(self.remote_command_timeout_seconds), }) } } /// The row as the table stores it, before JSON parsing. struct SettingsColumns { wanted_languages: String, providers_enabled: String, translation_engine: Option, provider_daily_budgets: String, translator_daily_budgets: String, remote_command_timeout_seconds: i64, } fn column( column: &'static str, value: &str, ) -> Result { serde_json::from_str(value).map_err(|error| { tracing::error!(column, %error, "subtitle settings column holds unexpected JSON"); ApiError::Database(format!("subtitle settings column {column} is not valid")) }) } impl SettingsColumns { fn into_settings(self) -> Result { Ok(SubtitleSettings { wanted_languages: column("wanted_languages", &self.wanted_languages)?, providers_enabled: column("providers_enabled", &self.providers_enabled)?, translation_engine: self.translation_engine, provider_daily_budgets: column("provider_daily_budgets", &self.provider_daily_budgets)?, translator_daily_budgets: column( "translator_daily_budgets", &self.translator_daily_budgets, )?, remote_command_timeout_seconds: u32::try_from(self.remote_command_timeout_seconds) .unwrap_or(0), available_engines: arr_subs::compiled_engines() .into_iter() .map(str::to_owned) .collect(), }) } } async fn load(state: &AppState) -> Result { let row = sqlx::query_as!( SettingsColumns, 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", remote_command_timeout_seconds AS "remote_command_timeout_seconds!: i64" FROM subtitle_settings WHERE id = 1"# ) .fetch_one(pool(state)?) .await?; row.into_settings() } #[utoipa::path( get, path = "/api/settings/subtitles", tag = "subtitles", responses( (status = 200, body = SubtitleSettings), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn get(State(state): State) -> Result, ApiError> { Ok(Json(load(&state).await?)) } #[utoipa::path( put, path = "/api/settings/subtitles", tag = "subtitles", request_body = SubtitleSettingsInput, responses( (status = 200, body = SubtitleSettings), (status = 422, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn update( State(state): State, body: Result, JsonRejection>, ) -> Result, ApiError> { let input = parsed(body)?; input.validate().map_err(ApiError::Invalid)?; let columns = input.into_columns()?; sqlx::query!( r#"UPDATE subtitle_settings SET wanted_languages = ?, providers_enabled = ?, translation_engine = ?, provider_daily_budgets = ?, translator_daily_budgets = ?, remote_command_timeout_seconds = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = 1"#, columns.wanted_languages, columns.providers_enabled, columns.translation_engine, columns.provider_daily_budgets, columns.translator_daily_budgets, columns.remote_command_timeout_seconds, ) .execute(pool(&state)?) .await?; Ok(Json(load(&state).await?)) } #[cfg(test)] mod tests { use axum::http::StatusCode; use crate::{router, AppState, Upstreams}; async fn application() -> (tempfile::TempDir, String) { let dir = tempfile::tempdir().expect("tempdir"); let database = arr_db::Db::connect(dir.path().join("arr.db")) .await .expect("connect database"); database.migrate().await.expect("migrate database"); let state = AppState::new(Upstreams::new( "http://127.0.0.1:1".into(), "http://127.0.0.1:1".into(), )) .expect("state") .with_database(database); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); let address = listener.local_addr().expect("address"); let app = router(state); tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); (dir, format!("http://{address}")) } fn valid_input() -> serde_json::Value { serde_json::json!({ "wanted_languages": ["pt-PT", "en"], "providers_enabled": ["opensubtitles", "podnapisi"], "translation_engine": null, "provider_daily_budgets": { "opensubtitles": 100 }, "translator_daily_budgets": {}, "remote_command_timeout_seconds": 45 }) } #[tokio::test] async fn the_seeded_row_reads_back_with_the_engines_this_build_compiled() { let (_dir, base) = application().await; let settings: serde_json::Value = reqwest::get(format!("{base}/api/settings/subtitles")) .await .expect("get settings") .json() .await .expect("settings json"); assert_eq!( settings["wanted_languages"], serde_json::json!(["pt-PT", "en"]) ); assert_eq!( settings["providers_enabled"], serde_json::json!(["opensubtitles", "podnapisi"]) ); assert!(settings["translation_engine"].is_null()); // `available_engines` is a fact about the build, not about the seed // row, so it tracks the `translate-*` features rather than a literal. assert_eq!( settings["available_engines"], serde_json::json!(arr_subs::compiled_engines()) ); } #[tokio::test] async fn the_settings_round_trip_through_a_put() { let (_dir, base) = application().await; let updated: serde_json::Value = reqwest::Client::new() .put(format!("{base}/api/settings/subtitles")) .json(&valid_input()) .send() .await .expect("put settings") .json() .await .expect("updated json"); assert_eq!( updated["provider_daily_budgets"], serde_json::json!({ "opensubtitles": 100 }) ); assert_eq!(updated["remote_command_timeout_seconds"], 45); let refetched: serde_json::Value = reqwest::get(format!("{base}/api/settings/subtitles")) .await .expect("get settings") .json() .await .expect("settings json"); assert_eq!(refetched, updated); } /// Whether a known engine is selectable depends on which `translate-*` /// features this binary was built with, so the test asks the build rather /// than assuming. With no feature on, every engine is uncompiled and must /// be refused; with all of them on there is nothing to refuse, and the /// complementary truth — a compiled engine is accepted — is what holds. #[tokio::test] async fn an_uncompiled_engine_is_a_422_naming_the_field() { let compiled = arr_subs::compiled_engines(); let Some(uncompiled) = arr_subs::ENGINES .iter() .find(|engine| !compiled.contains(*engine)) else { return a_compiled_engine_is_accepted().await; }; let (_dir, base) = application().await; let mut payload = valid_input(); payload["translation_engine"] = serde_json::json!(uncompiled); let response = reqwest::Client::new() .put(format!("{base}/api/settings/subtitles")) .json(&payload) .send() .await .expect("put settings"); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); let body: serde_json::Value = response.json().await.expect("error body"); assert!( body["error"] .as_str() .expect("error text") .contains("translation_engine"), "{body}" ); } /// The other side of the feature gate: an engine this binary *did* /// compile in is selectable. Called directly when no engine is uncompiled. async fn a_compiled_engine_is_accepted() { let compiled = arr_subs::compiled_engines(); let Some(engine) = compiled.first() else { return; }; let (_dir, base) = application().await; let mut payload = valid_input(); payload["translation_engine"] = serde_json::json!(engine); let response = reqwest::Client::new() .put(format!("{base}/api/settings/subtitles")) .json(&payload) .send() .await .expect("put settings"); assert_eq!(response.status(), StatusCode::OK, "{engine} is compiled in"); } #[tokio::test] async fn an_unknown_engine_name_is_a_422() { let (_dir, base) = application().await; let mut payload = valid_input(); payload["translation_engine"] = serde_json::json!("bing-translate"); let response = reqwest::Client::new() .put(format!("{base}/api/settings/subtitles")) .json(&payload) .send() .await .expect("put settings"); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); } #[tokio::test] async fn every_field_validates_by_name() { let (_dir, base) = application().await; let with = |patch: &dyn Fn(&mut serde_json::Value)| { let mut payload = valid_input(); patch(&mut payload); payload }; let cases: Vec<(serde_json::Value, &str)> = vec![ ( with(&|payload| payload["wanted_languages"] = serde_json::json!([])), "wanted_languages", ), ( with(&|payload| { payload["wanted_languages"] = serde_json::json!(["pt-PT", "pt-PT"]); }), "wanted_languages", ), ( with(&|payload| { payload["providers_enabled"] = serde_json::json!(["opensubtitles", ""]); }), "providers_enabled", ), ( with(&|payload| payload["remote_command_timeout_seconds"] = serde_json::json!(0)), "remote_command_timeout_seconds", ), ]; for (payload, field) in cases { let response = reqwest::Client::new() .put(format!("{base}/api/settings/subtitles")) .json(&payload) .send() .await .expect("put settings"); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); let body: serde_json::Value = response.json().await.expect("error body"); let error = body["error"].as_str().expect("error text"); assert!(error.contains(field), "{field}: {error}"); } } #[tokio::test] async fn malformed_json_is_422_not_400_or_500() { let (_dir, base) = application().await; let response = reqwest::Client::new() .put(format!("{base}/api/settings/subtitles")) .header("content-type", "application/json") .body("{not json") .send() .await .expect("malformed put"); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); } }