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 c6cfdff2c9
commit 910d28f639
7 changed files with 47 additions and 12 deletions
+1 -1
View File
@@ -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)]
-98
View File
@@ -1,98 +0,0 @@
//! Jellyfin library refresh after import. See DESIGN.md §7.5.
//!
//! 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).
use std::time::Duration;
use reqwest::{Client, StatusCode};
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("request: {0}")]
Request(#[from] reqwest::Error),
#[error("jellyfin returned {0}")]
Status(StatusCode),
}
/// A client for the one Jellyfin call this app makes.
#[derive(Debug, Clone)]
pub struct JellyfinClient {
client: Client,
base_url: String,
api_key: Option<String>,
}
impl JellyfinClient {
pub fn new(base_url: impl Into<String>, api_key: Option<String>) -> Result<Self, Error> {
let client = Client::builder().timeout(REQUEST_TIMEOUT).build()?;
Ok(Self {
client,
base_url: base_url.into(),
api_key,
})
}
/// Trigger a full library scan. Jellyfin exposes no per-library refresh
/// without knowing that library's ID, which this app never learns.
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);
if let Some(key) = &self.api_key {
request = request.header("X-Emby-Token", key);
}
let response = request.send().await?;
if !response.status().is_success() {
return Err(Error::Status(response.status()));
}
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
#[tokio::test]
async fn refresh_posts_to_the_library_refresh_endpoint_with_the_api_key() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/Library/Refresh"))
.and(header("X-Emby-Token", "secret"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = JellyfinClient::new(server.uri(), Some("secret".to_string())).unwrap();
client.refresh().await.unwrap();
}
#[tokio::test]
async fn a_non_success_status_is_an_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/Library/Refresh"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let client = JellyfinClient::new(server.uri(), None).unwrap();
assert!(matches!(client.refresh().await, Err(Error::Status(_))));
}
#[tokio::test]
async fn an_unreachable_jellyfin_is_an_error_the_caller_can_swallow() {
let client = JellyfinClient::new("http://127.0.0.1:1", None).unwrap();
assert!(matches!(client.refresh().await, Err(Error::Request(_))));
}
}
+15 -7
View File
@@ -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(), &notifier)?;
// Issue #176: the on-demand half of the metadata lane needs its own
@@ -150,7 +150,8 @@ async fn run() -> Result<(), Error> {
config.opensubtitles_api_key,
config.opensubtitles_username,
config.opensubtitles_password,
));
))
.with_jellyfin(api_jellyfin);
let app = arr_api::router(state.clone())
.merge(arr_compat::router(compat))
@@ -300,10 +301,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(
@@ -450,6 +448,16 @@ 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<arr_api::jellyfin::JellyfinClient, Error> {
Ok(arr_api::jellyfin::JellyfinClient::new(
config.jellyfin_url.clone(),
config.jellyfin_api_key.clone(),
)?)
}
/// The subtitle providers this deployment can reach (DESIGN.md §15).
///
/// Credentials are bootstrap config and never reach the database (§10), so