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).
This commit is contained in:
Miguel Palhas
2026-08-25 01:31:54 +01:00
parent 8c3e1c4a92
commit c64c572781
7 changed files with 48 additions and 12 deletions
Generated
+1
View File
@@ -42,6 +42,7 @@ dependencies = [
"serde_json", "serde_json",
"sqlx", "sqlx",
"tempfile", "tempfile",
"thiserror",
"tokio", "tokio",
"tracing", "tracing",
"utoipa", "utoipa",
+1
View File
@@ -18,6 +18,7 @@ reqwest = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
utoipa = { workspace = true } utoipa = { workspace = true }
@@ -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 //! Jellyfin's own filesystem watcher misses a hardlinked or sidecar file, so
//! asks it to rescan directly. A refresh failure must not fail the import — //! callers ask it to rescan directly. A refresh failure must not fail the
//! the caller logs and continues (§7.5). //! caller — it logs and continues (§7.5).
use std::time::Duration; use std::time::Duration;
@@ -27,6 +28,9 @@ pub struct JellyfinClient {
} }
impl JellyfinClient { impl JellyfinClient {
/// # Errors
///
/// If the underlying HTTP client cannot be built.
pub fn new(base_url: impl Into<String>, api_key: Option<String>) -> Result<Self, Error> { pub fn new(base_url: impl Into<String>, api_key: Option<String>) -> Result<Self, Error> {
let client = Client::builder().timeout(REQUEST_TIMEOUT).build()?; let client = Client::builder().timeout(REQUEST_TIMEOUT).build()?;
Ok(Self { Ok(Self {
@@ -38,6 +42,10 @@ impl JellyfinClient {
/// Trigger a full library scan. Jellyfin exposes no per-library refresh /// Trigger a full library scan. Jellyfin exposes no per-library refresh
/// without knowing that library's ID, which this app never learns. /// 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> { pub async fn refresh(&self) -> Result<(), Error> {
let url = format!("{}/Library/Refresh", self.base_url.trim_end_matches('/')); let url = format!("{}/Library/Refresh", self.base_url.trim_end_matches('/'));
let mut request = self.client.post(url); let mut request = self.client.post(url);
+1
View File
@@ -7,6 +7,7 @@
//! to compile, and the gate in DESIGN.md §12 fails with it. //! to compile, and the gate in DESIGN.md §12 fails with it.
mod health; mod health;
pub mod jellyfin;
mod metadata; mod metadata;
mod movies; mod movies;
mod owners; mod owners;
+16
View File
@@ -7,6 +7,8 @@ use std::time::Duration;
use arr_db::Db; use arr_db::Db;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::jellyfin::JellyfinClient;
/// The TMDB API root. Not a bootstrap setting (DESIGN.md §10) — only the key /// 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. /// is configurable, so this is a constant that tests point elsewhere.
pub const DEFAULT_TMDB_URL: &str = "https://api.themoviedb.org/3"; pub const DEFAULT_TMDB_URL: &str = "https://api.themoviedb.org/3";
@@ -76,6 +78,7 @@ pub struct AppState {
pending_season_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>, pending_season_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>,
metadata_commands: mpsc::Sender<MetadataCommand>, metadata_commands: mpsc::Sender<MetadataCommand>,
pending_metadata_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MetadataCommand>>>, pending_metadata_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MetadataCommand>>>,
jellyfin: Option<JellyfinClient>,
} }
/// Work explicitly requested through the movie API. /// 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)), pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)),
metadata_commands, metadata_commands,
pending_metadata_commands: Arc::new(tokio::sync::Mutex::new(pending_metadata_commands)), pending_metadata_commands: Arc::new(tokio::sync::Mutex::new(pending_metadata_commands)),
jellyfin: None,
}) })
} }
@@ -156,6 +160,18 @@ impl AppState {
self 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. /// Wait for the next manual movie action in the daemon's reconcile loop.
/// ///
/// # Errors /// # Errors
+1 -1
View File
@@ -25,9 +25,9 @@ use arr_db::Db;
use arr_dl::TransmissionClient; use arr_dl::TransmissionClient;
use arr_probe::Prober; use arr_probe::Prober;
use crate::jellyfin::JellyfinClient;
use crate::notify::Notifier; use crate::notify::Notifier;
use crate::reconcile::{Action, ActionFuture, Outcome}; use crate::reconcile::{Action, ActionFuture, Outcome};
use arr_api::jellyfin::JellyfinClient;
/// A failure during one import tick. /// A failure during one import tick.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
+16 -7
View File
@@ -6,7 +6,6 @@ mod config;
mod grab; mod grab;
mod import; mod import;
mod indexers; mod indexers;
mod jellyfin;
mod manual; mod manual;
mod metadata; mod metadata;
mod notify; mod notify;
@@ -94,7 +93,7 @@ enum Error {
#[error("transmission client: {0}")] #[error("transmission client: {0}")]
Transmission(#[from] arr_dl::Error), Transmission(#[from] arr_dl::Error),
#[error("jellyfin client: {0}")] #[error("jellyfin client: {0}")]
Jellyfin(#[from] jellyfin::Error), Jellyfin(#[from] arr_api::jellyfin::Error),
#[error("ntfy client: {0}")] #[error("ntfy client: {0}")]
Notify(#[from] notify::NotifyError), Notify(#[from] notify::NotifyError),
#[error("bind {addr}: {source}")] #[error("bind {addr}: {source}")]
@@ -124,6 +123,7 @@ async fn run() -> Result<(), Error> {
None None
}; };
let notifier = Notifier::new(config.ntfy_url.clone())?; let notifier = Notifier::new(config.ntfy_url.clone())?;
let api_jellyfin = jellyfin_client(&config)?;
let (reconcile, manual_grab, manual_tv) = let (reconcile, manual_grab, manual_tv) =
reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), &notifier)?; reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), &notifier)?;
// Issue #176: the on-demand half of the metadata lane needs its own // 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 { if let Some(tmdb_url) = config.tmdb_url {
upstreams = upstreams.with_tmdb_url(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()) let app = arr_api::router(state.clone())
.merge(arr_compat::router(compat)) .merge(arr_compat::router(compat))
@@ -294,10 +296,7 @@ fn reconcile_loop(
} else { } else {
tracing::warn!("TMDB is not configured: series metadata refresh is disabled"); tracing::warn!("TMDB is not configured: series metadata refresh is disabled");
} }
let jellyfin = jellyfin::JellyfinClient::new( let jellyfin = jellyfin_client(config)?;
config.jellyfin_url.clone(),
config.jellyfin_api_key.clone(),
)?;
// Grab before import, so a download that completes on this tick is // Grab before import, so a download that completes on this tick is
// imported on this tick. // imported on this tick.
reconcile = reconcile.register( reconcile = reconcile.register(
@@ -443,3 +442,13 @@ async fn shutdown() {
tracing::info!("shutting down"); 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<arr_api::jellyfin::JellyfinClient, Error> {
Ok(arr_api::jellyfin::JellyfinClient::new(
config.jellyfin_url.clone(),
config.jellyfin_api_key.clone(),
)?)
}