feat(arr): probe methods for subtitle providers and engines
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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 { .. }));
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user