feat(arr): sync manually grabbed subtitles with alass

The grab handler runs alass before recording the row, replacing the
sidecar with the synced text on acceptance and flagging it as
rejected otherwise (§15). Wires a Syncer into AppState, defaulting
to alass on PATH; the daemon binary points it at config.alass_path.
This commit is contained in:
Miguel Palhas
2026-08-25 01:24:09 +01:00
parent 635a651bee
commit f06e0e94bc
3 changed files with 128 additions and 4 deletions
+111 -2
View File
@@ -645,13 +645,19 @@ pub async fn grab(
let text = srt_text(&fetched)?;
write_sidecar(&destination, &text).await?;
let sync = state.syncer().settle(&target.path, &destination).await;
if let Some(synced) = &sync.content {
write_sidecar(&destination, synced).await?;
}
let mut record = arr_db::NewSubtitleFile::fetched(
target.media_file_id,
&language.to_string(),
&input.provider,
&input.candidate_id,
&destination.to_string_lossy(),
);
)
.sync(db_sync_state(sync.state));
if input.forced {
record = record.forced();
}
@@ -860,6 +866,19 @@ fn srt_text(fetched: &arr_subs::Fetched) -> Result<String, ApiError> {
.map_err(|error| ApiError::SubtitleUpstream(error.to_string()))
}
/// Map `arr_subs`'s three-state sync result onto the column pair `arr_db`
/// stores it as. `Syncer::settle` already folded an unusable `alass` and an
/// implausible result together into "nothing changed" — this is just the
/// vocabulary switch between the crate that ran `alass` and the one that
/// persists what it decided.
const fn db_sync_state(state: arr_subs::SyncState) -> arr_db::SubtitleSync {
match state {
arr_subs::SyncState::NotRun => arr_db::SubtitleSync::NotRun,
arr_subs::SyncState::Synced => arr_db::SubtitleSync::Synced,
arr_subs::SyncState::Rejected => arr_db::SubtitleSync::Rejected,
}
}
/// Write a sidecar whole or not at all, so Jellyfin never reads a half file.
async fn write_sidecar(destination: &Path, text: &str) -> Result<(), ApiError> {
let failure = |path: &Path, error: std::io::Error| {
@@ -906,6 +925,7 @@ async fn finish(
#[cfg(test)]
#[allow(clippy::too_many_lines)]
mod tests {
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::Arc;
@@ -915,6 +935,7 @@ mod tests {
ProviderId, SearchFuture, SearchRequest, SubtitleFormat, TranslateFuture, TranslatedCue,
};
use axum::http::StatusCode;
use tokio::io::AsyncWriteExt;
use crate::{router, AppState, Upstreams};
@@ -1096,6 +1117,22 @@ mod tests {
async fn application(
providers: Vec<Arc<dyn Provider>>,
backends: Vec<Arc<dyn Backend>>,
) -> Fixture {
build_fixture(providers, backends, arr_subs::Syncer::default()).await
}
async fn application_with_syncer(
providers: Vec<Arc<dyn Provider>>,
backends: Vec<Arc<dyn Backend>>,
syncer: arr_subs::Syncer,
) -> Fixture {
build_fixture(providers, backends, syncer).await
}
async fn build_fixture(
providers: Vec<Arc<dyn Provider>>,
backends: Vec<Arc<dyn Backend>>,
syncer: arr_subs::Syncer,
) -> Fixture {
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
@@ -1137,7 +1174,8 @@ mod tests {
.expect("state")
.with_database(database)
.with_subtitle_providers(providers)
.with_translation_backends(backends);
.with_translation_backends(backends)
.with_syncer(syncer);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
@@ -1350,6 +1388,77 @@ mod tests {
assert_eq!(fixture.subtitle_rows().await.len(), 1);
}
/// Write an executable fake `alass`. Its body receives the subtitle,
/// video and output paths as `$1`, `$2`, `$3`.
async fn fake_alass(dir: &std::path::Path, body: &str) -> PathBuf {
let path = dir.join("alass");
let mut file = tokio::fs::File::create(&path).await.expect("fake alass");
file.write_all(b"#!/bin/sh\n").await.expect("fake alass");
file.write_all(body.as_bytes()).await.expect("fake alass");
file.sync_all().await.expect("fake alass");
drop(file);
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.await
.expect("fake alass");
path
}
/// §15: `alass` runs on every fetched subtitle (#214), and an accepted
/// shift replaces the sidecar's content before the row is recorded.
#[tokio::test]
async fn a_grab_writes_the_synced_content_when_alass_accepts_it() {
let dir = tempfile::tempdir().expect("tempdir");
// The fetched cue starts at 1s (`SRT`); a 5s shift is within §15's
// 60-second bound.
let binary = fake_alass(
dir.path(),
"printf '1\\n00:00:06,000 --> 00:00:07,000\\nola\\n' > \"$3\"\n",
)
.await;
let fixture = application_with_syncer(
vec![Arc::new(StubProvider::new("opensubtitles"))],
vec![],
arr_subs::Syncer::new().with_binary(&binary),
)
.await;
let (status, body) = grab(&fixture, pt()).await;
assert_eq!(status, StatusCode::CREATED, "{body}");
assert_eq!(body["sync"], "synced");
let sidecar = PathBuf::from(body["path"].as_str().expect("path"));
assert_eq!(
tokio::fs::read_to_string(&sidecar).await.expect("sidecar"),
"1\n00:00:06,000 --> 00:00:07,000\nola\n"
);
}
/// §15: an implausible shift keeps the unsynced original and flags the
/// file rather than failing the grab.
#[tokio::test]
async fn a_grab_keeps_the_original_when_alass_is_implausible() {
let dir = tempfile::tempdir().expect("tempdir");
let binary = fake_alass(
dir.path(),
"printf '1\\n00:05:00,000 --> 00:05:01,000\\nola\\n' > \"$3\"\n",
)
.await;
let fixture = application_with_syncer(
vec![Arc::new(StubProvider::new("opensubtitles"))],
vec![],
arr_subs::Syncer::new().with_binary(&binary),
)
.await;
let (status, body) = grab(&fixture, pt()).await;
assert_eq!(status, StatusCode::CREATED, "{body}");
assert_eq!(body["sync"], "rejected");
let sidecar = PathBuf::from(body["path"].as_str().expect("path"));
assert_eq!(
tokio::fs::read_to_string(&sidecar).await.expect("sidecar"),
SRT
);
}
/// Sidecars are SRT (§15), so a provider serving ASS or VTT is converted
/// on the way to disk (#213) rather than refused.
#[tokio::test]