feat(arr): probe methods for subtitle providers and engines

This commit is contained in:
Miguel Palhas
2026-08-25 05:54:19 +01:00
parent e0e7ddf6de
commit 8c5613b247
12 changed files with 480 additions and 14 deletions
+31 -2
View File
@@ -35,8 +35,8 @@ use tokio::{
use arr_core::Language;
use crate::translate::{
strip_code_fence, system_prompt, Backend, BackendId, Batch, Error, Result, TranslateFuture,
TranslatedCue,
strip_code_fence, system_prompt, Backend, BackendId, Batch, Error, ProbeFuture, Result,
TranslateFuture, TranslatedCue,
};
const BACKEND_NAME: &str = "command";
@@ -210,6 +210,35 @@ impl Backend for Command {
fn translate<'a>(&'a self, batch: &'a Batch) -> TranslateFuture<'a> {
Box::pin(async move { self.translate_inner(batch).await })
}
/// Runs the template once over an empty batch (#200): the lamp is the
/// command starting and exiting cleanly, not what it says. The same
/// timeout cell bounds it, so a wedged template cannot pile up probes.
fn probe(&self) -> ProbeFuture<'_> {
Box::pin(async move {
let payload = format!(
"{}\n[]\n",
system_prompt(&Language::Other("en".to_owned()), &Language::PortuguesePortugal)
);
let mut tokens = argv(&self.config.template);
for token in &mut tokens {
*token = token
.replace("{source}", language_tag(&Language::Other("en".to_owned())))
.replace("{target}", language_tag(&Language::PortuguesePortugal));
}
let (program, args) = tokens.split_first().expect("the constructor rejects empty");
let timeout = Duration::from_millis(self.timeout_ms.load(Ordering::Relaxed));
match time::timeout(timeout, run(program, args, payload, self.id.clone())).await {
Err(_) => Err(Error::Transport {
backend: self.id(),
source: format!("timed out after {timeout:?}").into(),
}),
Ok(Err(err)) => Err(err),
Ok(Ok(_)) => Ok(()),
}
})
}
}
/// Spawn the command once, feed it `payload`, and collect its stdout.
+55 -1
View File
@@ -24,7 +24,9 @@ use serde::{Deserialize, Serialize};
use arr_core::Language;
use crate::translate::{Backend, BackendId, Batch, Error, TranslateFuture, TranslatedCue};
use crate::translate::{
Backend, BackendId, Batch, Error, ProbeFuture, TranslateFuture, TranslatedCue,
};
use crate::{Error as SubsError, ProviderId, Result};
/// The `DeepL` API endpoint for keys ending in `:fx` (the free tier).
@@ -282,6 +284,31 @@ impl Backend for DeepL {
.collect())
})
}
/// `/v2/usage` is the documented key check: one cheap authenticated GET
/// that spends none of the character quota (#200).
fn probe(&self) -> ProbeFuture<'_> {
Box::pin(async move {
let reply = self
.http
.get(self.join("usage"))
.header("DeepL-Auth-Key", &self.config.auth_key)
.send()
.await
.map_err(|error| Error::Transport {
backend: self.id(),
source: Box::new(error),
})?;
match reply.status() {
StatusCode::OK => Ok(()),
StatusCode::FORBIDDEN => Err(Error::Unauthorized { backend: self.id() }),
status => Err(Error::Malformed {
backend: self.id(),
detail: format!("status {}", status.as_u16()),
}),
}
})
}
}
fn retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
@@ -534,4 +561,31 @@ mod tests {
};
assert_eq!(retry_after, Some(Duration::from_secs(30)));
}
#[tokio::test]
async fn a_probe_asks_usage_and_judges_the_key() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/usage"))
.and(header("DeepL-Auth-Key", KEY))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.mount(&server)
.await;
deepl(format!("{}/v2", server.uri()))
.probe()
.await
.expect("a 200 usage answer is a lit lamp");
server.reset().await;
Mock::given(method("GET"))
.and(path("/v2/usage"))
.respond_with(ResponseTemplate::new(403))
.mount(&server)
.await;
let error = deepl(format!("{}/v2", server.uri()))
.probe()
.await
.expect_err("bad key");
assert!(matches!(error, Error::Unauthorized { .. }), "got {error:?}");
}
}
+64 -2
View File
@@ -26,7 +26,9 @@ use serde::{Deserialize, Serialize};
use arr_core::Language;
use crate::translate::{Backend, BackendId, Batch, Error, TranslateFuture, TranslatedCue};
use crate::translate::{
Backend, BackendId, Batch, Error, ProbeFuture, TranslateFuture, TranslatedCue,
};
use crate::{Error as SubsError, ProviderId, Result};
/// The public Google Cloud Translation v2 endpoint.
@@ -291,6 +293,39 @@ impl Backend for Google {
.collect())
})
}
/// `languages` is the cheapest authenticated v2 call and spends none of
/// the translation quota (#200). The request itself is fixed, so a 400
/// can only mean the key was refused — Google reports bad keys as 400,
/// not 401.
fn probe(&self) -> ProbeFuture<'_> {
Box::pin(async move {
let mut url = self.base_url.clone();
url.set_path(&format!("{}/languages", url.path().trim_end_matches('/')));
url.query_pairs_mut()
.append_pair("key", &self.config.api_key);
let reply = self
.http
.get(url)
.send()
.await
.map_err(|error| Error::Transport {
backend: self.id(),
source: Box::new(error),
})?;
match reply.status() {
StatusCode::OK => Ok(()),
StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN => {
Err(Error::Unauthorized { backend: self.id() })
}
status => Err(Error::Malformed {
backend: self.id(),
detail: format!("status {}", status.as_u16()),
}),
}
})
}
}
fn api_error_detail(status: StatusCode, body: &[u8]) -> String {
@@ -367,7 +402,7 @@ fn numeric_entity(entity: &str) -> Option<char> {
#[cfg(test)]
mod tests {
use arr_core::Language;
use wiremock::matchers::method;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::{Google, GoogleConfig};
@@ -584,4 +619,31 @@ mod tests {
// A stray ampersand that is not an entity stays one.
assert_eq!(unescape_html("fish & chips"), "fish & chips");
}
#[tokio::test]
async fn a_probe_lists_languages_and_judges_the_key() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/languages"))
.and(query_param("key", KEY))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.mount(&server)
.await;
google(format!("{}/v2", server.uri()))
.probe()
.await
.expect("a languages answer is a lit lamp");
server.reset().await;
// Google reports a bad key as a 400, not a 401.
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(400))
.mount(&server)
.await;
let error = google(format!("{}/v2", server.uri()))
.probe()
.await
.expect_err("bad key");
assert!(matches!(error, Error::Unauthorized { .. }), "got {error:?}");
}
}
+17 -1
View File
@@ -55,7 +55,8 @@ 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, Settled, SyncState, Syncer, DEFAULT_BINARY as ALASS_DEFAULT_BINARY,
binary_present, Outcome, Rejection, Settled, SyncState, Syncer,
DEFAULT_BINARY as ALASS_DEFAULT_BINARY,
};
pub use translate::{Backend, BackendId, Batch, BatchCue, TranslateFuture, TranslatedCue};
@@ -65,6 +66,9 @@ pub use translate::{Backend, BackendId, Batch, BatchCue, TranslateFuture, Transl
#[cfg(test)]
use wiremock as _;
/// One health probe (#200): reachability and, where they exist, credentials.
pub type ProbeFuture<'a> = Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
/// The candidates one search turned up.
pub type SearchFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<Candidate>>> + Send + 'a>>;
@@ -131,6 +135,14 @@ pub trait Provider: fmt::Debug + Send + Sync {
/// Anything in [`Error`], and [`Error::NotFound`] when the candidate has
/// gone away between the search and the download.
fn download<'a>(&'a self, id: &'a CandidateId) -> DownloadFuture<'a>;
/// One health probe (#200): reachable, and credentials accepted.
///
/// Cheap by contract — the endpoint carrying it is polled. It must cost
/// no search or download quota: a probe that spends budget is a lamp
/// that drains the account it watches. [`Error::Unauthorized`] means the
/// credentials were refused; anything else is an outage.
fn probe(&self) -> ProbeFuture<'_>;
}
#[cfg(test)]
@@ -190,6 +202,10 @@ mod tests {
})
})
}
fn probe(&self) -> super::ProbeFuture<'_> {
Box::pin(async move { Ok(()) })
}
}
fn request() -> SearchRequest {
+78 -2
View File
@@ -26,8 +26,8 @@ use serde::{Deserialize, Serialize};
use arr_core::Language;
use crate::translate::{
strip_code_fence, system_prompt, Backend, BackendId, Batch, Error, Result, TranslateFuture,
TranslatedCue,
strip_code_fence, system_prompt, Backend, BackendId, Batch, Error, ProbeFuture, Result,
TranslateFuture, TranslatedCue,
};
const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1/";
@@ -262,6 +262,36 @@ impl Backend for OpenAi {
fn translate<'a>(&'a self, batch: &'a Batch) -> TranslateFuture<'a> {
Box::pin(async move { self.translate_inner(batch).await })
}
/// `models` is a read-only listing: reachable and key accepted, nothing
/// spent (#200). Endpoints that need no key are probed the same way,
/// just without the header.
fn probe(&self) -> ProbeFuture<'_> {
Box::pin(async move {
let mut url = self.base_url.clone();
url.set_path(&format!("{}/models", url.path().trim_end_matches('/')));
let mut request = self.http.get(url);
if let Some(key) = &self.config.api_key {
request = request.bearer_auth(key);
}
match request.send().await {
Ok(response) => match response.status() {
StatusCode::OK => Ok(()),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
Err(Error::Unauthorized { backend: self.id() })
}
status => Err(Error::Malformed {
backend: self.id(),
detail: format!("status {}", status.as_u16()),
}),
},
Err(error) => Err(Error::Transport {
backend: self.id(),
source: Box::new(error),
}),
}
})
}
}
fn retry_after(response: &reqwest::Response) -> Option<Duration> {
@@ -382,4 +412,50 @@ mod tests {
assert_eq!(strip_code_fence("```\n[1,2]\n```"), "[1,2]");
assert_eq!(strip_code_fence("[1,2]"), "[1,2]");
}
#[tokio::test]
async fn a_probe_lists_models_and_judges_the_key() {
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("Authorization", "Bearer test-key"))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.mount(&server)
.await;
OpenAi::with_base_url(
"gpt-4o-mini",
super::OpenAiConfig {
api_key: Some("test-key".to_owned()),
},
&format!("{}/v1", server.uri()),
)
.expect("client builds")
.probe()
.await
.expect("a models answer is a lit lamp");
server.reset().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let error = OpenAi::with_base_url(
"gpt-4o-mini",
super::OpenAiConfig {
api_key: Some("test-key".to_owned()),
},
&format!("{}/v1", server.uri()),
)
.expect("client builds")
.probe()
.await
.expect_err("bad key");
assert!(
matches!(error, crate::translate::Error::Unauthorized { .. }),
"got {error:?}"
);
}
}
+51 -3
View File
@@ -222,6 +222,23 @@ impl OpenSubtitles {
query: Option<&[(String, String)]>,
json: Option<&serde_json::Value>,
bearer: Option<&str>,
) -> Result<reqwest::Response> {
let response = self.send_raw(method, path, query, json, bearer).await?;
self.check_status(response).await
}
/// Send a request and hand back the raw response, whatever its status.
///
/// The health probe (#200) needs statuses `check_status` folds away: a
/// 400 there means "the API refused the request *after* accepting the
/// key", which is exactly what a lamp wants to hear.
async fn send_raw(
&self,
method: Method,
path: &str,
query: Option<&[(String, String)]>,
json: Option<&serde_json::Value>,
bearer: Option<&str>,
) -> Result<reqwest::Response> {
let url = self.base_url.join(path).map_err(|err| Error::Malformed {
provider: self.id.clone(),
@@ -244,11 +261,10 @@ impl OpenSubtitles {
}
tracing::debug!(provider = %self.id, path, "OpenSubtitles request");
let response = request.send().await.map_err(|err| Error::Transport {
request.send().await.map_err(|err| Error::Transport {
provider: self.id.clone(),
source: Box::new(err),
})?;
self.check_status(response).await
})
}
/// Fetch (or refetch) the user token downloads travel under.
@@ -292,6 +308,34 @@ impl OpenSubtitles {
self.login().await
}
/// One health probe (#200): reachable, and the API key accepted.
///
/// Deliberately not a real search — searches spend quota, and health is
/// polled. An unparseable candidate id is refused with a 4xx *after*
/// authentication, so anything short of an auth refusal or a 5xx is
/// proof the key was accepted, at no cost.
async fn probe_inner(&self) -> Result<()> {
let response = self
.send_raw(
Method::GET,
"subtitles",
Some(&[("id".to_owned(), "not-a-number".to_owned())]),
None,
None,
)
.await?;
match response.status() {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(Error::Unauthorized {
provider: self.id.clone(),
}),
status if status.is_server_error() => Err(Error::Malformed {
provider: self.id.clone(),
detail: format!("status {}", status.as_u16()),
}),
_ => Ok(()),
}
}
async fn search_inner(&self, request: &SearchRequest) -> Result<Vec<Candidate>> {
let hash =
moviehash(&request.file.path, request.file.size).map_err(|source| Error::Io {
@@ -432,6 +476,10 @@ impl Provider for OpenSubtitles {
fn download<'a>(&'a self, id: &'a CandidateId) -> DownloadFuture<'a> {
Box::pin(async move { self.download_inner(id).await })
}
fn probe(&self) -> crate::ProbeFuture<'_> {
Box::pin(async move { self.probe_inner().await })
}
}
fn retry_after(response: &reqwest::Response) -> Option<Duration> {
+8
View File
@@ -255,6 +255,14 @@ impl Provider for Podnapisi {
fn download<'a>(&'a self, id: &'a CandidateId) -> DownloadFuture<'a> {
Box::pin(async move { self.download_candidate(id).await })
}
/// Anonymous, so the lamp is reachability alone (#200): the site root
/// answers, no quota spent.
fn probe(&self) -> crate::ProbeFuture<'_> {
Box::pin(async move {
self.send(self.base_url.clone(), None).await.map(|_| ())
})
}
}
/// Configuration for a [`Podnapisi`].
+72 -1
View File
@@ -14,7 +14,7 @@
//! reference is always the media file itself; subtitle and video are both on
//! disk by the time this runs.
use std::{ffi::OsString, path::Path, process::Stdio, time::Duration};
use std::{ffi::OsStr, ffi::OsString, path::Path, process::Stdio, time::Duration};
use tokio::process::Command;
@@ -37,6 +37,38 @@ pub const MAX_SHIFT: Duration = Duration::from_secs(60);
/// How much of `alass`'s stderr is kept in an error.
const STDERR_LIMIT: usize = 512;
/// Whether a configured external binary resolves to an executable.
///
/// A name with any path component (`/usr/local/bin/alass`, `./alass`) must
/// exist and be executable exactly where it points; a bare name (`alass`) is
/// resolved through `PATH`, the way spawning it would (#200). No process is
/// started: the health endpoint is polled, and launching the real thing per
/// poll would be neither cheap nor side-effect-free.
#[must_use]
pub fn binary_present(binary: &OsStr) -> bool {
let path = Path::new(binary);
if path.components().count() > 1 {
return is_executable_file(path);
}
std::env::var_os("PATH").is_some_and(|paths| {
std::env::split_paths(&paths)
.map(|dir| dir.join(path))
.any(|candidate| is_executable_file(&candidate))
})
}
#[cfg(unix)]
fn is_executable_file(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
path.is_file()
&& std::fs::metadata(path).is_ok_and(|meta| meta.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
fn is_executable_file(path: &Path) -> bool {
path.is_file()
}
/// Runs `alass` over one subtitle against its video.
#[derive(Clone, Debug)]
pub struct Syncer {
@@ -74,6 +106,18 @@ impl Syncer {
self
}
/// The configured binary, as health probes (#200) report it.
#[must_use]
pub fn binary_path(&self) -> &OsStr {
&self.binary
}
/// Whether [`Self::binary_path`] resolves to an executable (#200).
#[must_use]
pub fn present(&self) -> bool {
binary_present(&self.binary)
}
/// Sync `subtitle` against `video`, in place on disk.
///
/// Returns the synced SRT text on acceptance; the input file is never
@@ -637,4 +681,31 @@ mod tests {
"1 of 2 cues survived"
);
}
/// The health lamp (#200) is made of this, so it must agree with what
/// spawning would do: an explicit path is judged where it points, a bare
/// name through `PATH`, and a missing or non-executable file is absent.
#[test]
fn binary_presence_follows_the_same_rules_spawning_does() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let executable = dir.path().join("alass");
std::fs::write(&executable, "#!/bin/sh\n").expect("write");
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755))
.expect("chmod");
let plain = dir.path().join("not-executable");
std::fs::write(&plain, "text").expect("write");
assert!(super::binary_present(executable.as_os_str()));
assert!(!super::binary_present(plain.as_os_str()));
assert!(!super::binary_present(std::ffi::OsStr::new(
"/nowhere/alass"
)));
// `sh` is on PATH of every machine that runs these tests.
assert!(super::binary_present(std::ffi::OsStr::new("sh")));
assert!(Syncer::new()
.with_binary(executable.as_os_str())
.present());
}
}
+19 -1
View File
@@ -105,6 +105,9 @@ pub struct TranslatedCue {
pub type TranslateFuture<'a> =
Pin<Box<dyn Future<Output = Result<Vec<TranslatedCue>>> + Send + 'a>>;
/// One health probe (#200): reachable and credentials accepted.
pub type ProbeFuture<'a> = Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
/// One translation backend (DESIGN.md §15).
///
/// Boxed futures rather than `async fn` for the same reason as
@@ -146,6 +149,15 @@ pub trait Backend: fmt::Debug + Send + Sync {
///
/// Anything in [`Error`].
fn translate<'a>(&'a self, batch: &'a Batch) -> TranslateFuture<'a>;
/// One health probe (#200): reachable, and credentials accepted.
///
/// For the remote-command backend this means the configured command
/// runs and exits cleanly. Cheap by contract — the endpoint carrying it
/// is polled, and it must spend none of the quota translation spends.
/// [`Error::Unauthorized`] means the credentials were refused; anything
/// else is an outage.
fn probe(&self) -> ProbeFuture<'_>;
}
/// Result alias for translation.
@@ -424,7 +436,9 @@ mod tests {
use arr_core::Language;
use super::{translate, Backend, BackendId, Batch, Error, TranslateFuture, TranslatedCue};
use super::{
translate, Backend, BackendId, Batch, Error, ProbeFuture, TranslateFuture, TranslatedCue,
};
use crate::srt::Cue;
/// Uppercases every cue and records how it was called; misbehaves on cue.
@@ -466,6 +480,10 @@ mod tests {
Ok(reply)
})
}
fn probe(&self) -> ProbeFuture<'_> {
Box::pin(async move { Ok(()) })
}
}
fn cues(texts: &[&str]) -> Vec<Cue> {
+23 -1
View File
@@ -21,7 +21,7 @@ use std::{
use arr_core::Language;
use arr_subs::translate::{translate, Error};
use arr_subs::{Command, CommandConfig};
use arr_subs::{Backend as _, Command, CommandConfig};
fn cues(texts: &[&str]) -> Vec<arr_subs::Cue> {
texts
@@ -270,3 +270,25 @@ async fn an_edited_timeout_reaches_the_next_call() {
}
const DEFAULT: Duration = arr_subs::COMMAND_DEFAULT_TIMEOUT;
/// The lamp (#200) is the command starting and exiting cleanly — not what it
/// says. An empty batch goes in; only the exit status is judged.
#[tokio::test]
async fn a_probe_runs_the_template_and_wants_a_clean_exit() {
let dir = tempfile::tempdir().expect("tempdir");
let ok = stub(dir.path(), "ok.sh", "cat > /dev/null\n");
Command::new(config(&ok.display().to_string(), DEFAULT))
.expect("backend constructs")
.probe()
.await
.expect("a clean exit is a lit lamp");
let failing = stub(dir.path(), "fail.sh", "echo nope >&2\nexit 3\n");
let error = Command::new(config(&failing.display().to_string(), DEFAULT))
.expect("backend constructs")
.probe()
.await
.expect_err("non-zero exit");
assert!(matches!(error, Error::Transport { .. }), "got {error:?}");
}
+39
View File
@@ -424,3 +424,42 @@ async fn an_id_this_provider_never_offered_is_not_found() {
other => panic!("expected NotFound, got {other:?}"),
}
}
#[tokio::test]
async fn a_probe_passes_when_the_key_is_accepted_even_on_a_refused_request() {
let server = MockServer::start().await;
// An unparseable candidate id is rejected *after* authentication: any
// 4xx short of 401/403 is proof the key was accepted, and spends no
// search quota (#200).
Mock::given(method("GET"))
.and(path("/api/v1/subtitles"))
.respond_with(ResponseTemplate::new(400))
.mount(&server)
.await;
client(&server)
.probe()
.await
.expect("a refused request still proves the key");
}
#[tokio::test]
async fn a_probe_reports_refused_credentials_and_outages() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/subtitles"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let err = client(&server).probe().await.expect_err("bad key");
assert!(matches!(err, Error::Unauthorized { .. }));
server.reset().await;
Mock::given(method("GET"))
.and(path("/api/v1/subtitles"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let err = client(&server).probe().await.expect_err("outage");
assert!(matches!(err, Error::Malformed { .. }));
}
+23
View File
@@ -282,3 +282,26 @@ async fn an_id_this_provider_never_issued_is_not_found() {
assert!(matches!(error, Error::NotFound { .. }), "got {error:?}");
}
#[tokio::test]
async fn a_probe_is_reachability_alone() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/subtitles/"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
provider(&server)
.probe()
.await
.expect("the anonymous provider is up when the site answers");
server.reset().await;
Mock::given(method("GET"))
.and(path("/subtitles/"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let error = provider(&server).probe().await.expect_err("site down");
assert!(matches!(error, Error::Malformed { .. }), "got {error:?}");
}