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:
@@ -0,0 +1,106 @@
|
||||
//! Jellyfin library refresh after import or a subtitle write. See DESIGN.md
|
||||
//! §7.5 and §15.
|
||||
//!
|
||||
//! 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;
|
||||
|
||||
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 {
|
||||
/// # Errors
|
||||
///
|
||||
/// If the underlying HTTP client cannot be built.
|
||||
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.
|
||||
///
|
||||
/// # 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);
|
||||
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(_))));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -8,6 +8,8 @@ use arr_db::Db;
|
||||
use arr_subs::{Backend, Provider};
|
||||
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";
|
||||
@@ -79,6 +81,7 @@ 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>>>,
|
||||
jellyfin: Option<JellyfinClient>,
|
||||
}
|
||||
|
||||
/// Work explicitly requested through the movie API.
|
||||
@@ -151,6 +154,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()),
|
||||
jellyfin: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -185,6 +189,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()
|
||||
}
|
||||
|
||||
pub(crate) fn subtitle_provider(&self, id: &str) -> Option<&Arc<dyn Provider>> {
|
||||
self.subtitle_providers
|
||||
.iter()
|
||||
|
||||
Reference in New Issue
Block a user