diff --git a/.gitea/workflows/e2e.yml b/.gitea/workflows/e2e.yml index 7175982..268210e 100644 --- a/.gitea/workflows/e2e.yml +++ b/.gitea/workflows/e2e.yml @@ -13,6 +13,7 @@ on: - 'crates/arr-indexer/**' - 'crates/arr-meta/**' - 'crates/arr-probe/**' + - 'crates/arr-subs/**' - 'crates/arr-api/**' - 'crates/arr-daemon/**' - '.gitea/workflows/e2e.yml' @@ -70,10 +71,21 @@ jobs: curl -LsSf https://get.nexte.st/latest/linux \ | tar zxf - -C "$HOME/.cargo/bin" + - name: alass + # No distro package; the subtitle sync path (DESIGN.md §15) shells + # out to a binary literally named `alass`, which the crate is not. + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v alass >/dev/null || { + cargo install alass-cli --locked + ln -sf "$HOME/.cargo/bin/alass-cli" "$HOME/.cargo/bin/alass" + } + - name: build daemon # The harness spawns this binary; building it here keeps the compile - # out of the first test's boot window. - run: cargo build -p arr-daemon --bin arr + # out of the first test's boot window. `translate-command` is the + # subtitle e2e scenarios' stub-script translation backend. + run: cargo build -p arr-daemon --bin arr --features translate-command - name: e2e env: diff --git a/Cargo.lock b/Cargo.lock index a0defb9..212d713 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -147,12 +147,14 @@ dependencies = [ name = "arr-e2e" version = "0.1.0" dependencies = [ + "arr-db", "arr-dl", "arr-indexer", "arr-meta", "chrono", "reqwest", "serde_json", + "sqlx", "tempfile", "tokio", "uuid", diff --git a/crates/arr-daemon/src/config.rs b/crates/arr-daemon/src/config.rs index 5bc840d..e7e9ce7 100644 --- a/crates/arr-daemon/src/config.rs +++ b/crates/arr-daemon/src/config.rs @@ -48,6 +48,11 @@ pub const ENV_TRANSLATE_GOOGLE_BASE_URL: &str = "ARR_TRANSLATE_GOOGLE_BASE_URL"; pub const ENV_TRANSLATE_COMMAND_TEMPLATE: &str = "ARR_TRANSLATE_COMMAND_TEMPLATE"; pub const ENV_ALASS_PATH: &str = "ARR_ALASS_PATH"; pub const ENV_FFMPEG_PATH: &str = "ARR_FFMPEG_PATH"; +/// E2E seam only, env-only, same shape as [`ENV_TMDB_URL`]: `None` means +/// Podnapisi's real address. Kept out of the config file for the same +/// reason `tmdb_url` is — DESIGN.md §10 has no business exposing a seam that +/// only a test harness uses. +pub const ENV_PODNAPISI_URL: &str = "ARR_PODNAPISI_URL"; pub const DEFAULT_BIND_ADDR: &str = "0.0.0.0:7878"; pub const DEFAULT_DATABASE_PATH: &str = "arr.db"; @@ -167,6 +172,7 @@ pub struct EnvOverrides { pub tmdb_url: Option, pub jellyfin_url: Option, pub jellyfin_api_key: Option, + pub podnapisi_url: Option, pub ntfy_url: Option, pub ntfy_operator_topic: Option, pub opensubtitles_api_key: Option, @@ -203,6 +209,7 @@ impl EnvOverrides { tmdb_url: std::env::var(ENV_TMDB_URL).ok(), jellyfin_url: std::env::var(ENV_JELLYFIN_URL).ok(), jellyfin_api_key: std::env::var(ENV_JELLYFIN_API_KEY).ok(), + podnapisi_url: std::env::var(ENV_PODNAPISI_URL).ok(), ntfy_url: std::env::var(ENV_NTFY_URL).ok(), ntfy_operator_topic: std::env::var(ENV_NTFY_OPERATOR_TOPIC).ok(), opensubtitles_api_key: std::env::var(ENV_OPENSUBTITLES_API_KEY).ok(), @@ -245,6 +252,9 @@ pub struct Config { pub tmdb_url: Option, pub jellyfin_url: String, pub jellyfin_api_key: Option, + /// E2E seam only, env-only. `None` means Podnapisi's built-in default + /// address; see [`ENV_PODNAPISI_URL`]. + pub podnapisi_url: Option, pub ntfy_url: String, /// The operator's ntfy topic (DESIGN.md §9.5) for *needs a decision* and /// *broken*. `None` means those two notifications are skipped — there is @@ -424,6 +434,7 @@ impl Config { .or(file.jellyfin_url) .unwrap_or_else(|| DEFAULT_JELLYFIN_URL.to_string()), jellyfin_api_key: env.jellyfin_api_key, + podnapisi_url: env.podnapisi_url, ntfy_url: env .ntfy_url .or(file.ntfy_url) @@ -483,6 +494,7 @@ mod tests { DEFAULT_SEED_IDLE_LIMIT_MINUTES ); assert_eq!(config.jellyfin_url, DEFAULT_JELLYFIN_URL); + assert_eq!(config.podnapisi_url, None); assert!(config.tracker_seeding.is_empty()); assert_eq!(config.ntfy_url, DEFAULT_NTFY_URL); assert_eq!(config.ntfy_operator_topic, None); @@ -675,6 +687,37 @@ alass_path = "/usr/local/bin/alass" )); } + /// Same env-only seam as `tmdb_url`, for the same reason: `arr-e2e` needs + /// to point Podnapisi at a `wiremock` fake without a live-tracker risk + /// creeping into the config file (DESIGN.md §15, §10). + #[test] + fn podnapisi_url_is_an_env_only_seam() { + let config = Config::resolve(EnvOverrides::default()).unwrap(); + assert_eq!(config.podnapisi_url, None); + + let env = EnvOverrides { + podnapisi_url: Some("http://127.0.0.1:9/subtitles".into()), + ..EnvOverrides::default() + }; + let config = Config::resolve(env).unwrap(); + assert_eq!( + config.podnapisi_url.as_deref(), + Some("http://127.0.0.1:9/subtitles") + ); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("arr.toml"); + std::fs::write(&path, "podnapisi_url = \"http://127.0.0.1:9/subtitles\"\n").unwrap(); + let env = EnvOverrides { + config_file: Some(path.to_string_lossy().into_owned()), + ..EnvOverrides::default() + }; + assert!(matches!( + Config::resolve(env), + Err(ConfigError::TomlDecode(_)) + )); + } + #[test] fn a_secret_in_the_config_file_is_a_parse_error() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/arr-daemon/src/main.rs b/crates/arr-daemon/src/main.rs index 6a73f75..0c21380 100644 --- a/crates/arr-daemon/src/main.rs +++ b/crates/arr-daemon/src/main.rs @@ -133,6 +133,7 @@ fn api_state( config.opensubtitles_api_key.clone(), config.opensubtitles_username.clone(), config.opensubtitles_password.clone(), + config.podnapisi_url.clone(), )) .with_translation_backends(translators.backends.clone()) .with_jellyfin(jellyfin) @@ -541,6 +542,7 @@ fn subtitle_action( config.opensubtitles_api_key.clone(), config.opensubtitles_username.clone(), config.opensubtitles_password.clone(), + config.podnapisi_url.clone(), ), translators.backends.clone(), arr_subs::Syncer::new().with_binary(config.alass_path.clone()), @@ -564,10 +566,17 @@ fn subtitle_providers( opensubtitles_api_key: Option, username: Option, password: Option, + podnapisi_url: Option, ) -> Vec> { let mut providers: Vec> = Vec::new(); - match arr_subs::Podnapisi::new() { + // `podnapisi_url` is the e2e-only seam (`ARR_PODNAPISI_URL`); absent, this + // is the real Podnapisi.net. + let mut builder = arr_subs::Podnapisi::builder(); + if let Some(url) = podnapisi_url { + builder = builder.base_url(url); + } + match builder.build() { Ok(podnapisi) => providers.push(std::sync::Arc::new(podnapisi)), Err(error) => tracing::warn!(%error, "Podnapisi not available"), } diff --git a/crates/arr-e2e/Cargo.toml b/crates/arr-e2e/Cargo.toml index aab3fac..bfc3974 100644 --- a/crates/arr-e2e/Cargo.toml +++ b/crates/arr-e2e/Cargo.toml @@ -14,10 +14,12 @@ tokio = { workspace = true } wiremock = { workspace = true } [dev-dependencies] +arr-db = { workspace = true } arr-dl = { workspace = true } arr-indexer = { workspace = true } arr-meta = { workspace = true } chrono = { workspace = true } +sqlx = { workspace = true } uuid = { workspace = true } [lints] diff --git a/crates/arr-e2e/fixtures/podnapisi-farfetched.zip b/crates/arr-e2e/fixtures/podnapisi-farfetched.zip new file mode 100644 index 0000000..32c2a35 Binary files /dev/null and b/crates/arr-e2e/fixtures/podnapisi-farfetched.zip differ diff --git a/crates/arr-e2e/fixtures/podnapisi-plausible.zip b/crates/arr-e2e/fixtures/podnapisi-plausible.zip new file mode 100644 index 0000000..4f53394 Binary files /dev/null and b/crates/arr-e2e/fixtures/podnapisi-plausible.zip differ diff --git a/crates/arr-e2e/src/lib.rs b/crates/arr-e2e/src/lib.rs index 1f12eff..34acbda 100644 --- a/crates/arr-e2e/src/lib.rs +++ b/crates/arr-e2e/src/lib.rs @@ -12,9 +12,11 @@ // Dev-dependencies belong to the integration tests; the lib's own test build // links them without using them. Same per-target quirk as arr-meta. #[cfg(test)] -use {arr_dl as _, arr_indexer as _, arr_meta as _, chrono as _, uuid as _}; +use { + arr_db as _, arr_dl as _, arr_indexer as _, arr_meta as _, chrono as _, sqlx as _, uuid as _, +}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::OnceLock; use std::time::{Duration, Instant}; @@ -36,6 +38,24 @@ pub mod fixtures { pub const PROWLARR_MOVIE_SEARCH: &str = include_str!("../fixtures/prowlarr-movie-search.xml"); pub const TMDB_CONFIGURATION: &str = include_str!("../fixtures/tmdb-configuration.json"); pub const TMDB_MOVIE_DUNE: &str = include_str!("../fixtures/tmdb-movie-dune.json"); + /// A zipped SRT with one cue at 00:00:01 — plausibly close to a fetched + /// subtitle's real timing for [`crate::probe_movie_fixture`]'s + /// three-second clip. + pub const PODNAPISI_PLAUSIBLE_ZIP: &[u8] = + include_bytes!("../fixtures/podnapisi-plausible.zip"); + /// A zipped SRT timed five minutes in — implausible against the same + /// three-second clip, so `alass` must reject it (DESIGN.md §15). + pub const PODNAPISI_FARFETCHED_ZIP: &[u8] = + include_bytes!("../fixtures/podnapisi-farfetched.zip"); +} + +/// The clip the subtitle scenarios probe and sync against: three seconds, +/// a real English `subrip` track, real audio — the same fixture +/// `arr-probe`'s own extraction tests use, so a real `ffmpeg` extraction and +/// a real `alass` sync both have something genuine to work with. +#[must_use] +pub fn probe_movie_fixture() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../arr-probe/tests/fixtures/movie-1080p.mkv") } /// The enabled indexer in [`fixtures::PROWLARR_INDEXERS`]. @@ -172,6 +192,8 @@ impl FakeTmdb { pub struct Daemon { child: Child, base_url: String, + database_path: PathBuf, + media_root: PathBuf, // Owns the database and media root for the child's lifetime. _dir: tempfile::TempDir, } @@ -185,35 +207,75 @@ impl Daemon { /// Panics when the binary cannot be built or spawned, or when the health /// endpoint does not answer within the boot timeout. pub async fn spawn(prowlarr_url: &str, tmdb_url: &str, transmission_url: &str) -> Self { + Self::spawn_with_env(prowlarr_url, tmdb_url, transmission_url, &[]).await + } + + /// Same as [`Self::spawn`], plus extra environment variables for the + /// child — the subtitle seams (`ARR_PODNAPISI_URL`, + /// `ARR_TRANSLATE_COMMAND_TEMPLATE`, ...) that only a test harness sets. + /// + /// # Panics + /// + /// Panics when the binary cannot be built or spawned, or when the health + /// endpoint does not answer within the boot timeout. + pub async fn spawn_with_env( + prowlarr_url: &str, + tmdb_url: &str, + transmission_url: &str, + extra_env: &[(&str, &str)], + ) -> Self { let dir = tempfile::tempdir().expect("create daemon tempdir"); let media_root = dir.path().join("media"); std::fs::create_dir(&media_root).expect("create media root"); let port = free_port(); let base_url = format!("http://127.0.0.1:{port}"); + let database_path = dir.path().join("arr.db"); - let child = Command::new(daemon_binary()) + let mut command = Command::new(daemon_binary()); + command .env_remove("ARR_CONFIG_FILE") .env("ARR_BIND_ADDR", format!("127.0.0.1:{port}")) - .env("ARR_DATABASE_PATH", dir.path().join("arr.db")) + .env("ARR_DATABASE_PATH", &database_path) .env("ARR_MEDIA_ROOT", &media_root) .env("ARR_PROWLARR_URL", prowlarr_url) .env("ARR_PROWLARR_API_KEY", API_KEY) .env("ARR_TMDB_URL", tmdb_url) .env("ARR_TMDB_API_KEY", API_KEY) .env("ARR_TRANSMISSION_URL", transmission_url) - .stdin(Stdio::null()) - .spawn() - .expect("spawn arr daemon"); + .stdin(Stdio::null()); + for (key, value) in extra_env { + command.env(key, value); + } + let child = command.spawn().expect("spawn arr daemon"); let mut daemon = Self { child, base_url, + database_path, + media_root, _dir: dir, }; daemon.wait_until_healthy().await; daemon } + /// The child's SQLite database file. Subtitle scenarios have no HTTP + /// route to adopt an already-imported media file (that pipeline is its + /// own seam, DESIGN.md §12), so tests seed one directly here — the same + /// file the daemon's own reconcile loop reads and writes, WAL mode + /// making the two connections safe to interleave. + #[must_use] + pub fn database_path(&self) -> &Path { + &self.database_path + } + + /// The child's media root (`ARR_MEDIA_ROOT`), for tests that plant a + /// video file for the reconcile loop to find. + #[must_use] + pub fn media_root(&self) -> &Path { + &self.media_root + } + /// The daemon's API base URL, such as `http://127.0.0.1:41234`. #[must_use] pub fn base_url(&self) -> &str { @@ -275,6 +337,10 @@ fn daemon_binary() -> &'static PathBuf { let mut build = Command::new(env!("CARGO")); build .args(["build", "-p", "arr-daemon", "--bin", "arr"]) + // The remote-command translation backend (DESIGN.md §15, issue + // #193) is behind its own cargo feature; the subtitle e2e + // scenarios need it compiled in to drive a stub script. + .args(["--features", "translate-command"]) .args(["--message-format", "json-render-diagnostics"]) .stderr(Stdio::inherit()); // The test process carries the per-crate vars cargo set for *this* diff --git a/crates/arr-e2e/tests/e2e.rs b/crates/arr-e2e/tests/e2e.rs index f7ae564..b588dc0 100644 --- a/crates/arr-e2e/tests/e2e.rs +++ b/crates/arr-e2e/tests/e2e.rs @@ -8,7 +8,7 @@ // The harness library links these; this test target does not use them all // directly. Same per-target quirk as arr-meta's tests. -use {tempfile as _, wiremock as _}; +use {arr_db as _, sqlx as _, tempfile as _, wiremock as _}; use std::path::PathBuf; diff --git a/crates/arr-e2e/tests/subtitles.rs b/crates/arr-e2e/tests/subtitles.rs new file mode 100644 index 0000000..7b8859f --- /dev/null +++ b/crates/arr-e2e/tests/subtitles.rs @@ -0,0 +1,430 @@ +//! The subtitle path end to end (DESIGN.md §15, issue #205), cross-process +//! like the rest of this crate: the real `arr` binary, real `ffmpeg` and real +//! `alass`. Only the HTTP boundary — Podnapisi — and the remote-command +//! translation backend are stubbed, the same rule §12 already applies to +//! Prowlarr and TMDB: a live tracker or a live translation API never runs in +//! a test. +//! +//! There is no HTTP route that adopts an already-imported file (that +//! pipeline is its own seam — grab, download, import), so every scenario +//! here seeds a `media_files` row directly against the daemon's own SQLite +//! file, the same fixture shape the daemon's in-process subtitle tests use. + +// Same per-target quirk as `e2e.rs`: dev-dependencies used only by this +// integration target still link into the crate's own test build. +use {arr_db as _, arr_dl as _, arr_indexer as _, arr_meta as _, chrono as _}; + +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use arr_e2e::{fixtures, probe_movie_fixture, Daemon}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Neither Prowlarr, TMDB nor Transmission matter to a subtitle scenario; +/// every test points them at an address nothing answers on rather than +/// standing up fakes it never calls. +const UNUSED_UPSTREAM: &str = "http://127.0.0.1:1"; + +/// Insert a movie, an imported grab and a media file directly into the +/// daemon's database, then copy `clip` into the daemon's media root next to +/// it. Returns the media file id and the video's path on disk. +async fn seed_movie_file( + database: &arr_db::Db, + media_root: &Path, + tmdb_id: i64, + release_name: &str, + probed: Option<&serde_json::Value>, +) -> (i64, PathBuf) { + let pool = database.pool(); + + let root_id: i64 = + sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'movie' AND audience = 'main'") + .fetch_one(pool) + .await + .expect("migrations seed the main movie root"); + + let movie_id: i64 = sqlx::query_scalar( + "INSERT INTO movies (tmdb_id, title, root_id) VALUES (?, ?, ?) RETURNING id", + ) + .bind(tmdb_id) + .bind(format!("Movie {tmdb_id}")) + .bind(root_id) + .fetch_one(pool) + .await + .expect("insert movie"); + + let folder = media_root.join(format!("Movie {tmdb_id} (2024) - [1080p]")); + std::fs::create_dir_all(&folder).expect("create title folder"); + let video = folder.join(format!("Movie {tmdb_id} (2024) - [1080p].mkv")); + std::fs::copy(probe_movie_fixture(), &video).expect("copy the real probe clip"); + let size = i64::try_from(std::fs::metadata(&video).expect("clip metadata").len()) + .expect("clip size fits in i64"); + + let media_file_id: i64 = sqlx::query_scalar( + "INSERT INTO media_files (owner_kind, owner_id, path, size, probed) + VALUES ('movie', ?, ?, ?, ?) RETURNING id", + ) + .bind(movie_id) + .bind(video.to_string_lossy().into_owned()) + .bind(size) + .bind(probed.map(ToString::to_string)) + .fetch_one(pool) + .await + .expect("insert media file"); + + // A release and an imported grab, so `Target::release_name` resolves to + // `release_name` — Podnapisi searches nothing without one. + let release_id: i64 = sqlx::query_scalar( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) + VALUES (1, ?, ?, 1000000, 'http://example.invalid/download', '{}', 'eligible') + RETURNING id", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(release_name) + .fetch_one(pool) + .await + .expect("insert release"); + + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, imported_at) + VALUES (?, 'movie', ?, ?, 'imported', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + ) + .bind(release_id) + .bind(movie_id) + .bind(uuid::Uuid::new_v4().to_string()) + .execute(pool) + .await + .expect("insert grab"); + + (media_file_id, video) +} + +/// `PUT /api/settings/subtitles`, the operator surface the reconcile loop +/// reads every tick (DESIGN.md §15, #198). +async fn configure_subtitles( + base: &str, + wanted_languages: &[&str], + providers_enabled: &[&str], + translation_engine: Option<&str>, +) { + let response = reqwest::Client::new() + .put(format!("{base}/api/settings/subtitles")) + .json(&serde_json::json!({ + "wanted_languages": wanted_languages, + "providers_enabled": providers_enabled, + "translation_engine": translation_engine, + "provider_daily_budgets": {}, + "translator_daily_budgets": {}, + "remote_command_timeout_seconds": 30, + })) + .send() + .await + .expect("put subtitle settings"); + assert_eq!( + response.status(), + reqwest::StatusCode::OK, + "subtitle settings accepted: {}", + response.text().await.unwrap_or_default() + ); +} + +/// Poll `/api/media-files/{id}/subtitles` until a row of the given `origin` +/// shows up — the reconcile lane closes a gap from a detached task, so its +/// effects land some time after the tick that started it (§8, §15). +async fn wait_for_subtitle(base: &str, media_file_id: i64, origin: &str) -> serde_json::Value { + for _ in 0..200 { + let subtitles: Vec = + reqwest::get(format!("{base}/api/media-files/{media_file_id}/subtitles")) + .await + .expect("list subtitles") + .json() + .await + .expect("subtitles json"); + if let Some(found) = subtitles + .iter() + .find(|subtitle| subtitle["origin"] == origin) + { + return found.clone(); + } + tokio::time::sleep(Duration::from_millis(300)).await; + } + panic!("no {origin} subtitle appeared for media file {media_file_id} in time"); +} + +/// A remote-command translation backend stub (DESIGN.md §15, issue #193): +/// discards whatever batch it is fed and always answers with the same +/// translated cue. Every scenario here feeds it exactly one embedded English +/// cue ("hello", numbered 1, from the committed probe fixture), so a fixed +/// reply exercises the real IPC without needing a JSON parser in `sh`. +fn write_stub_translator(dir: &Path) -> PathBuf { + let script = dir.join("stub-translate.sh"); + std::fs::write( + &script, + "#!/bin/sh\ncat >/dev/null\nprintf '[{\"number\":1,\"text\":\"Ola\"}]'\n", + ) + .expect("write stub translator"); + let mut permissions = std::fs::metadata(&script) + .expect("stub translator metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&script, permissions).expect("make stub translator executable"); + script +} + +async fn connect(daemon: &Daemon) -> arr_db::Db { + arr_db::Db::connect(daemon.database_path()) + .await + .expect("connect to the daemon's own database") +} + +/// Where DESIGN.md §15 puts a sidecar for `video`, e.g. `.pt-PT.srt`. +fn sidecar_path(video: &Path, suffix: &str) -> PathBuf { + let stem = video + .file_stem() + .and_then(|stem| stem.to_str()) + .expect("the seeded video path has a UTF-8 stem"); + video.with_file_name(format!("{stem}.{suffix}")) +} + +/// A media file with no subtitles gets one fetched from a provider, synced +/// by real `alass`, and written under the §15 sidecar name — and once +/// satisfied, the loop never asks the provider again. +#[tokio::test] +async fn a_missing_subtitle_is_fetched_synced_and_named() { + let podnapisi = MockServer::start().await; + let pid = "movie-one"; + Mock::given(method("GET")) + .and(path("/subtitles/search/advanced")) + .respond_with( + ResponseTemplate::new(200).set_body_string( + serde_json::json!({ + "data": [{ + "id": pid, + "language": "pt-PT", + "flags": [], + "releases": ["Movie.One.2024.1080p.WEB-DL-GROUP"], + "custom_releases": [] + }], + "page": 1, + "all_pages": 1 + }) + .to_string(), + ), + ) + .mount(&podnapisi) + .await; + Mock::given(method("GET")) + .and(path(format!("/subtitles/{pid}/download"))) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw(fixtures::PODNAPISI_PLAUSIBLE_ZIP, "application/zip"), + ) + .mount(&podnapisi) + .await; + let podnapisi_url = format!("{}/subtitles", podnapisi.uri()); + + let daemon = Daemon::spawn_with_env( + UNUSED_UPSTREAM, + UNUSED_UPSTREAM, + UNUSED_UPSTREAM, + &[("ARR_PODNAPISI_URL", &podnapisi_url)], + ) + .await; + let database = connect(&daemon).await; + let (media_file_id, video) = seed_movie_file( + &database, + daemon.media_root(), + 900_001, + "Movie.One.2024.1080p.WEB-DL-GROUP", + Some(&serde_json::json!({ "sub_tracks": [] })), + ) + .await; + configure_subtitles(daemon.base_url(), &["pt-PT"], &["podnapisi"], None).await; + + let subtitle = wait_for_subtitle(daemon.base_url(), media_file_id, "provider").await; + assert_eq!(subtitle["language"], "pt-PT"); + assert_eq!(subtitle["provider"], "podnapisi"); + assert_eq!( + subtitle["sync"], "synced", + "real alass produced a plausible shift" + ); + + let expected_path = sidecar_path(&video, "pt-PT.srt"); + assert_eq!( + subtitle["path"].as_str(), + Some(expected_path.to_string_lossy()).as_deref() + ); + assert!( + expected_path.exists(), + "sidecar written at the §15 name: {}", + expected_path.display() + ); + + // §15: once satisfied, the loop never searches this language again. + // Wait past one more 30-second reconcile tick and confirm nothing moved. + tokio::time::sleep(Duration::from_secs(35)).await; + let requests = podnapisi + .received_requests() + .await + .expect("request recording is on by default"); + assert_eq!( + requests.len(), + 3, + "a pt-PT want searches both pt-PT and pt-BR (§15's substitution), plus one \ + download, and never again once satisfied: {requests:?}" + ); +} + +/// A file whose only English text lives in an embedded track gets that track +/// extracted by real `ffmpeg` and translated into a `.mt` sidecar — the +/// improvement over Bazarr §15 calls out explicitly. +#[tokio::test] +async fn an_embedded_track_is_extracted_and_translated() { + let scripts = tempfile::tempdir().expect("tempdir for the stub translator"); + let template = write_stub_translator(scripts.path()); + + let daemon = Daemon::spawn_with_env( + UNUSED_UPSTREAM, + UNUSED_UPSTREAM, + UNUSED_UPSTREAM, + &[( + "ARR_TRANSLATE_COMMAND_TEMPLATE", + template.to_str().expect("utf8 path"), + )], + ) + .await; + let database = connect(&daemon).await; + let (media_file_id, video) = seed_movie_file( + &database, + daemon.media_root(), + 900_002, + "Movie.Two.2024.1080p.WEB-DL-GROUP", + Some(&serde_json::json!({ "sub_tracks": [ + { "language": "en", "codec": "subrip", "forced": false, "sdh": false } + ] })), + ) + .await; + // No provider enabled: §15 translates immediately once nothing else has + // the language, no waiting window. + configure_subtitles(daemon.base_url(), &["pt-PT"], &[], Some("command")).await; + + let translated = wait_for_subtitle(daemon.base_url(), media_file_id, "translated").await; + assert_eq!(translated["language"], "pt-PT"); + assert_eq!(translated["engine"], "command"); + + let subtitles: Vec = reqwest::get(format!( + "{}/api/media-files/{media_file_id}/subtitles", + daemon.base_url() + )) + .await + .expect("list subtitles") + .json() + .await + .expect("subtitles json"); + let extracted = subtitles + .iter() + .find(|subtitle| subtitle["origin"] == "extracted") + .expect("the embedded English track was extracted as the translation source"); + assert_eq!(extracted["language"], "en"); + + let sidecar = sidecar_path(&video, "pt-PT.mt.srt"); + let content = tokio::fs::read_to_string(&sidecar) + .await + .expect("read the translated sidecar"); + assert!( + content.contains("Ola"), + "the stub's translation reached the sidecar: {content}" + ); +} + +/// A subtitle whose sync is implausible — real `alass` correlating a +/// five-minutes-off cue against a three-second clip — keeps the unsynced +/// original and is flagged, rather than silently writing a bad shift. +#[tokio::test] +async fn an_implausible_sync_keeps_the_original_and_is_flagged() { + let podnapisi = MockServer::start().await; + let pid = "movie-three"; + Mock::given(method("GET")) + .and(path("/subtitles/search/advanced")) + .respond_with( + ResponseTemplate::new(200).set_body_string( + serde_json::json!({ + "data": [{ + "id": pid, + "language": "pt-PT", + "flags": [], + "releases": ["Movie.Three.2024.1080p.WEB-DL-GROUP"], + "custom_releases": [] + }], + "page": 1, + "all_pages": 1 + }) + .to_string(), + ), + ) + .mount(&podnapisi) + .await; + Mock::given(method("GET")) + .and(path(format!("/subtitles/{pid}/download"))) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw(fixtures::PODNAPISI_FARFETCHED_ZIP, "application/zip"), + ) + .mount(&podnapisi) + .await; + let podnapisi_url = format!("{}/subtitles", podnapisi.uri()); + + let daemon = Daemon::spawn_with_env( + UNUSED_UPSTREAM, + UNUSED_UPSTREAM, + UNUSED_UPSTREAM, + &[("ARR_PODNAPISI_URL", &podnapisi_url)], + ) + .await; + let database = connect(&daemon).await; + let (media_file_id, video) = seed_movie_file( + &database, + daemon.media_root(), + 900_003, + "Movie.Three.2024.1080p.WEB-DL-GROUP", + Some(&serde_json::json!({ "sub_tracks": [] })), + ) + .await; + configure_subtitles(daemon.base_url(), &["pt-PT"], &["podnapisi"], None).await; + + let subtitle = wait_for_subtitle(daemon.base_url(), media_file_id, "provider").await; + assert_eq!(subtitle["sync"], "rejected"); + + let sidecar = sidecar_path(&video, "pt-PT.srt"); + let content = tokio::fs::read_to_string(&sidecar) + .await + .expect("read the unsynced sidecar"); + assert!( + content.contains("completely unrelated dialogue") && content.contains("00:05:00"), + "the unsynced original is kept verbatim, timings untouched: {content}" + ); + + let queue: serde_json::Value = + reqwest::get(format!("{}/api/queues/subtitles", daemon.base_url())) + .await + .expect("get subtitle queue") + .json() + .await + .expect("queue json"); + let movie_gaps = queue["movies"] + .as_array() + .expect("movies array") + .iter() + .find(|movie| movie["media_file_id"] == media_file_id) + .expect("the rejected sync surfaces in the missing-subtitles queue (#202)"); + assert!( + movie_gaps["gaps"] + .as_array() + .expect("gaps array") + .iter() + .any(|gap| gap["reason"] == "sync_rejected"), + "{movie_gaps}" + ); +} diff --git a/crates/arr-subs/src/sync.rs b/crates/arr-subs/src/sync.rs index e47b575..a1154d4 100644 --- a/crates/arr-subs/src/sync.rs +++ b/crates/arr-subs/src/sync.rs @@ -177,7 +177,9 @@ impl Syncer { async fn run(&self, video: &Path, subtitle: &Path, output: &Path) -> Result<()> { let mut command = Command::new(&self.binary); command - .args([subtitle, video]) + // `alass `: the + // video is the reference, never the other way round. + .args([video, subtitle]) .arg(output) .stdin(Stdio::null()) .stdout(Stdio::null()) @@ -327,7 +329,7 @@ mod tests { "1\n00:00:01,000 --> 00:00:02,500\nol\u{e1}\n\n2\n00:00:03,000 --> 00:00:04,000\ntwo lines\nof text\n"; /// Write an executable fake `alass`. Its body receives the three paths as - /// `$1` subtitle, `$2` video and `$3` output. + /// `$1` video, `$2` subtitle and `$3` output. async fn fake_binary(dir: &std::path::Path, body: &str) -> std::path::PathBuf { let path = dir.join("alass"); let mut file = tokio::fs::File::create(&path) @@ -372,7 +374,7 @@ mod tests { #[tokio::test] async fn an_unchanged_output_is_accepted_with_alass_text() { let dir = tempfile::tempdir().expect("test setup and fake binary succeed"); - let binary = fake_binary(dir.path(), "cp \"$1\" \"$3\"\n").await; + let binary = fake_binary(dir.path(), "cp \"$2\" \"$3\"\n").await; let subtitle = write_subtitle(dir.path(), "in.srt", PLAIN).await; let outcome = Syncer::new()