From c64c572781af0e60fe624fb4738c0793a60cc137 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 01:31:54 +0100 Subject: [PATCH] refactor(arr): give arr-api its own jellyfin client arr-daemon depends on arr-api, so a handler in arr-api can never reach the daemon's private JellyfinClient. Move it into arr-api and attach an instance to AppState, so a manual subtitle write can ask for the same refresh import already does (#195). --- Cargo.lock | 1 + crates/arr-api/Cargo.toml | 1 + .../{arr-daemon => arr-api}/src/jellyfin.rs | 16 +++++++++---- crates/arr-api/src/lib.rs | 1 + crates/arr-api/src/state.rs | 16 +++++++++++++ crates/arr-daemon/src/import.rs | 2 +- crates/arr-daemon/src/main.rs | 23 +++++++++++++------ 7 files changed, 48 insertions(+), 12 deletions(-) rename crates/{arr-daemon => arr-api}/src/jellyfin.rs (85%) diff --git a/Cargo.lock b/Cargo.lock index 1042d70..78bdfe6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,7 @@ dependencies = [ "serde_json", "sqlx", "tempfile", + "thiserror", "tokio", "tracing", "utoipa", diff --git a/crates/arr-api/Cargo.toml b/crates/arr-api/Cargo.toml index 686ed94..b5da491 100644 --- a/crates/arr-api/Cargo.toml +++ b/crates/arr-api/Cargo.toml @@ -18,6 +18,7 @@ reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sqlx = { workspace = true } +thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } utoipa = { workspace = true } diff --git a/crates/arr-daemon/src/jellyfin.rs b/crates/arr-api/src/jellyfin.rs similarity index 85% rename from crates/arr-daemon/src/jellyfin.rs rename to crates/arr-api/src/jellyfin.rs index dde3f47..e064d9a 100644 --- a/crates/arr-daemon/src/jellyfin.rs +++ b/crates/arr-api/src/jellyfin.rs @@ -1,8 +1,9 @@ -//! Jellyfin library refresh after import. See DESIGN.md §7.5. +//! Jellyfin library refresh after import or a subtitle write. See DESIGN.md +//! §7.5 and §15. //! -//! Jellyfin's own filesystem watcher misses the hardlinked file, so import -//! asks it to rescan directly. A refresh failure must not fail the import — -//! the caller logs and continues (§7.5). +//! Jellyfin's own filesystem watcher misses a hardlinked or sidecar file, so +//! callers ask it to rescan directly. A refresh failure must not fail the +//! caller — it logs and continues (§7.5). use std::time::Duration; @@ -27,6 +28,9 @@ pub struct JellyfinClient { } impl JellyfinClient { + /// # Errors + /// + /// If the underlying HTTP client cannot be built. pub fn new(base_url: impl Into, api_key: Option) -> Result { let client = Client::builder().timeout(REQUEST_TIMEOUT).build()?; Ok(Self { @@ -38,6 +42,10 @@ impl JellyfinClient { /// Trigger a full library scan. Jellyfin exposes no per-library refresh /// without knowing that library's ID, which this app never learns. + /// + /// # Errors + /// + /// If the request fails, or Jellyfin answers with a non-success status. pub async fn refresh(&self) -> Result<(), Error> { let url = format!("{}/Library/Refresh", self.base_url.trim_end_matches('/')); let mut request = self.client.post(url); diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 38187b4..d42a89b 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -7,6 +7,7 @@ //! to compile, and the gate in DESIGN.md §12 fails with it. mod health; +pub mod jellyfin; mod metadata; mod movies; mod owners; diff --git a/crates/arr-api/src/state.rs b/crates/arr-api/src/state.rs index de8c4c0..e534120 100644 --- a/crates/arr-api/src/state.rs +++ b/crates/arr-api/src/state.rs @@ -7,6 +7,8 @@ use std::time::Duration; use arr_db::Db; use tokio::sync::mpsc; +use crate::jellyfin::JellyfinClient; + /// The TMDB API root. Not a bootstrap setting (DESIGN.md §10) — only the key /// is configurable, so this is a constant that tests point elsewhere. pub const DEFAULT_TMDB_URL: &str = "https://api.themoviedb.org/3"; @@ -76,6 +78,7 @@ pub struct AppState { pending_season_commands: Arc>>, metadata_commands: mpsc::Sender, pending_metadata_commands: Arc>>, + jellyfin: Option, } /// Work explicitly requested through the movie API. @@ -146,6 +149,7 @@ impl AppState { pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)), metadata_commands, pending_metadata_commands: Arc::new(tokio::sync::Mutex::new(pending_metadata_commands)), + jellyfin: None, }) } @@ -156,6 +160,18 @@ impl AppState { 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] + pub fn with_jellyfin(mut self, jellyfin: JellyfinClient) -> Self { + self.jellyfin = Some(jellyfin); + self + } + + pub(crate) fn jellyfin(&self) -> Option<&JellyfinClient> { + self.jellyfin.as_ref() + } + /// Wait for the next manual movie action in the daemon's reconcile loop. /// /// # Errors diff --git a/crates/arr-daemon/src/import.rs b/crates/arr-daemon/src/import.rs index 80db79a..ebc6109 100644 --- a/crates/arr-daemon/src/import.rs +++ b/crates/arr-daemon/src/import.rs @@ -25,9 +25,9 @@ use arr_db::Db; use arr_dl::TransmissionClient; use arr_probe::Prober; -use crate::jellyfin::JellyfinClient; use crate::notify::Notifier; use crate::reconcile::{Action, ActionFuture, Outcome}; +use arr_api::jellyfin::JellyfinClient; /// A failure during one import tick. #[derive(Debug, thiserror::Error)] diff --git a/crates/arr-daemon/src/main.rs b/crates/arr-daemon/src/main.rs index 34c45c9..5264b36 100644 --- a/crates/arr-daemon/src/main.rs +++ b/crates/arr-daemon/src/main.rs @@ -6,7 +6,6 @@ mod config; mod grab; mod import; mod indexers; -mod jellyfin; mod manual; mod metadata; mod notify; @@ -94,7 +93,7 @@ enum Error { #[error("transmission client: {0}")] Transmission(#[from] arr_dl::Error), #[error("jellyfin client: {0}")] - Jellyfin(#[from] jellyfin::Error), + Jellyfin(#[from] arr_api::jellyfin::Error), #[error("ntfy client: {0}")] Notify(#[from] notify::NotifyError), #[error("bind {addr}: {source}")] @@ -124,6 +123,7 @@ async fn run() -> Result<(), Error> { None }; let notifier = Notifier::new(config.ntfy_url.clone())?; + let api_jellyfin = jellyfin_client(&config)?; let (reconcile, manual_grab, manual_tv) = reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), ¬ifier)?; // Issue #176: the on-demand half of the metadata lane needs its own @@ -144,7 +144,9 @@ async fn run() -> Result<(), Error> { if let Some(tmdb_url) = config.tmdb_url { upstreams = upstreams.with_tmdb_url(tmdb_url); } - let state = AppState::new(upstreams)?.with_database(database.clone()); + let state = AppState::new(upstreams)? + .with_database(database.clone()) + .with_jellyfin(api_jellyfin); let app = arr_api::router(state.clone()) .merge(arr_compat::router(compat)) @@ -294,10 +296,7 @@ fn reconcile_loop( } else { tracing::warn!("TMDB is not configured: series metadata refresh is disabled"); } - let jellyfin = jellyfin::JellyfinClient::new( - config.jellyfin_url.clone(), - config.jellyfin_api_key.clone(), - )?; + let jellyfin = jellyfin_client(config)?; // Grab before import, so a download that completes on this tick is // imported on this tick. reconcile = reconcile.register( @@ -443,3 +442,13 @@ async fn shutdown() { tracing::info!("shutting down"); } + +/// The Jellyfin client, built fresh for each of its two independent callers: +/// import's own reconcile action, and the subtitle API's manual grab and +/// translate handlers (§7.5, §15). +fn jellyfin_client(config: &Config) -> Result { + Ok(arr_api::jellyfin::JellyfinClient::new( + config.jellyfin_url.clone(), + config.jellyfin_api_key.clone(), + )?) +}