feat: Jellyfin library refresh on import (#93)
ci / web (push) Successful in 28s
e2e / e2e (push) Successful in 55s
ci / rust (push) Successful in 2m12s

This commit was merged in pull request #93.
This commit is contained in:
2026-08-23 01:14:46 +01:00
parent 225be33b55
commit 35391f02fc
3 changed files with 144 additions and 4 deletions
+38 -3
View File
@@ -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];
+98
View File
@@ -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(_))));
}
}
+8 -1
View File
@@ -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())))
}