From 8baef11c0ea3755f7e389920ad3173cce48838b6 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 01:31:59 +0100 Subject: [PATCH] feat(arr): refresh jellyfin after a subtitle write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DESIGN.md §7.5's watcher gap applies to a sidecar dropped next to a file Jellyfin already knows about, same as an imported file. A grab or translation now makes the same refresh call import does; a refresh failure logs and never fails the write that already landed. --- crates/arr-api/src/subtitles.rs | 120 ++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/crates/arr-api/src/subtitles.rs b/crates/arr-api/src/subtitles.rs index a62a3e8..3b385be 100644 --- a/crates/arr-api/src/subtitles.rs +++ b/crates/arr-api/src/subtitles.rs @@ -900,9 +900,23 @@ async fn finish( .into_iter() .find(|file| file.id == id) .ok_or(ApiError::SubtitleNotFound)?; + refresh_jellyfin(state).await; Ok((StatusCode::CREATED, Json(Subtitle::from(subtitle)))) } +/// Ask Jellyfin to rescan, the same single call §7.5 already makes on +/// import. Its filesystem watcher misses a sidecar dropped in next to a file +/// it already knows about, and a failure here must not fail the write that +/// already landed on disk. +async fn refresh_jellyfin(state: &AppState) { + let Some(jellyfin) = state.jellyfin() else { + return; + }; + if let Err(error) = jellyfin.refresh().await { + tracing::warn!(%error, "jellyfin refresh failed"); + } +} + #[cfg(test)] #[allow(clippy::too_many_lines)] mod tests { @@ -1159,6 +1173,70 @@ mod tests { application(vec![Arc::new(StubProvider::new("opensubtitles"))], vec![]).await } + /// Same fixture as [`application`], with a Jellyfin client attached so a + /// write can be observed asking it to refresh (§7.5, §15). + async fn application_with_jellyfin( + providers: Vec>, + backends: Vec>, + jellyfin_url: &str, + ) -> Fixture { + let dir = tempfile::tempdir().expect("tempdir"); + let database = arr_db::Db::connect(dir.path().join("arr.db")) + .await + .expect("connect database"); + database.migrate().await.expect("migrate database"); + let pool = database.pool().clone(); + + let folder = dir.path().join("Dune (2021) [tmdbid-438631]"); + tokio::fs::create_dir_all(&folder).await.expect("folder"); + let video = folder.join("Dune (2021) [tmdbid-438631] - [2160p][WEB-DL].mkv"); + tokio::fs::write(&video, vec![7u8; 200_000]) + .await + .expect("video"); + + sqlx::query( + "INSERT INTO movies (id, tmdb_id, title, year, root_id) VALUES (1, 438631, 'Dune', 2021, 1)", + ) + .execute(&pool) + .await + .expect("movie"); + let path = video.to_string_lossy().into_owned(); + sqlx::query( + "INSERT INTO media_files (id, owner_kind, owner_id, path, size) VALUES (1, 'movie', 1, ?, 200000)", + ) + .bind(&path) + .execute(&pool) + .await + .expect("media file"); + + let jellyfin = + crate::jellyfin::JellyfinClient::new(jellyfin_url, None).expect("jellyfin client"); + let state = AppState::new(Upstreams::new( + "http://127.0.0.1:1".into(), + "http://127.0.0.1:1".into(), + )) + .expect("state") + .with_database(database) + .with_subtitle_providers(providers) + .with_translation_backends(backends) + .with_jellyfin(jellyfin); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") }); + + Fixture { + _dir: dir, + base: format!("http://{address}"), + pool, + media_file_id: 1, + folder, + video, + } + } + async fn search(fixture: &Fixture, language: &str) -> (StatusCode, serde_json::Value) { let response = reqwest::Client::new() .post(format!( @@ -1271,6 +1349,48 @@ mod tests { assert_eq!(body["error"], "media file not found"); } + /// §7.5 applied to subtitles: a grab calls Jellyfin's refresh, the same + /// single call import already makes, because its watcher misses a + /// sidecar dropped next to a file it already knows about. + #[tokio::test] + async fn a_grab_refreshes_jellyfin() { + let jellyfin = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/Library/Refresh")) + .respond_with(wiremock::ResponseTemplate::new(204)) + .mount(&jellyfin) + .await; + + let fixture = application_with_jellyfin( + vec![Arc::new(StubProvider::new("opensubtitles"))], + vec![], + &jellyfin.uri(), + ) + .await; + + let (status, body) = grab(&fixture, pt()).await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + assert_eq!( + jellyfin.received_requests().await.expect("requests").len(), + 1 + ); + } + + /// A refresh failure must not fail the grab that already landed the + /// sidecar on disk (§7.5). + #[tokio::test] + async fn an_unreachable_jellyfin_does_not_fail_the_grab() { + let fixture = application_with_jellyfin( + vec![Arc::new(StubProvider::new("opensubtitles"))], + vec![], + "http://127.0.0.1:1", + ) + .await; + + let (status, body) = grab(&fixture, pt()).await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + } + /// §15's disk rule: the sidecar sits next to the video, inside the §7.4 /// folder, named `