+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-meta",
|
||||
"arr-parse",
|
||||
"arr-subs",
|
||||
"axum",
|
||||
"chrono",
|
||||
"reqwest",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<AppState> {
|
||||
.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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,23 @@ pub const ENV_JELLYFIN_URL: &str = "ARR_JELLYFIN_URL";
|
||||
pub const ENV_JELLYFIN_API_KEY: &str = "ARR_JELLYFIN_API_KEY";
|
||||
pub const ENV_NTFY_URL: &str = "ARR_NTFY_URL";
|
||||
pub const ENV_NTFY_OPERATOR_TOPIC: &str = "ARR_NTFY_OPERATOR_TOPIC";
|
||||
// DESIGN.md §15 subtitle bootstrap. Credentials are secrets (env-only,
|
||||
// below); base URLs, the remote-command template and the binary paths may
|
||||
// also sit in the config file.
|
||||
pub const ENV_OPENSUBTITLES_API_KEY: &str = "ARR_OPENSUBTITLES_API_KEY";
|
||||
pub const ENV_OPENSUBTITLES_USERNAME: &str = "ARR_OPENSUBTITLES_USERNAME";
|
||||
pub const ENV_OPENSUBTITLES_PASSWORD: &str = "ARR_OPENSUBTITLES_PASSWORD";
|
||||
pub const ENV_PODNAPISI_USERNAME: &str = "ARR_PODNAPISI_USERNAME";
|
||||
pub const ENV_PODNAPISI_PASSWORD: &str = "ARR_PODNAPISI_PASSWORD";
|
||||
pub const ENV_TRANSLATE_OPENAI_API_KEY: &str = "ARR_TRANSLATE_OPENAI_API_KEY";
|
||||
pub const ENV_TRANSLATE_OPENAI_BASE_URL: &str = "ARR_TRANSLATE_OPENAI_BASE_URL";
|
||||
pub const ENV_TRANSLATE_DEEPL_API_KEY: &str = "ARR_TRANSLATE_DEEPL_API_KEY";
|
||||
pub const ENV_TRANSLATE_DEEPL_BASE_URL: &str = "ARR_TRANSLATE_DEEPL_BASE_URL";
|
||||
pub const ENV_TRANSLATE_GOOGLE_API_KEY: &str = "ARR_TRANSLATE_GOOGLE_API_KEY";
|
||||
pub const ENV_TRANSLATE_GOOGLE_BASE_URL: &str = "ARR_TRANSLATE_GOOGLE_BASE_URL";
|
||||
pub const ENV_TRANSLATE_COMMAND_TEMPLATE: &str = "ARR_TRANSLATE_COMMAND_TEMPLATE";
|
||||
pub const ENV_ALASS_PATH: &str = "ARR_ALASS_PATH";
|
||||
pub const ENV_FFMPEG_PATH: &str = "ARR_FFMPEG_PATH";
|
||||
|
||||
pub const DEFAULT_BIND_ADDR: &str = "0.0.0.0:7878";
|
||||
pub const DEFAULT_DATABASE_PATH: &str = "arr.db";
|
||||
@@ -45,6 +62,8 @@ pub const DEFAULT_SEED_RATIO_LIMIT: f64 = 1.0;
|
||||
pub const DEFAULT_SEED_IDLE_LIMIT_MINUTES: u64 = 4320;
|
||||
pub const DEFAULT_JELLYFIN_URL: &str = "http://localhost:8096";
|
||||
pub const DEFAULT_NTFY_URL: &str = "http://localhost";
|
||||
pub const DEFAULT_ALASS_PATH: &str = "alass";
|
||||
pub const DEFAULT_FFMPEG_PATH: &str = "ffmpeg";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
@@ -92,6 +111,18 @@ struct ConfigFile {
|
||||
ntfy_url: Option<String>,
|
||||
#[serde(default)]
|
||||
ntfy_operator_topic: Option<String>,
|
||||
#[serde(default)]
|
||||
translate_openai_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
translate_deepl_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
translate_google_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
translate_command_template: Option<String>,
|
||||
#[serde(default)]
|
||||
alass_path: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
ffmpeg_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
|
||||
@@ -131,6 +162,20 @@ pub struct EnvOverrides {
|
||||
pub jellyfin_api_key: Option<String>,
|
||||
pub ntfy_url: Option<String>,
|
||||
pub ntfy_operator_topic: Option<String>,
|
||||
pub opensubtitles_api_key: Option<String>,
|
||||
pub opensubtitles_username: Option<String>,
|
||||
pub opensubtitles_password: Option<String>,
|
||||
pub podnapisi_username: Option<String>,
|
||||
pub podnapisi_password: Option<String>,
|
||||
pub translate_openai_api_key: Option<String>,
|
||||
pub translate_openai_base_url: Option<String>,
|
||||
pub translate_deepl_api_key: Option<String>,
|
||||
pub translate_deepl_base_url: Option<String>,
|
||||
pub translate_google_api_key: Option<String>,
|
||||
pub translate_google_base_url: Option<String>,
|
||||
pub translate_command_template: Option<String>,
|
||||
pub alass_path: Option<String>,
|
||||
pub ffmpeg_path: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvOverrides {
|
||||
@@ -152,6 +197,20 @@ impl EnvOverrides {
|
||||
jellyfin_api_key: std::env::var(ENV_JELLYFIN_API_KEY).ok(),
|
||||
ntfy_url: std::env::var(ENV_NTFY_URL).ok(),
|
||||
ntfy_operator_topic: std::env::var(ENV_NTFY_OPERATOR_TOPIC).ok(),
|
||||
opensubtitles_api_key: std::env::var(ENV_OPENSUBTITLES_API_KEY).ok(),
|
||||
opensubtitles_username: std::env::var(ENV_OPENSUBTITLES_USERNAME).ok(),
|
||||
opensubtitles_password: std::env::var(ENV_OPENSUBTITLES_PASSWORD).ok(),
|
||||
podnapisi_username: std::env::var(ENV_PODNAPISI_USERNAME).ok(),
|
||||
podnapisi_password: std::env::var(ENV_PODNAPISI_PASSWORD).ok(),
|
||||
translate_openai_api_key: std::env::var(ENV_TRANSLATE_OPENAI_API_KEY).ok(),
|
||||
translate_openai_base_url: std::env::var(ENV_TRANSLATE_OPENAI_BASE_URL).ok(),
|
||||
translate_deepl_api_key: std::env::var(ENV_TRANSLATE_DEEPL_API_KEY).ok(),
|
||||
translate_deepl_base_url: std::env::var(ENV_TRANSLATE_DEEPL_BASE_URL).ok(),
|
||||
translate_google_api_key: std::env::var(ENV_TRANSLATE_GOOGLE_API_KEY).ok(),
|
||||
translate_google_base_url: std::env::var(ENV_TRANSLATE_GOOGLE_BASE_URL).ok(),
|
||||
translate_command_template: std::env::var(ENV_TRANSLATE_COMMAND_TEMPLATE).ok(),
|
||||
alass_path: std::env::var(ENV_ALASS_PATH).ok(),
|
||||
ffmpeg_path: std::env::var(ENV_FFMPEG_PATH).ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,6 +241,31 @@ pub struct Config {
|
||||
/// *broken*. `None` means those two notifications are skipped — there is
|
||||
/// no sensible default topic name to fall back to.
|
||||
pub ntfy_operator_topic: Option<String>,
|
||||
/// §15. `None` means OpenSubtitles.com search runs unauthenticated,
|
||||
/// which its API allows at a lower rate.
|
||||
pub opensubtitles_api_key: Option<String>,
|
||||
pub opensubtitles_username: Option<String>,
|
||||
pub opensubtitles_password: Option<String>,
|
||||
pub podnapisi_username: Option<String>,
|
||||
pub podnapisi_password: Option<String>,
|
||||
pub translate_openai_api_key: Option<String>,
|
||||
/// §15. No default: an OpenAI-compatible endpoint has no universal
|
||||
/// address the way `DeepL` or Google Translate do.
|
||||
pub translate_openai_base_url: Option<String>,
|
||||
pub translate_deepl_api_key: Option<String>,
|
||||
/// `None` means the backend's own built-in default when it lands (#192).
|
||||
pub translate_deepl_base_url: Option<String>,
|
||||
pub translate_google_api_key: Option<String>,
|
||||
/// `None` means the backend's own built-in default when it lands (#193).
|
||||
pub translate_google_base_url: Option<String>,
|
||||
/// §15. The generic remote-command backend's invocation template, e.g.
|
||||
/// `ssh box claude -p`. `None` means that backend is unconfigured.
|
||||
pub translate_command_template: Option<String>,
|
||||
/// §15. `alass` runs on every fetched and every translated subtitle; a
|
||||
/// bare name resolves through `PATH`, matching `arr-probe`'s `ffprobe`.
|
||||
pub alass_path: PathBuf,
|
||||
/// §15. Extracts text-format embedded tracks to sidecar SRTs.
|
||||
pub ffmpeg_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -263,6 +347,36 @@ impl Config {
|
||||
.or(file.ntfy_url)
|
||||
.unwrap_or_else(|| DEFAULT_NTFY_URL.to_string()),
|
||||
ntfy_operator_topic: env.ntfy_operator_topic.or(file.ntfy_operator_topic),
|
||||
opensubtitles_api_key: env.opensubtitles_api_key,
|
||||
opensubtitles_username: env.opensubtitles_username,
|
||||
opensubtitles_password: env.opensubtitles_password,
|
||||
podnapisi_username: env.podnapisi_username,
|
||||
podnapisi_password: env.podnapisi_password,
|
||||
translate_openai_api_key: env.translate_openai_api_key,
|
||||
translate_openai_base_url: env
|
||||
.translate_openai_base_url
|
||||
.or(file.translate_openai_base_url),
|
||||
translate_deepl_api_key: env.translate_deepl_api_key,
|
||||
translate_deepl_base_url: env
|
||||
.translate_deepl_base_url
|
||||
.or(file.translate_deepl_base_url),
|
||||
translate_google_api_key: env.translate_google_api_key,
|
||||
translate_google_base_url: env
|
||||
.translate_google_base_url
|
||||
.or(file.translate_google_base_url),
|
||||
translate_command_template: env
|
||||
.translate_command_template
|
||||
.or(file.translate_command_template),
|
||||
alass_path: env
|
||||
.alass_path
|
||||
.map(PathBuf::from)
|
||||
.or(file.alass_path)
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_ALASS_PATH)),
|
||||
ffmpeg_path: env
|
||||
.ffmpeg_path
|
||||
.map(PathBuf::from)
|
||||
.or(file.ffmpeg_path)
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_FFMPEG_PATH)),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -308,6 +422,11 @@ mod tests {
|
||||
assert_eq!(config.prowlarr_api_key, None);
|
||||
assert_eq!(config.tmdb_api_key, None);
|
||||
assert_eq!(config.jellyfin_api_key, None);
|
||||
assert_eq!(config.opensubtitles_api_key, None);
|
||||
assert_eq!(config.podnapisi_username, None);
|
||||
assert_eq!(config.translate_command_template, None);
|
||||
assert_eq!(config.alass_path, PathBuf::from(DEFAULT_ALASS_PATH));
|
||||
assert_eq!(config.ffmpeg_path, PathBuf::from(DEFAULT_FFMPEG_PATH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -376,6 +495,88 @@ prowlarr_url = "http://prowlarr.internal:9696"
|
||||
assert_eq!(config.jellyfin_api_key.as_deref(), Some("secret-3"));
|
||||
}
|
||||
|
||||
/// §15: `OpenSubtitles`, Podnapisi and every translator's credentials are
|
||||
/// secrets, so this mirrors [`secrets_come_only_from_env`] for them.
|
||||
#[test]
|
||||
fn subtitle_secrets_come_only_from_env() {
|
||||
let env = EnvOverrides {
|
||||
opensubtitles_api_key: Some("os-key".into()),
|
||||
opensubtitles_username: Some("os-user".into()),
|
||||
opensubtitles_password: Some("os-pass".into()),
|
||||
podnapisi_username: Some("pod-user".into()),
|
||||
podnapisi_password: Some("pod-pass".into()),
|
||||
translate_openai_api_key: Some("oa-key".into()),
|
||||
translate_deepl_api_key: Some("dl-key".into()),
|
||||
translate_google_api_key: Some("gg-key".into()),
|
||||
..EnvOverrides::default()
|
||||
};
|
||||
let config = Config::resolve(env).unwrap();
|
||||
assert_eq!(config.opensubtitles_api_key.as_deref(), Some("os-key"));
|
||||
assert_eq!(config.opensubtitles_username.as_deref(), Some("os-user"));
|
||||
assert_eq!(config.opensubtitles_password.as_deref(), Some("os-pass"));
|
||||
assert_eq!(config.podnapisi_username.as_deref(), Some("pod-user"));
|
||||
assert_eq!(config.podnapisi_password.as_deref(), Some("pod-pass"));
|
||||
assert_eq!(config.translate_openai_api_key.as_deref(), Some("oa-key"));
|
||||
assert_eq!(config.translate_deepl_api_key.as_deref(), Some("dl-key"));
|
||||
assert_eq!(config.translate_google_api_key.as_deref(), Some("gg-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subtitle_secret_in_the_config_file_is_a_parse_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("arr.toml");
|
||||
std::fs::write(&path, "opensubtitles_api_key = \"leaked\"\n").unwrap();
|
||||
let env = EnvOverrides {
|
||||
config_file: Some(path.to_string_lossy().into_owned()),
|
||||
..EnvOverrides::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
Config::resolve(env),
|
||||
Err(ConfigError::TomlDecode(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// §15: base URLs, the remote-command template and the binary paths are
|
||||
/// not secrets, so the file, the environment and their precedence all
|
||||
/// apply the same way they do for `jellyfin_url` and friends.
|
||||
#[test]
|
||||
fn subtitle_non_secret_bootstrap_comes_from_file_or_env() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("arr.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
translate_openai_base_url = "http://llm.internal/v1"
|
||||
translate_command_template = "ssh box claude -p"
|
||||
alass_path = "/usr/local/bin/alass"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let env = EnvOverrides {
|
||||
config_file: Some(path.to_string_lossy().into_owned()),
|
||||
..EnvOverrides::default()
|
||||
};
|
||||
let config = Config::resolve(env.clone()).unwrap();
|
||||
assert_eq!(
|
||||
config.translate_openai_base_url.as_deref(),
|
||||
Some("http://llm.internal/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
config.translate_command_template.as_deref(),
|
||||
Some("ssh box claude -p")
|
||||
);
|
||||
assert_eq!(config.alass_path, PathBuf::from("/usr/local/bin/alass"));
|
||||
// ffmpeg_path was not set anywhere, so it still defaults.
|
||||
assert_eq!(config.ffmpeg_path, PathBuf::from(DEFAULT_FFMPEG_PATH));
|
||||
|
||||
let config = Config::resolve(EnvOverrides {
|
||||
ffmpeg_path: Some("/opt/bin/ffmpeg".into()),
|
||||
..env
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(config.ffmpeg_path, PathBuf::from("/opt/bin/ffmpeg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmdb_url_is_an_env_only_seam() {
|
||||
let config = Config::resolve(EnvOverrides::default()).unwrap();
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
-- #198. The runtime-editable half of subtitle configuration (DESIGN.md §15,
|
||||
-- §10). The other half — provider credentials, translator keys, the
|
||||
-- remote-command template, the alass and ffmpeg binary paths — is
|
||||
-- config/env only and never reaches this table.
|
||||
--
|
||||
-- One row: the wanted set, enabled providers and translation engine are
|
||||
-- global, not per root (§15), so there is nothing to key this on.
|
||||
|
||||
CREATE TABLE subtitle_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
-- Language tags as `arr_core::Language` spells them, e.g. `pt-PT`, `en`.
|
||||
wanted_languages TEXT NOT NULL DEFAULT '["pt-PT","en"]'
|
||||
CHECK (json_valid(wanted_languages)),
|
||||
-- Provider ids in search order (DESIGN.md §15's ranking runs per
|
||||
-- provider before it runs per candidate). Absent from the array means
|
||||
-- disabled.
|
||||
providers_enabled TEXT NOT NULL DEFAULT '["opensubtitles","podnapisi"]'
|
||||
CHECK (json_valid(providers_enabled)),
|
||||
-- The compiled-in backend in use, or NULL when translation is off. Not
|
||||
-- checked against the running binary's features here — the API does
|
||||
-- that (#198) — so a rebuild with a different feature set never leaves
|
||||
-- an unreadable row.
|
||||
translation_engine TEXT
|
||||
CHECK (translation_engine IN ('openai', 'deepl', 'google', 'command')),
|
||||
-- Provider id / engine name -> a daily allowance (§15's token buckets,
|
||||
-- #197). A missing key means no allowance configured yet, not zero.
|
||||
provider_daily_budgets TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(provider_daily_budgets)),
|
||||
translator_daily_budgets TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(translator_daily_budgets)),
|
||||
-- Only the remote-command backend needs one of these: the HTTP backends
|
||||
-- carry their own client timeout.
|
||||
remote_command_timeout_seconds INTEGER NOT NULL DEFAULT 30
|
||||
CHECK (remote_command_timeout_seconds > 0),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
) STRICT;
|
||||
|
||||
INSERT INTO subtitle_settings (id) VALUES (1);
|
||||
@@ -32,6 +32,33 @@ pub type SearchFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<Candidate>>>
|
||||
/// One downloaded subtitle.
|
||||
pub type DownloadFuture<'a> = Pin<Box<dyn Future<Output = Result<Fetched>> + Send + 'a>>;
|
||||
|
||||
/// Translation engine names DESIGN.md §15 recognises, whether or not this
|
||||
/// binary has the matching cargo feature compiled in.
|
||||
pub const ENGINES: &[&str] = &["openai", "deepl", "google", "command"];
|
||||
|
||||
/// Which of [`ENGINES`] this binary actually compiled in.
|
||||
///
|
||||
/// A backend whose feature is not compiled in must not be selectable
|
||||
/// (DESIGN.md §15, issue #198): the settings API checks a requested engine
|
||||
/// against this list before it ever reaches the database.
|
||||
#[must_use]
|
||||
pub fn compiled_engines() -> Vec<&'static str> {
|
||||
let mut engines = Vec::new();
|
||||
if cfg!(feature = "translate-openai") {
|
||||
engines.push("openai");
|
||||
}
|
||||
if cfg!(feature = "translate-deepl") {
|
||||
engines.push("deepl");
|
||||
}
|
||||
if cfg!(feature = "translate-google") {
|
||||
engines.push("google");
|
||||
}
|
||||
if cfg!(feature = "translate-command") {
|
||||
engines.push("command");
|
||||
}
|
||||
engines
|
||||
}
|
||||
|
||||
/// One subtitle source: OpenSubtitles.com, Podnapisi (DESIGN.md §15).
|
||||
///
|
||||
/// Boxed futures rather than `async fn`, so that the trait stays object safe
|
||||
@@ -158,6 +185,16 @@ mod tests {
|
||||
assert_eq!(candidates[0].language, Language::PortuguesePortugal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_engine_is_compiled_in_by_default() {
|
||||
// The default feature set is empty (Cargo.toml), matching #198's
|
||||
// scope: the backends themselves are later issues.
|
||||
assert!(super::compiled_engines().is_empty());
|
||||
for engine in super::compiled_engines() {
|
||||
assert!(super::ENGINES.contains(&engine));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_candidate_id_round_trips_to_its_download() {
|
||||
let provider = StubProvider;
|
||||
|
||||
Reference in New Issue
Block a user