Merge #214: sync manually grabbed subtitles with alass
Closes #214 # Conflicts: # crates/arr-api/src/state.rs # crates/arr-daemon/src/main.rs
This commit is contained in:
@@ -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| {
|
||||
@@ -920,6 +939,7 @@ async fn refresh_jellyfin(state: &AppState) {
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
mod tests {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -929,6 +949,7 @@ mod tests {
|
||||
ProviderId, SearchFuture, SearchRequest, SubtitleFormat, TranslateFuture, TranslatedCue,
|
||||
};
|
||||
use axum::http::StatusCode;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::{router, AppState, Upstreams};
|
||||
|
||||
@@ -1110,6 +1131,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"))
|
||||
@@ -1151,7 +1188,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
|
||||
@@ -1470,6 +1508,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]
|
||||
|
||||
Reference in New Issue
Block a user