feat: Jellyfin library refresh on import (#93)
This commit was merged in pull request #93.
This commit is contained in:
@@ -25,6 +25,7 @@ use arr_db::Db;
|
||||
use arr_dl::TransmissionClient;
|
||||
use arr_probe::Prober;
|
||||
|
||||
use crate::jellyfin::JellyfinClient;
|
||||
use crate::reconcile::{Action, ActionFuture, Outcome};
|
||||
|
||||
/// A failure during one import tick.
|
||||
@@ -73,6 +74,7 @@ enum ProbeOutcome {
|
||||
pub struct ImportAction {
|
||||
transmission: TransmissionClient,
|
||||
prober: Prober,
|
||||
jellyfin: JellyfinClient,
|
||||
/// Probe results by path, kept across ticks. The reconcile lane cancels
|
||||
/// the whole action after its 25 s budget while one probe alone may take
|
||||
/// up to 60 s, so without this a large multi-file torrent would restart
|
||||
@@ -99,10 +101,11 @@ struct PendingImport {
|
||||
|
||||
impl ImportAction {
|
||||
#[must_use]
|
||||
pub fn new(transmission: TransmissionClient, prober: Prober) -> Self {
|
||||
pub fn new(transmission: TransmissionClient, prober: Prober, jellyfin: JellyfinClient) -> Self {
|
||||
Self {
|
||||
transmission,
|
||||
prober,
|
||||
jellyfin,
|
||||
probed: std::sync::Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
@@ -288,6 +291,7 @@ impl ImportAction {
|
||||
|
||||
record_import(database, pending, &feature, waiver.as_ref(), &destination).await?;
|
||||
self.forget_probes(&paths).await;
|
||||
self.refresh_jellyfin().await;
|
||||
let path_text = destination.to_string_lossy().into_owned();
|
||||
tracing::info!(
|
||||
grab_id = pending.grab_id,
|
||||
@@ -304,6 +308,15 @@ impl ImportAction {
|
||||
)))
|
||||
}
|
||||
|
||||
/// §7.5: the filesystem watcher misses the just-hardlinked file. A
|
||||
/// failure to reach Jellyfin must not fail the import, which has already
|
||||
/// succeeded.
|
||||
async fn refresh_jellyfin(&self) {
|
||||
if let Err(error) = self.jellyfin.refresh().await {
|
||||
tracing::warn!(%error, "jellyfin refresh failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// §5.7 hard fail: blacklist the release, fail the grab, reopen the gap.
|
||||
/// The torrent is deliberately untouched (§7.3).
|
||||
async fn hard_fail(
|
||||
@@ -545,7 +558,7 @@ mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use super::*;
|
||||
@@ -583,6 +596,7 @@ mod tests {
|
||||
library: PathBuf,
|
||||
action: ImportAction,
|
||||
_server: MockServer,
|
||||
jellyfin_server: MockServer,
|
||||
}
|
||||
|
||||
/// An `ffprobe` stand-in: canned JSON for media, a `tty` document for the
|
||||
@@ -667,8 +681,20 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let jellyfin_server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/Library/Refresh"))
|
||||
.respond_with(ResponseTemplate::new(204))
|
||||
.mount(&jellyfin_server)
|
||||
.await;
|
||||
|
||||
let prober = Prober::new().with_binary(fake_ffprobe(dir.path(), media_json));
|
||||
let action = ImportAction::new(TransmissionClient::new(&server.uri()).unwrap(), prober);
|
||||
let jellyfin = JellyfinClient::new(jellyfin_server.uri(), None).unwrap();
|
||||
let action = ImportAction::new(
|
||||
TransmissionClient::new(&server.uri()).unwrap(),
|
||||
prober,
|
||||
jellyfin,
|
||||
);
|
||||
|
||||
Harness {
|
||||
_dir: dir,
|
||||
@@ -677,6 +703,7 @@ mod tests {
|
||||
library,
|
||||
action,
|
||||
_server: server,
|
||||
jellyfin_server,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,6 +752,12 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(movie_state, "available");
|
||||
|
||||
assert_eq!(
|
||||
h.jellyfin_server.received_requests().await.unwrap().len(),
|
||||
1,
|
||||
"§7.5: one refresh call at the end of a successful import"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5.3 through §5.7: Profile 5 is a hard fail — blacklisted, grab
|
||||
@@ -948,6 +981,7 @@ mod tests {
|
||||
let action = ImportAction::new(
|
||||
TransmissionClient::new("http://127.0.0.1:1").unwrap(),
|
||||
Prober::new().with_binary(&script),
|
||||
JellyfinClient::new("http://127.0.0.1:1", None).unwrap(),
|
||||
);
|
||||
let paths = vec![media];
|
||||
|
||||
@@ -992,6 +1026,7 @@ mod tests {
|
||||
let action = ImportAction::new(
|
||||
TransmissionClient::new("http://127.0.0.1:1").unwrap(),
|
||||
Prober::new().with_binary(&script),
|
||||
JellyfinClient::new("http://127.0.0.1:1", None).unwrap(),
|
||||
);
|
||||
let paths = vec![media];
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! 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(_))));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ mod config;
|
||||
mod grab;
|
||||
mod import;
|
||||
mod indexers;
|
||||
mod jellyfin;
|
||||
mod reaper;
|
||||
pub mod reconcile;
|
||||
mod rss;
|
||||
@@ -80,6 +81,8 @@ enum Error {
|
||||
Prowlarr(#[from] arr_indexer::Error),
|
||||
#[error("transmission client: {0}")]
|
||||
Transmission(#[from] arr_dl::Error),
|
||||
#[error("jellyfin client: {0}")]
|
||||
Jellyfin(#[from] jellyfin::Error),
|
||||
#[error("bind {addr}: {source}")]
|
||||
Bind {
|
||||
addr: std::net::SocketAddr,
|
||||
@@ -225,11 +228,15 @@ fn reconcile_loop(
|
||||
),
|
||||
);
|
||||
}
|
||||
let jellyfin = jellyfin::JellyfinClient::new(
|
||||
config.jellyfin_url.clone(),
|
||||
config.jellyfin_api_key.clone(),
|
||||
)?;
|
||||
// Grab before import, so a download that completes on this tick is
|
||||
// imported on this tick.
|
||||
reconcile = reconcile.register(
|
||||
Tick::Reconcile,
|
||||
ImportAction::new(transmission.clone(), arr_probe::Prober::new()),
|
||||
ImportAction::new(transmission.clone(), arr_probe::Prober::new(), jellyfin),
|
||||
);
|
||||
Ok(reconcile.register(Tick::Reaper, ReaperAction::new(transmission.clone())))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user