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:
Generated
+1
@@ -42,6 +42,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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(), ¬ifier)?;
|
reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), ¬ifier)?;
|
||||||
// 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(),
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user