feat(arr): refresh jellyfin after a subtitle write
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.
This commit is contained in:
@@ -900,9 +900,23 @@ async fn finish(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|file| file.id == id)
|
.find(|file| file.id == id)
|
||||||
.ok_or(ApiError::SubtitleNotFound)?;
|
.ok_or(ApiError::SubtitleNotFound)?;
|
||||||
|
refresh_jellyfin(state).await;
|
||||||
Ok((StatusCode::CREATED, Json(Subtitle::from(subtitle))))
|
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)]
|
#[cfg(test)]
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -1159,6 +1173,70 @@ mod tests {
|
|||||||
application(vec![Arc::new(StubProvider::new("opensubtitles"))], vec![]).await
|
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<Arc<dyn Provider>>,
|
||||||
|
backends: Vec<Arc<dyn Backend>>,
|
||||||
|
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) {
|
async fn search(fixture: &Fixture, language: &str) -> (StatusCode, serde_json::Value) {
|
||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.post(format!(
|
.post(format!(
|
||||||
@@ -1271,6 +1349,48 @@ mod tests {
|
|||||||
assert_eq!(body["error"], "media file not found");
|
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
|
/// §15's disk rule: the sidecar sits next to the video, inside the §7.4
|
||||||
/// folder, named `<video basename>.<lang>.srt`.
|
/// folder, named `<video basename>.<lang>.srt`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
Reference in New Issue
Block a user