fix(arr): wire the command timeout to the settings row

This commit is contained in:
Miguel Palhas
2026-08-25 03:15:33 +01:00
parent 47c7ef6682
commit e338a76c66
4 changed files with 180 additions and 18 deletions
+19 -1
View File
@@ -1,7 +1,7 @@
//! What the API needs to answer a request: one HTTP client and the addresses
//! of the three upstreams the service cannot work without (DESIGN.md §3).
use std::sync::Arc;
use std::sync::{atomic::AtomicU64, Arc};
use std::time::Duration;
use arr_db::Db;
@@ -81,6 +81,10 @@ pub struct AppState {
pending_metadata_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MetadataCommand>>>,
subtitle_providers: Arc<Vec<Arc<dyn Provider>>>,
translation_backends: Arc<Vec<Arc<dyn Backend>>>,
/// The remote-command backend's live timeout, in milliseconds (#219).
/// `None` unless the daemon compiled and configured that backend; the
/// settings API writes it on every edit of the row.
command_timeout: Option<Arc<AtomicU64>>,
jellyfin: Option<JellyfinClient>,
syncer: Syncer,
}
@@ -155,6 +159,7 @@ impl AppState {
pending_metadata_commands: Arc::new(tokio::sync::Mutex::new(pending_metadata_commands)),
subtitle_providers: Arc::new(Vec::new()),
translation_backends: Arc::new(Vec::new()),
command_timeout: None,
jellyfin: None,
syncer: Syncer::default(),
})
@@ -191,6 +196,15 @@ impl AppState {
self
}
/// Attach the cell the remote-command backend re-reads per batch (#219),
/// so an edit of `remote_command_timeout_seconds` reaches it without a
/// restart. Absent when that backend is not configured.
#[must_use]
pub fn with_command_timeout(mut self, timeout: Arc<AtomicU64>) -> Self {
self.command_timeout = Some(timeout);
self
}
/// Attach the Jellyfin client, so a manual subtitle grab or translation
/// can trigger the same library refresh import does (§7.5, §15).
#[must_use]
@@ -219,6 +233,10 @@ impl AppState {
.find(|backend| backend.id().as_str() == id)
}
pub(crate) fn command_timeout(&self) -> Option<&Arc<AtomicU64>> {
self.command_timeout.as_ref()
}
/// Attach the `alass` binary this deployment runs (§15). Defaults to
/// resolving `alass` from `PATH`.
#[must_use]
+56 -2
View File
@@ -197,6 +197,7 @@ pub async fn update(
let input = parsed(body)?;
input.validate().map_err(ApiError::Invalid)?;
let columns = input.into_columns()?;
let timeout_seconds = columns.remote_command_timeout_seconds;
sqlx::query!(
r#"UPDATE subtitle_settings SET
wanted_languages = ?, providers_enabled = ?, translation_engine = ?,
@@ -213,6 +214,17 @@ pub async fn update(
)
.execute(pool(&state)?)
.await?;
// Issue #219: the row alone never reaches the running backend. Push it
// into the cell the remote-command translator re-reads per batch; the
// cell counts milliseconds, the row counts seconds.
if let Some(timeout) = state.command_timeout() {
timeout.store(
u64::try_from(timeout_seconds)
.unwrap_or(u64::MAX)
.saturating_mul(1_000),
std::sync::atomic::Ordering::Relaxed,
);
}
Ok(Json(load(&state).await?))
}
@@ -223,24 +235,39 @@ mod tests {
use crate::{router, AppState, Upstreams};
async fn application() -> (tempfile::TempDir, String) {
let (dir, base, _timeout) = application_with_timeout().await;
(dir, base)
}
/// The same app, with the remote-command backend's live timeout cell
/// attached — what the daemon wires up when that backend is configured.
async fn application_with_timeout() -> (
tempfile::TempDir,
String,
std::sync::Arc<std::sync::atomic::AtomicU64>,
) {
use std::sync::{atomic::AtomicU64, Arc};
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 timeout = Arc::new(AtomicU64::new(30_000));
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);
.with_database(database)
.with_command_timeout(timeout.clone());
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}"))
(dir, format!("http://{address}"), timeout)
}
fn valid_input() -> serde_json::Value {
@@ -307,6 +334,33 @@ mod tests {
assert_eq!(refetched, updated);
}
/// Issue #219: the row alone never reaches the running backend, so a PUT
/// must push its value into the cell the command translator re-reads.
#[tokio::test]
async fn a_put_updates_the_live_command_timeout() {
let (_dir, base, timeout) = application_with_timeout().await;
assert_eq!(
timeout.load(std::sync::atomic::Ordering::Relaxed),
30_000,
"seeded from the row at startup"
);
reqwest::Client::new()
.put(format!("{base}/api/settings/subtitles"))
.json(&valid_input())
.send()
.await
.expect("put settings")
.error_for_status()
.expect("valid input accepted");
assert_eq!(
timeout.load(std::sync::atomic::Ordering::Relaxed),
45_000,
"the edited value reaches the running backend"
);
}
/// Whether a known engine is selectable depends on which `translate-*`
/// features this binary was built with, so the test asks the build rather
/// than assuming. With no feature on, every engine is uncompiled and must