diff --git a/crates/arr-subs/src/lib.rs b/crates/arr-subs/src/lib.rs index 4518d32..bb94dbe 100644 --- a/crates/arr-subs/src/lib.rs +++ b/crates/arr-subs/src/lib.rs @@ -51,7 +51,9 @@ pub use openai::{OpenAi, OpenAiConfig}; pub use opensubtitles::{moviehash, OpenSubtitles, OpenSubtitlesConfig}; pub use podnapisi::{Podnapisi, PodnapisiBuilder, DEFAULT_BASE_URL as PODNAPISI_DEFAULT_BASE_URL}; pub use srt::Cue; -pub use sync::{Outcome, Rejection, Syncer, DEFAULT_BINARY as ALASS_DEFAULT_BINARY}; +pub use sync::{ + Outcome, Rejection, Settled, SyncState, Syncer, DEFAULT_BINARY as ALASS_DEFAULT_BINARY, +}; pub use translate::{Backend, BackendId, Batch, BatchCue, TranslateFuture, TranslatedCue}; // `unused_crate_dependencies` is a per-target lint. Every provider and diff --git a/crates/arr-subs/src/sync.rs b/crates/arr-subs/src/sync.rs index 79e53d4..e47b575 100644 --- a/crates/arr-subs/src/sync.rs +++ b/crates/arr-subs/src/sync.rs @@ -131,6 +131,48 @@ impl Syncer { }) } + /// Run [`Self::sync`] and settle the result into a form neither caller + /// has to branch on twice. + /// + /// #199's manual grab handler and #196's reconcile loop both write a + /// fetched or translated subtitle, then call this before recording the + /// row — the seam issue #214 asked for, so the sync-then-classify logic + /// exists once. An [`Error`] from `sync` — a missing binary, a timeout, a + /// non-zero exit — degrades to [`SyncState::NotRun`] with a warning + /// logged, rather than failing the caller: a subtitle arr already has + /// beats one it refuses because `alass` could not run. + pub async fn settle(&self, video: impl AsRef, subtitle: impl AsRef) -> Settled { + let subtitle = subtitle.as_ref(); + match self.sync(video, subtitle).await { + Ok(Outcome::Accepted { content }) => Settled { + content: Some(content), + state: SyncState::Synced, + }, + Ok(Outcome::Rejected(reason)) => { + tracing::warn!( + subtitle = %subtitle.display(), + %reason, + "alass sync rejected as implausible, keeping the unsynced original" + ); + Settled { + content: None, + state: SyncState::Rejected, + } + } + Err(error) => { + tracing::warn!( + subtitle = %subtitle.display(), + %error, + "alass sync could not run, leaving the subtitle unsynced" + ); + Settled { + content: None, + state: SyncState::NotRun, + } + } + } + } + /// Spawn `alass` and wait for it to write its output. async fn run(&self, video: &Path, subtitle: &Path, output: &Path) -> Result<()> { let mut command = Command::new(&self.binary); @@ -177,6 +219,33 @@ impl Syncer { } } +/// What settling a sync ended up recording, once an unusable `alass` and an +/// implausible result are both folded into "did not change anything" (§15). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SyncState { + /// `alass` could not be run to a plausible conclusion — binary missing, + /// timed out, or exited non-zero. Not the same as [`Self::Rejected`]: + /// nothing here says the *original* is implausible, only that nothing + /// checked. + NotRun, + /// Ran, and the result was accepted. + Synced, + /// Ran, and the result was implausible. The unsynced original stands and + /// the file is flagged. + Rejected, +} + +/// The result of [`Syncer::settle`]: what belongs on disk, and what to +/// record. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Settled { + /// The synced text, only when it differs from what is already at the + /// subtitle path. `None` means the file `settle` was given is already + /// correct — the original, untouched. + pub content: Option, + pub state: SyncState, +} + /// What one sync run decided. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Outcome { @@ -250,7 +319,7 @@ mod tests { use tokio::io::AsyncWriteExt; - use super::{plausible, Outcome, Rejection, Syncer, MAX_SHIFT}; + use super::{plausible, Outcome, Rejection, SyncState, Syncer, MAX_SHIFT}; use crate::error::Error; use crate::srt::Cue; @@ -505,6 +574,58 @@ mod tests { ); } + #[tokio::test] + async fn settle_returns_the_synced_content_to_write() { + let dir = tempfile::tempdir().expect("test setup and fake binary succeed"); + let binary = fake_binary( + dir.path(), + "printf '1\\n00:00:06,000 --> 00:00:07,500\\nola\\n\\n2\\n00:00:08,000 --> 00:00:09,000\\ntext\\n' > \"$3\"\n", + ) + .await; + let subtitle = write_subtitle(dir.path(), "in.srt", PLAIN).await; + + let settled = Syncer::new() + .with_binary(&binary) + .settle(dir.path().join("video.mkv"), &subtitle) + .await; + + assert_eq!(settled.state, SyncState::Synced); + assert!(settled.content.is_some()); + } + + #[tokio::test] + async fn settle_on_rejection_keeps_the_original_and_flags_it() { + let dir = tempfile::tempdir().expect("test setup and fake binary succeed"); + let binary = fake_binary( + dir.path(), + "printf '1\\n00:01:40,000 --> 00:01:41,500\\nola\\n\\n2\\n00:01:42,000 --> 00:01:43,000\\ntext\\n' > \"$3\"\n", + ) + .await; + let subtitle = write_subtitle(dir.path(), "in.srt", PLAIN).await; + + let settled = Syncer::new() + .with_binary(&binary) + .settle(dir.path().join("video.mkv"), &subtitle) + .await; + + assert_eq!(settled.state, SyncState::Rejected); + assert_eq!(settled.content, None); + } + + #[tokio::test] + async fn settle_on_a_missing_binary_degrades_to_not_run() { + let dir = tempfile::tempdir().expect("test setup and fake binary succeed"); + let subtitle = write_subtitle(dir.path(), "in.srt", PLAIN).await; + + let settled = Syncer::new() + .with_binary("alass-not-installed") + .settle(dir.path().join("video.mkv"), &subtitle) + .await; + + assert_eq!(settled.state, SyncState::NotRun); + assert_eq!(settled.content, None); + } + #[test] fn rejections_render_as_a_sentence() { assert_eq!(