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 arr_core::Language;
|
||||||
|
|
||||||
use crate::translate::{
|
use crate::translate::{
|
||||||
strip_code_fence, system_prompt, Backend, BackendId, Batch, Error, Result, TranslateFuture,
|
strip_code_fence, system_prompt, Backend, BackendId, Batch, Error, ProbeFuture, Result,
|
||||||
TranslatedCue,
|
TranslateFuture, TranslatedCue,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BACKEND_NAME: &str = "command";
|
const BACKEND_NAME: &str = "command";
|
||||||
@@ -210,6 +210,35 @@ impl Backend for Command {
|
|||||||
fn translate<'a>(&'a self, batch: &'a Batch) -> TranslateFuture<'a> {
|
fn translate<'a>(&'a self, batch: &'a Batch) -> TranslateFuture<'a> {
|
||||||
Box::pin(async move { self.translate_inner(batch).await })
|
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.
|
/// Spawn the command once, feed it `payload`, and collect its stdout.
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use arr_core::Language;
|
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};
|
use crate::{Error as SubsError, ProviderId, Result};
|
||||||
|
|
||||||
/// The `DeepL` API endpoint for keys ending in `:fx` (the free tier).
|
/// The `DeepL` API endpoint for keys ending in `:fx` (the free tier).
|
||||||
@@ -282,6 +284,31 @@ impl Backend for DeepL {
|
|||||||
.collect())
|
.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> {
|
fn retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
|
||||||
@@ -534,4 +561,31 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(retry_after, Some(Duration::from_secs(30)));
|
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 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};
|
use crate::{Error as SubsError, ProviderId, Result};
|
||||||
|
|
||||||
/// The public Google Cloud Translation v2 endpoint.
|
/// The public Google Cloud Translation v2 endpoint.
|
||||||
@@ -291,6 +293,39 @@ impl Backend for Google {
|
|||||||
.collect())
|
.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 {
|
fn api_error_detail(status: StatusCode, body: &[u8]) -> String {
|
||||||
@@ -367,7 +402,7 @@ fn numeric_entity(entity: &str) -> Option<char> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use arr_core::Language;
|
use arr_core::Language;
|
||||||
use wiremock::matchers::method;
|
use wiremock::matchers::{method, path, query_param};
|
||||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
use super::{Google, GoogleConfig};
|
use super::{Google, GoogleConfig};
|
||||||
@@ -584,4 +619,31 @@ mod tests {
|
|||||||
// A stray ampersand that is not an entity stays one.
|
// A stray ampersand that is not an entity stays one.
|
||||||
assert_eq!(unescape_html("fish & chips"), "fish & chips");
|
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 podnapisi::{Podnapisi, PodnapisiBuilder, DEFAULT_BASE_URL as PODNAPISI_DEFAULT_BASE_URL};
|
||||||
pub use srt::Cue;
|
pub use srt::Cue;
|
||||||
pub use sync::{
|
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};
|
pub use translate::{Backend, BackendId, Batch, BatchCue, TranslateFuture, TranslatedCue};
|
||||||
|
|
||||||
@@ -65,6 +66,9 @@ pub use translate::{Backend, BackendId, Batch, BatchCue, TranslateFuture, Transl
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use wiremock as _;
|
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.
|
/// The candidates one search turned up.
|
||||||
pub type SearchFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<Candidate>>> + Send + 'a>>;
|
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
|
/// Anything in [`Error`], and [`Error::NotFound`] when the candidate has
|
||||||
/// gone away between the search and the download.
|
/// gone away between the search and the download.
|
||||||
fn download<'a>(&'a self, id: &'a CandidateId) -> DownloadFuture<'a>;
|
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)]
|
#[cfg(test)]
|
||||||
@@ -190,6 +202,10 @@ mod tests {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn probe(&self) -> super::ProbeFuture<'_> {
|
||||||
|
Box::pin(async move { Ok(()) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request() -> SearchRequest {
|
fn request() -> SearchRequest {
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use arr_core::Language;
|
use arr_core::Language;
|
||||||
|
|
||||||
use crate::translate::{
|
use crate::translate::{
|
||||||
strip_code_fence, system_prompt, Backend, BackendId, Batch, Error, Result, TranslateFuture,
|
strip_code_fence, system_prompt, Backend, BackendId, Batch, Error, ProbeFuture, Result,
|
||||||
TranslatedCue,
|
TranslateFuture, TranslatedCue,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1/";
|
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> {
|
fn translate<'a>(&'a self, batch: &'a Batch) -> TranslateFuture<'a> {
|
||||||
Box::pin(async move { self.translate_inner(batch).await })
|
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> {
|
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("```\n[1,2]\n```"), "[1,2]");
|
||||||
assert_eq!(strip_code_fence("[1,2]"), "[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)]>,
|
query: Option<&[(String, String)]>,
|
||||||
json: Option<&serde_json::Value>,
|
json: Option<&serde_json::Value>,
|
||||||
bearer: Option<&str>,
|
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> {
|
) -> Result<reqwest::Response> {
|
||||||
let url = self.base_url.join(path).map_err(|err| Error::Malformed {
|
let url = self.base_url.join(path).map_err(|err| Error::Malformed {
|
||||||
provider: self.id.clone(),
|
provider: self.id.clone(),
|
||||||
@@ -244,11 +261,10 @@ impl OpenSubtitles {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::debug!(provider = %self.id, path, "OpenSubtitles request");
|
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(),
|
provider: self.id.clone(),
|
||||||
source: Box::new(err),
|
source: Box::new(err),
|
||||||
})?;
|
})
|
||||||
self.check_status(response).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch (or refetch) the user token downloads travel under.
|
/// Fetch (or refetch) the user token downloads travel under.
|
||||||
@@ -292,6 +308,34 @@ impl OpenSubtitles {
|
|||||||
self.login().await
|
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>> {
|
async fn search_inner(&self, request: &SearchRequest) -> Result<Vec<Candidate>> {
|
||||||
let hash =
|
let hash =
|
||||||
moviehash(&request.file.path, request.file.size).map_err(|source| Error::Io {
|
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> {
|
fn download<'a>(&'a self, id: &'a CandidateId) -> DownloadFuture<'a> {
|
||||||
Box::pin(async move { self.download_inner(id).await })
|
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> {
|
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> {
|
fn download<'a>(&'a self, id: &'a CandidateId) -> DownloadFuture<'a> {
|
||||||
Box::pin(async move { self.download_candidate(id).await })
|
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`].
|
/// Configuration for a [`Podnapisi`].
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
//! reference is always the media file itself; subtitle and video are both on
|
//! reference is always the media file itself; subtitle and video are both on
|
||||||
//! disk by the time this runs.
|
//! 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;
|
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.
|
/// How much of `alass`'s stderr is kept in an error.
|
||||||
const STDERR_LIMIT: usize = 512;
|
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.
|
/// Runs `alass` over one subtitle against its video.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Syncer {
|
pub struct Syncer {
|
||||||
@@ -74,6 +106,18 @@ impl Syncer {
|
|||||||
self
|
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.
|
/// Sync `subtitle` against `video`, in place on disk.
|
||||||
///
|
///
|
||||||
/// Returns the synced SRT text on acceptance; the input file is never
|
/// Returns the synced SRT text on acceptance; the input file is never
|
||||||
@@ -637,4 +681,31 @@ mod tests {
|
|||||||
"1 of 2 cues survived"
|
"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> =
|
pub type TranslateFuture<'a> =
|
||||||
Pin<Box<dyn Future<Output = Result<Vec<TranslatedCue>>> + Send + '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).
|
/// One translation backend (DESIGN.md §15).
|
||||||
///
|
///
|
||||||
/// Boxed futures rather than `async fn` for the same reason as
|
/// 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`].
|
/// Anything in [`Error`].
|
||||||
fn translate<'a>(&'a self, batch: &'a Batch) -> TranslateFuture<'a>;
|
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.
|
/// Result alias for translation.
|
||||||
@@ -424,7 +436,9 @@ mod tests {
|
|||||||
|
|
||||||
use arr_core::Language;
|
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;
|
use crate::srt::Cue;
|
||||||
|
|
||||||
/// Uppercases every cue and records how it was called; misbehaves on cue.
|
/// Uppercases every cue and records how it was called; misbehaves on cue.
|
||||||
@@ -466,6 +480,10 @@ mod tests {
|
|||||||
Ok(reply)
|
Ok(reply)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn probe(&self) -> ProbeFuture<'_> {
|
||||||
|
Box::pin(async move { Ok(()) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cues(texts: &[&str]) -> Vec<Cue> {
|
fn cues(texts: &[&str]) -> Vec<Cue> {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use std::{
|
|||||||
|
|
||||||
use arr_core::Language;
|
use arr_core::Language;
|
||||||
use arr_subs::translate::{translate, Error};
|
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> {
|
fn cues(texts: &[&str]) -> Vec<arr_subs::Cue> {
|
||||||
texts
|
texts
|
||||||
@@ -270,3 +270,25 @@ async fn an_edited_timeout_reaches_the_next_call() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT: Duration = arr_subs::COMMAND_DEFAULT_TIMEOUT;
|
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:?}"),
|
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:?}");
|
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