@@ -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<String>,
|
||||
pub jellyfin_url: Option<String>,
|
||||
pub jellyfin_api_key: Option<String>,
|
||||
pub podnapisi_url: Option<String>,
|
||||
pub ntfy_url: Option<String>,
|
||||
pub ntfy_operator_topic: Option<String>,
|
||||
pub opensubtitles_api_key: Option<String>,
|
||||
@@ -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<String>,
|
||||
pub jellyfin_url: String,
|
||||
pub jellyfin_api_key: Option<String>,
|
||||
/// E2E seam only, env-only. `None` means Podnapisi's built-in default
|
||||
/// address; see [`ENV_PODNAPISI_URL`].
|
||||
pub podnapisi_url: Option<String>,
|
||||
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();
|
||||
|
||||
@@ -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<String>,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
podnapisi_url: Option<String>,
|
||||
) -> Vec<std::sync::Arc<dyn arr_subs::Provider>> {
|
||||
let mut providers: Vec<std::sync::Arc<dyn arr_subs::Provider>> = 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"),
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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*
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<serde_json::Value> =
|
||||
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. `<stem>.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<serde_json::Value> = 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}"
|
||||
);
|
||||
}
|
||||
@@ -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 <reference-file> <incorrect-sub-file> <output>`: 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()
|
||||
|
||||
Reference in New Issue
Block a user