feat(arr): expose subtitle settings API

This commit is contained in:
Miguel Palhas
2026-08-24 22:28:31 +01:00
parent ed1a74e03f
commit 802d2a1208
6 changed files with 507 additions and 1 deletions
+400
View File
@@ -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<String>,
pub providers_enabled: Vec<String>,
pub translation_engine: Option<String>,
pub provider_daily_budgets: BTreeMap<String, u32>,
pub translator_daily_budgets: BTreeMap<String, u32>,
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<String>,
}
/// 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<String>,
pub providers_enabled: Vec<String>,
pub translation_engine: Option<String>,
#[serde(default)]
pub provider_daily_budgets: BTreeMap<String, u32>,
#[serde(default)]
pub translator_daily_budgets: BTreeMap<String, u32>,
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<SettingsColumns, ApiError> {
fn json(value: impl serde::Serialize) -> Result<String, ApiError> {
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<String>,
provider_daily_budgets: String,
translator_daily_budgets: String,
remote_command_timeout_seconds: i64,
}
fn column<T: serde::de::DeserializeOwned>(
column: &'static str,
value: &str,
) -> Result<T, ApiError> {
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<SubtitleSettings, ApiError> {
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<SubtitleSettings, ApiError> {
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<AppState>) -> Result<Json<SubtitleSettings>, 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<AppState>,
body: Result<Json<SubtitleSettingsInput>, JsonRejection>,
) -> Result<Json<SubtitleSettings>, 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);
}
}