feat(arr): expose subtitle settings API
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user