diff --git a/.sqlx/query-44a954a16f858caf8bc9ff9a3a05a1f10482c9d13e0ffd7b9f92d35bdf9cccdc.json b/.sqlx/query-44a954a16f858caf8bc9ff9a3a05a1f10482c9d13e0ffd7b9f92d35bdf9cccdc.json new file mode 100644 index 0000000..c228946 --- /dev/null +++ b/.sqlx/query-44a954a16f858caf8bc9ff9a3a05a1f10482c9d13e0ffd7b9f92d35bdf9cccdc.json @@ -0,0 +1,86 @@ +{ + "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 remote_command_timeout_seconds AS \"remote_command_timeout_seconds!: i64\"\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" + } + } + }, + { + "name": "remote_command_timeout_seconds!: i64", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "subtitle_settings", + "name": "remote_command_timeout_seconds" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + true, + false, + false, + false + ] + }, + "hash": "44a954a16f858caf8bc9ff9a3a05a1f10482c9d13e0ffd7b9f92d35bdf9cccdc" +} diff --git a/.sqlx/query-e4438c9d2ac3ca2bb88922c8cb16a26221efec3b1de7976d8015f61e26118cd1.json b/.sqlx/query-e4438c9d2ac3ca2bb88922c8cb16a26221efec3b1de7976d8015f61e26118cd1.json new file mode 100644 index 0000000..4a6ce32 --- /dev/null +++ b/.sqlx/query-e4438c9d2ac3ca2bb88922c8cb16a26221efec3b1de7976d8015f61e26118cd1.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE subtitle_settings SET\n wanted_languages = ?, providers_enabled = ?, translation_engine = ?,\n provider_daily_budgets = ?, translator_daily_budgets = ?,\n remote_command_timeout_seconds = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = 1", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "e4438c9d2ac3ca2bb88922c8cb16a26221efec3b1de7976d8015f61e26118cd1" +} diff --git a/Cargo.lock b/Cargo.lock index ec3a232..753186e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,6 +35,7 @@ dependencies = [ "arr-indexer", "arr-meta", "arr-parse", + "arr-subs", "axum", "chrono", "reqwest", diff --git a/crates/arr-api/Cargo.toml b/crates/arr-api/Cargo.toml index 686ed94..e2e2243 100644 --- a/crates/arr-api/Cargo.toml +++ b/crates/arr-api/Cargo.toml @@ -12,6 +12,7 @@ arr-db = { workspace = true } arr-indexer = { workspace = true } arr-meta = { workspace = true } arr-parse = { workspace = true } +arr-subs = { workspace = true } axum = { workspace = true } chrono = { workspace = true } reqwest = { workspace = true } diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 1fd1f75..df6c6a6 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -15,6 +15,7 @@ mod roots; mod search; mod series; mod state; +mod subtitle_settings; mod trailer; use axum::routing::get; @@ -43,6 +44,7 @@ pub use state::{ AppState, EpisodeCommand, MetadataCommand, MovieCommand, SeasonCommand, Upstreams, DEFAULT_TMDB_URL, }; +pub use subtitle_settings::{SubtitleSettings, SubtitleSettingsInput}; pub use trailer::{Trailer, TrailerKind}; /// Where the generated document is served, and where `just gen-client` reads @@ -68,7 +70,8 @@ pub const DOCS_PATH: &str = "/api/docs"; (name = "owners", description = "Owner tags and filtered views (DESIGN.md §4.3)"), (name = "policies", description = "Quality policies (DESIGN.md §5)"), (name = "search", description = "Unified title and release search"), - (name = "roots", description = "Root folders and their policies") + (name = "roots", description = "Root folders and their policies"), + (name = "subtitles", description = "Subtitle configuration (DESIGN.md §15)") ), )] struct ApiDoc; @@ -115,6 +118,7 @@ fn api_router() -> OpenApiRouter { .routes(routes!(roots::get, roots::update, roots::delete)) .routes(routes!(policies::list, policies::create)) .routes(routes!(policies::get, policies::update, policies::delete)) + .routes(routes!(subtitle_settings::get, subtitle_settings::update)) } /// The generated `OpenAPI` document. @@ -356,6 +360,8 @@ mod tests { ("/api/policies/{policy_id}", "put"), ("/api/policies/{policy_id}", "delete"), ("/api/roots", "post"), + ("/api/settings/subtitles", "get"), + ("/api/settings/subtitles", "put"), ] { assert!( json["paths"][path][method].is_object(), diff --git a/crates/arr-api/src/subtitle_settings.rs b/crates/arr-api/src/subtitle_settings.rs new file mode 100644 index 0000000..dc9e9e4 --- /dev/null +++ b/crates/arr-api/src/subtitle_settings.rs @@ -0,0 +1,400 @@ +//! 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_no_engines_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()); + assert_eq!(settings["available_engines"], serde_json::json!([])); + } + + #[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); + } + + #[tokio::test] + async fn an_uncompiled_engine_is_a_422_naming_the_field() { + let (_dir, base) = application().await; + let mut payload = valid_input(); + // No `translate-*` feature is enabled by default (arr-subs's + // Cargo.toml), so every named engine is rejected as uncompiled. + payload["translation_engine"] = serde_json::json!("deepl"); + 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}" + ); + } + + #[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); + } +}