feat(arr): expose subtitle settings API
This commit is contained in:
+86
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
Generated
+1
@@ -35,6 +35,7 @@ dependencies = [
|
|||||||
"arr-indexer",
|
"arr-indexer",
|
||||||
"arr-meta",
|
"arr-meta",
|
||||||
"arr-parse",
|
"arr-parse",
|
||||||
|
"arr-subs",
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ arr-db = { workspace = true }
|
|||||||
arr-indexer = { workspace = true }
|
arr-indexer = { workspace = true }
|
||||||
arr-meta = { workspace = true }
|
arr-meta = { workspace = true }
|
||||||
arr-parse = { workspace = true }
|
arr-parse = { workspace = true }
|
||||||
|
arr-subs = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ mod roots;
|
|||||||
mod search;
|
mod search;
|
||||||
mod series;
|
mod series;
|
||||||
mod state;
|
mod state;
|
||||||
|
mod subtitle_settings;
|
||||||
mod trailer;
|
mod trailer;
|
||||||
|
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
@@ -43,6 +44,7 @@ pub use state::{
|
|||||||
AppState, EpisodeCommand, MetadataCommand, MovieCommand, SeasonCommand, Upstreams,
|
AppState, EpisodeCommand, MetadataCommand, MovieCommand, SeasonCommand, Upstreams,
|
||||||
DEFAULT_TMDB_URL,
|
DEFAULT_TMDB_URL,
|
||||||
};
|
};
|
||||||
|
pub use subtitle_settings::{SubtitleSettings, SubtitleSettingsInput};
|
||||||
pub use trailer::{Trailer, TrailerKind};
|
pub use trailer::{Trailer, TrailerKind};
|
||||||
|
|
||||||
/// Where the generated document is served, and where `just gen-client` reads
|
/// 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 = "owners", description = "Owner tags and filtered views (DESIGN.md §4.3)"),
|
||||||
(name = "policies", description = "Quality policies (DESIGN.md §5)"),
|
(name = "policies", description = "Quality policies (DESIGN.md §5)"),
|
||||||
(name = "search", description = "Unified title and release search"),
|
(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;
|
struct ApiDoc;
|
||||||
@@ -115,6 +118,7 @@ fn api_router() -> OpenApiRouter<AppState> {
|
|||||||
.routes(routes!(roots::get, roots::update, roots::delete))
|
.routes(routes!(roots::get, roots::update, roots::delete))
|
||||||
.routes(routes!(policies::list, policies::create))
|
.routes(routes!(policies::list, policies::create))
|
||||||
.routes(routes!(policies::get, policies::update, policies::delete))
|
.routes(routes!(policies::get, policies::update, policies::delete))
|
||||||
|
.routes(routes!(subtitle_settings::get, subtitle_settings::update))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The generated `OpenAPI` document.
|
/// The generated `OpenAPI` document.
|
||||||
@@ -356,6 +360,8 @@ mod tests {
|
|||||||
("/api/policies/{policy_id}", "put"),
|
("/api/policies/{policy_id}", "put"),
|
||||||
("/api/policies/{policy_id}", "delete"),
|
("/api/policies/{policy_id}", "delete"),
|
||||||
("/api/roots", "post"),
|
("/api/roots", "post"),
|
||||||
|
("/api/settings/subtitles", "get"),
|
||||||
|
("/api/settings/subtitles", "put"),
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
json["paths"][path][method].is_object(),
|
json["paths"][path][method].is_object(),
|
||||||
|
|||||||
@@ -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