@@ -6,11 +6,15 @@
|
||||
//! the body carries the verdict, so a degraded service can still explain
|
||||
//! itself to the UI instead of looking like a fourth outage.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use arr_subs::{binary_present, translate as backend_error};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Whether the service as a whole can do its job.
|
||||
@@ -68,6 +72,44 @@ impl Check {
|
||||
}
|
||||
}
|
||||
|
||||
/// One enabled subtitle provider's verdict (#200).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
|
||||
pub struct ProviderCheck {
|
||||
/// The provider's id as settings name it.
|
||||
pub id: String,
|
||||
pub status: Status,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// The subtitle lane's verdicts (DESIGN.md §15, #200). Only what is actually
|
||||
/// in use appears here: providers nobody enabled and engines nobody selected
|
||||
/// cannot be broken.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
|
||||
pub struct SubtitleHealth {
|
||||
/// One entry per provider the settings enable, in that order.
|
||||
pub providers: Vec<ProviderCheck>,
|
||||
/// The selected translation engine, or `None` when none is chosen —
|
||||
/// which is a configuration state, not an outage.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub translation: Option<Check>,
|
||||
/// The `alass` sync binary, present at its configured path.
|
||||
pub alass: Check,
|
||||
/// The `ffmpeg` extraction binary, present at its configured path.
|
||||
pub ffmpeg: Check,
|
||||
}
|
||||
|
||||
impl SubtitleHealth {
|
||||
/// Every verdict the lane carries, for the overall status.
|
||||
fn statuses(&self) -> impl Iterator<Item = Status> + '_ {
|
||||
self.providers
|
||||
.iter()
|
||||
.map(|provider| provider.status)
|
||||
.chain(self.translation.iter().map(|check| check.status))
|
||||
.chain([self.alass.status, self.ffmpeg.status])
|
||||
}
|
||||
}
|
||||
|
||||
/// The body of `GET /api/health`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
|
||||
pub struct HealthReport {
|
||||
@@ -78,9 +120,11 @@ pub struct HealthReport {
|
||||
pub prowlarr: Check,
|
||||
pub transmission: Check,
|
||||
pub tmdb: Check,
|
||||
pub subtitles: SubtitleHealth,
|
||||
}
|
||||
|
||||
/// Report reachability of Prowlarr, Transmission and TMDB.
|
||||
/// Report reachability of Prowlarr, Transmission and TMDB, plus the
|
||||
/// subtitle upstreams (#200).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/health",
|
||||
@@ -90,17 +134,19 @@ pub struct HealthReport {
|
||||
),
|
||||
)]
|
||||
pub async fn health(State(state): State<AppState>) -> Json<HealthReport> {
|
||||
// Three independent network probes; serialising them would make the
|
||||
// endpoint as slow as the sum of the timeouts.
|
||||
let (prowlarr, transmission, tmdb) = tokio::join!(
|
||||
// Independent network probes; serialising them would make the endpoint
|
||||
// as slow as the sum of the timeouts.
|
||||
let (prowlarr, transmission, tmdb, subtitles) = tokio::join!(
|
||||
probe_prowlarr(&state),
|
||||
probe_transmission(&state),
|
||||
probe_tmdb(&state),
|
||||
probe_subtitles(&state)
|
||||
);
|
||||
|
||||
let status = if [prowlarr.status, transmission.status, tmdb.status]
|
||||
.iter()
|
||||
.all(|s| *s == Status::Ok)
|
||||
.into_iter()
|
||||
.chain(subtitles.statuses())
|
||||
.all(|check| check == Status::Ok)
|
||||
{
|
||||
Health::Ok
|
||||
} else {
|
||||
@@ -113,6 +159,7 @@ pub async fn health(State(state): State<AppState>) -> Json<HealthReport> {
|
||||
prowlarr,
|
||||
transmission,
|
||||
tmdb,
|
||||
subtitles,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -181,3 +228,316 @@ async fn probe_tmdb(state: &AppState) -> Check {
|
||||
fn describe(err: reqwest::Error) -> String {
|
||||
err.without_url().to_string()
|
||||
}
|
||||
|
||||
// ---- the subtitle lane (DESIGN.md §15, #200) -----------------------------
|
||||
|
||||
/// Probe every subtitle lamp: enabled providers, the selected engine, and
|
||||
/// the two binaries. The settings row decides what is *in use* — a provider
|
||||
/// nobody enabled or an engine nobody selected is not probed at all, so it
|
||||
/// cannot fail a lamp.
|
||||
async fn probe_subtitles(state: &AppState) -> SubtitleHealth {
|
||||
let settings = match state.database() {
|
||||
Some(_) => crate::subtitle_settings::load(state).await.ok(),
|
||||
None => None,
|
||||
};
|
||||
// No readable row means nothing is known to be in use; the lamps stay
|
||||
// quiet rather than failing on data the operator has not entered yet.
|
||||
let enabled = settings
|
||||
.as_ref()
|
||||
.map(|settings| settings.providers_enabled.as_slice())
|
||||
.unwrap_or_default();
|
||||
let engine = settings
|
||||
.as_ref()
|
||||
.and_then(|settings| settings.translation_engine.as_deref());
|
||||
|
||||
let mut probes = Vec::with_capacity(enabled.len());
|
||||
for id in enabled {
|
||||
probes.push(probe_provider(state, id).await);
|
||||
}
|
||||
let translation = match engine {
|
||||
Some(engine) => Some(probe_engine(state, engine).await),
|
||||
None => None,
|
||||
};
|
||||
|
||||
SubtitleHealth {
|
||||
providers: probes,
|
||||
translation,
|
||||
alass: binary_check("alass", state.syncer().binary_path()),
|
||||
ffmpeg: binary_check("ffmpeg", state.ffmpeg_binary()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reachable and credentials accepted, per enabled provider (#200).
|
||||
///
|
||||
/// A provider that is enabled but was never attached is unconfigured, not
|
||||
/// unreachable: its credentials are bootstrap config that this deployment
|
||||
/// simply does not have.
|
||||
async fn probe_provider(state: &AppState, id: &str) -> ProviderCheck {
|
||||
let check = match state.subtitle_provider(id) {
|
||||
Some(provider) => check_from(provider.probe().await),
|
||||
None => Check::unconfigured(format!(
|
||||
"{id} is enabled but not configured — credentials are bootstrap config"
|
||||
)),
|
||||
};
|
||||
ProviderCheck {
|
||||
id: id.to_owned(),
|
||||
status: check.status,
|
||||
detail: check.detail,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reachable, credentials accepted — for the remote-command backend,
|
||||
/// "reachable" means the command ran and exited cleanly (#200).
|
||||
async fn probe_engine(state: &AppState, engine: &str) -> Check {
|
||||
match state.translation_backend(engine) {
|
||||
Some(backend) => backend_check(backend.probe().await),
|
||||
None => Check::unconfigured(format!(
|
||||
"'{engine}' is selected but unavailable in this build"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// One probe verdict, whatever kind of upstream produced it. A refused key
|
||||
/// gets its own wording because it never resolves by retrying. The two error
|
||||
/// enums — providers' and backends' — carry the same shape for this purpose.
|
||||
fn check_from(result: Result<(), arr_subs::Error>) -> Check {
|
||||
match result {
|
||||
Ok(()) => Check::ok(),
|
||||
Err(arr_subs::Error::Unauthorized { .. }) => Check::unreachable("credentials refused"),
|
||||
Err(error) => Check::unreachable(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn backend_check(result: Result<(), backend_error::Error>) -> Check {
|
||||
match result {
|
||||
Ok(()) => Check::ok(),
|
||||
Err(backend_error::Error::Unauthorized { .. }) => Check::unreachable("credentials refused"),
|
||||
Err(error) => Check::unreachable(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A binary lamp (#200): present and executable at its configured path.
|
||||
/// Nothing is spawned — the endpoint is polled, and starting `ffmpeg` per
|
||||
/// poll would be neither cheap nor side-effect-free.
|
||||
fn binary_check(name: &str, binary: &OsStr) -> Check {
|
||||
if binary_present(binary) {
|
||||
Check::ok()
|
||||
} else {
|
||||
Check::unreachable(format!("{name} not found at {}", binary.to_string_lossy()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use arr_db::Db;
|
||||
use arr_subs::translate as backend;
|
||||
use arr_subs::{
|
||||
Backend, CandidateId, DownloadFuture, Provider, ProviderId, SearchFuture, SearchRequest,
|
||||
Syncer,
|
||||
};
|
||||
|
||||
use crate::{router, AppState, Upstreams};
|
||||
|
||||
/// A provider whose lamp is what `lamp` says — the probe under test is
|
||||
/// ours, so the stub never touches the network.
|
||||
#[derive(Debug)]
|
||||
struct StubProvider {
|
||||
id: &'static str,
|
||||
up: bool,
|
||||
}
|
||||
|
||||
impl Provider for StubProvider {
|
||||
fn id(&self) -> ProviderId {
|
||||
ProviderId::new(self.id)
|
||||
}
|
||||
|
||||
fn search<'a>(&'a self, _request: &'a SearchRequest) -> SearchFuture<'a> {
|
||||
Box::pin(async move { Ok(Vec::new()) })
|
||||
}
|
||||
|
||||
fn download<'a>(&'a self, _id: &'a CandidateId) -> DownloadFuture<'a> {
|
||||
unreachable!("health probes never download");
|
||||
}
|
||||
|
||||
fn probe(&self) -> arr_subs::ProbeFuture<'_> {
|
||||
let result = if self.up {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(arr_subs::Error::Unauthorized {
|
||||
provider: ProviderId::new(self.id),
|
||||
})
|
||||
};
|
||||
Box::pin(async move { result })
|
||||
}
|
||||
}
|
||||
|
||||
/// Same idea for the selected engine; the closure rebuilds its error per
|
||||
/// call because probing borrows.
|
||||
#[derive(Debug)]
|
||||
struct StubBackend {
|
||||
#[allow(dead_code)]
|
||||
detail: &'static str,
|
||||
}
|
||||
|
||||
impl Backend for StubBackend {
|
||||
fn id(&self) -> backend::BackendId {
|
||||
backend::BackendId::new("openai")
|
||||
}
|
||||
|
||||
fn supports(&self, _target: &arr_core::Language) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn translate<'a>(&'a self, _batch: &'a backend::Batch) -> backend::TranslateFuture<'a> {
|
||||
unreachable!("health probes never translate");
|
||||
}
|
||||
|
||||
fn probe(&self) -> backend::ProbeFuture<'_> {
|
||||
Box::pin(async move {
|
||||
Err(backend::Error::Transport {
|
||||
backend: backend::BackendId::new("openai"),
|
||||
source: "connection refused".into(),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve the app on an ephemeral port and return its base URL.
|
||||
async fn serve(state: AppState) -> String {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let address = listener.local_addr().expect("address");
|
||||
tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") });
|
||||
format!("http://{address}")
|
||||
}
|
||||
|
||||
/// An app with a migrated database and both binaries somewhere findable.
|
||||
/// The three classic upstreams point at a dead port; these tests read the
|
||||
/// subtitle lamps, not theirs.
|
||||
async fn application() -> (tempfile::TempDir, AppState) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let database = Db::connect(dir.path().join("arr.db"))
|
||||
.await
|
||||
.expect("connect");
|
||||
database.migrate().await.expect("migrate");
|
||||
let state = AppState::new(Upstreams::new(
|
||||
"http://127.0.0.1:1".into(),
|
||||
"http://127.0.0.1:1".into(),
|
||||
))
|
||||
.expect("state")
|
||||
.with_database(database)
|
||||
.with_syncer(Syncer::new().with_binary("sh"))
|
||||
.with_ffmpeg_binary("sh");
|
||||
(dir, state)
|
||||
}
|
||||
|
||||
async fn report(state: AppState) -> serde_json::Value {
|
||||
let base = serve(state).await;
|
||||
reqwest::get(format!("{base}/api/health"))
|
||||
.await
|
||||
.expect("request health")
|
||||
.json()
|
||||
.await
|
||||
.expect("health body")
|
||||
}
|
||||
|
||||
/// Replace the seeded enabled set. Runtime-checked rather than going
|
||||
/// through the settings API, whose validation refuses engines this test
|
||||
/// binary has not compiled in.
|
||||
async fn set_enabled(state: &AppState, providers: &str, engine: Option<&str>) {
|
||||
sqlx::query("UPDATE subtitle_settings SET providers_enabled = ?, translation_engine = ?")
|
||||
.bind(providers)
|
||||
.bind(engine)
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("settings update");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn without_a_settings_row_the_lane_is_quiet_and_binaries_still_checked() {
|
||||
// No database attached: nothing is known to be in use, so nothing
|
||||
// may fail a lamp — but a missing binary is a fact about the host.
|
||||
let state = AppState::new(Upstreams::new(
|
||||
"http://127.0.0.1:1".into(),
|
||||
"http://127.0.0.1:1".into(),
|
||||
))
|
||||
.expect("state")
|
||||
.with_syncer(Syncer::new().with_binary("sh"))
|
||||
.with_ffmpeg_binary("/nowhere/ffmpeg");
|
||||
|
||||
let body = report(state).await;
|
||||
assert_eq!(body["subtitles"]["providers"], serde_json::json!([]));
|
||||
assert!(body["subtitles"]["translation"].is_null());
|
||||
assert_eq!(body["subtitles"]["alass"]["status"], "ok");
|
||||
assert_eq!(body["subtitles"]["ffmpeg"]["status"], "unreachable");
|
||||
assert_eq!(body["status"], "degraded");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_enabled_provider_without_credentials_is_unconfigured() {
|
||||
let (_dir, state) = application().await;
|
||||
// The seed row enables opensubtitles and podnapisi; none are attached.
|
||||
let body = report(state).await;
|
||||
assert_eq!(
|
||||
body["subtitles"]["providers"],
|
||||
serde_json::json!([
|
||||
{ "id": "opensubtitles", "status": "unconfigured",
|
||||
"detail": "opensubtitles is enabled but not configured — credentials are bootstrap config" },
|
||||
{ "id": "podnapisi", "status": "unconfigured",
|
||||
"detail": "podnapisi is enabled but not configured — credentials are bootstrap config" },
|
||||
])
|
||||
);
|
||||
assert_eq!(body["status"], "degraded");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_attached_provider_lamp_follows_its_probe() {
|
||||
let (_dir, state) = application().await;
|
||||
set_enabled(&state, r#"["ok","bad"]"#, None).await;
|
||||
let state = state.with_subtitle_providers(vec![
|
||||
Arc::new(StubProvider { id: "ok", up: true }),
|
||||
Arc::new(StubProvider {
|
||||
id: "bad",
|
||||
up: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
let body = report(state).await;
|
||||
assert_eq!(body["subtitles"]["providers"][0]["id"], "ok");
|
||||
assert_eq!(body["subtitles"]["providers"][0]["status"], "ok");
|
||||
assert_eq!(body["subtitles"]["providers"][1]["id"], "bad");
|
||||
assert_eq!(body["subtitles"]["providers"][1]["status"], "unreachable");
|
||||
assert_eq!(
|
||||
body["subtitles"]["providers"][1]["detail"],
|
||||
"credentials refused"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_selected_engine_is_probed_only_when_selected() {
|
||||
let (_dir, state) = application().await;
|
||||
let body = report(state.clone()).await;
|
||||
assert!(body["subtitles"]["translation"].is_null(), "{body}");
|
||||
|
||||
set_enabled(&state, "[]", Some("openai")).await;
|
||||
// Selected but never attached: unavailable in this deployment.
|
||||
let body = report(state).await;
|
||||
assert_eq!(body["subtitles"]["translation"]["status"], "unconfigured");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unreachable_engine_fails_the_whole_report() {
|
||||
let (_dir, state) = application().await;
|
||||
set_enabled(&state, "[]", Some("openai")).await;
|
||||
let state = state.with_translation_backends(vec![Arc::new(StubBackend {
|
||||
detail: "connection refused",
|
||||
})]);
|
||||
|
||||
let body = report(state).await;
|
||||
assert_eq!(body["subtitles"]["translation"]["status"], "unreachable");
|
||||
assert_eq!(body["status"], "degraded");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,11 @@ mod tests {
|
||||
.with_tmdb_url(tmdb.uri())
|
||||
.with_tmdb_api_key(Some("key".into())),
|
||||
)
|
||||
.expect("state");
|
||||
.expect("state")
|
||||
// The subtitle binaries default to `PATH`; pin them to something
|
||||
// every machine has so this test stays about the classic upstreams.
|
||||
.with_syncer(arr_subs::Syncer::new().with_binary("sh"))
|
||||
.with_ffmpeg_binary("sh");
|
||||
|
||||
let body = report(state).await;
|
||||
assert_eq!(body["status"], "ok");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! What the API needs to answer a request: one HTTP client and the addresses
|
||||
//! of the three upstreams the service cannot work without (DESIGN.md §3).
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::sync::{atomic::AtomicU64, Arc};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -14,6 +15,10 @@ use crate::jellyfin::JellyfinClient;
|
||||
/// is configurable, so this is a constant that tests point elsewhere.
|
||||
pub const DEFAULT_TMDB_URL: &str = "https://api.themoviedb.org/3";
|
||||
|
||||
/// The `ffmpeg` invoked when nothing else is configured — same default as
|
||||
/// `arr-probe`'s extractor, which is where it is actually run.
|
||||
pub const DEFAULT_FFMPEG_BINARY: &str = "ffmpeg";
|
||||
|
||||
/// How long an upstream has to answer a probe before it counts as
|
||||
/// unreachable. Health is polled by a human waiting on a page.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
@@ -85,6 +90,8 @@ pub struct AppState {
|
||||
/// `None` unless the daemon compiled and configured that backend; the
|
||||
/// settings API writes it on every edit of the row.
|
||||
command_timeout: Option<Arc<AtomicU64>>,
|
||||
/// The configured `ffmpeg` binary, for the health lamps (#200).
|
||||
ffmpeg_binary: OsString,
|
||||
jellyfin: Option<JellyfinClient>,
|
||||
syncer: Syncer,
|
||||
}
|
||||
@@ -160,6 +167,7 @@ impl AppState {
|
||||
subtitle_providers: Arc::new(Vec::new()),
|
||||
translation_backends: Arc::new(Vec::new()),
|
||||
command_timeout: None,
|
||||
ffmpeg_binary: DEFAULT_FFMPEG_BINARY.into(),
|
||||
jellyfin: None,
|
||||
syncer: Syncer::default(),
|
||||
})
|
||||
@@ -245,6 +253,19 @@ impl AppState {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach the configured `ffmpeg` binary, for the health lamps (#200).
|
||||
/// Defaults to resolving `ffmpeg` from `PATH`.
|
||||
#[must_use]
|
||||
pub fn with_ffmpeg_binary(mut self, binary: impl Into<OsString>) -> Self {
|
||||
self.ffmpeg_binary = binary.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// The configured `ffmpeg` binary.
|
||||
pub(crate) fn ffmpeg_binary(&self) -> &OsStr {
|
||||
&self.ffmpeg_binary
|
||||
}
|
||||
|
||||
pub(crate) fn syncer(&self) -> &Syncer {
|
||||
&self.syncer
|
||||
}
|
||||
|
||||
@@ -153,7 +153,9 @@ impl SettingsColumns {
|
||||
}
|
||||
}
|
||||
|
||||
async fn load(state: &AppState) -> Result<SubtitleSettings, ApiError> {
|
||||
/// Read the row and parse it. Shared with the health lamps (#200), which
|
||||
/// need the enabled set and the chosen engine but not the budgets.
|
||||
pub(crate) async fn load(state: &AppState) -> Result<SubtitleSettings, ApiError> {
|
||||
let row = sqlx::query_as!(
|
||||
SettingsColumns,
|
||||
r#"SELECT wanted_languages AS "wanted_languages!: String",
|
||||
|
||||
@@ -1714,6 +1714,10 @@ mod tests {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn probe(&self) -> arr_subs::ProbeFuture<'_> {
|
||||
Box::pin(async move { Ok(()) })
|
||||
}
|
||||
}
|
||||
|
||||
/// A provider that is configured but never answers.
|
||||
@@ -1741,6 +1745,14 @@ mod tests {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn probe(&self) -> arr_subs::ProbeFuture<'_> {
|
||||
Box::pin(async move {
|
||||
Err(arr_subs::Error::Unauthorized {
|
||||
provider: ProviderId::new("dead"),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Uppercases every cue. Enough to prove the pipeline, and it keeps cue
|
||||
@@ -1769,6 +1781,10 @@ mod tests {
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
fn probe(&self) -> arr_subs::translate::ProbeFuture<'_> {
|
||||
Box::pin(async move { Ok(()) })
|
||||
}
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
|
||||
+204
-13
@@ -1,5 +1,5 @@
|
||||
//! §9.5 *broken* → the operator alone: Prowlarr, Transmission or TMDB
|
||||
//! unreachable.
|
||||
//! unreachable, or a subtitle lamp failing (#200).
|
||||
//!
|
||||
//! Edge-triggered: notifies once when an upstream stops answering, and
|
||||
//! silently re-arms once it answers again. There is no "fixed" notification —
|
||||
@@ -29,52 +29,121 @@ pub struct Upstreams {
|
||||
pub tmdb_api_key: Option<String>,
|
||||
}
|
||||
|
||||
/// The subtitle upstreams (#200): the providers and engines this deployment
|
||||
/// has credentials for, and the two binaries with their configured paths.
|
||||
///
|
||||
/// What is *in use* is read from the settings row per tick; this only holds
|
||||
/// what could ever answer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubtitleUpstreams {
|
||||
pub providers: Vec<Arc<dyn arr_subs::Provider>>,
|
||||
pub backends: Vec<Arc<dyn arr_subs::Backend>>,
|
||||
pub alass_path: std::path::PathBuf,
|
||||
pub ffmpeg_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl SubtitleUpstreams {
|
||||
/// The lamps as `(name, reachable)` pairs. Only what the settings row
|
||||
/// has in use is probed — a provider nobody enabled cannot be broken.
|
||||
async fn probe(&self, database: &Db) -> Vec<(String, bool)> {
|
||||
let (providers_enabled, engine) = match crate::subtitles::load_settings(database).await {
|
||||
Ok(settings) => (settings.providers_enabled, settings.translation_engine),
|
||||
// An unreadable row says nothing about any upstream; skipping the
|
||||
// whole lane beats notifying on our own database.
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "subtitle settings unreadable; subtitle lamps skipped");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let mut lamps = Vec::new();
|
||||
for id in &providers_enabled {
|
||||
let reachable = match self
|
||||
.providers
|
||||
.iter()
|
||||
.find(|p| p.id().as_str() == id.as_str())
|
||||
{
|
||||
Some(provider) => provider.probe().await.is_ok(),
|
||||
None => false,
|
||||
};
|
||||
lamps.push((id.clone(), reachable));
|
||||
}
|
||||
if let Some(engine) = engine {
|
||||
let reachable = match self.backends.iter().find(|b| b.id().as_str() == engine) {
|
||||
Some(backend) => backend.probe().await.is_ok(),
|
||||
None => false,
|
||||
};
|
||||
lamps.push((engine, reachable));
|
||||
}
|
||||
lamps.push((
|
||||
"alass".to_owned(),
|
||||
arr_subs::binary_present(std::ffi::OsStr::new(&self.alass_path)),
|
||||
));
|
||||
lamps.push((
|
||||
"ffmpeg".to_owned(),
|
||||
arr_subs::binary_present(std::ffi::OsStr::new(&self.ffmpeg_path)),
|
||||
));
|
||||
lamps
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BrokenAction {
|
||||
http: Client,
|
||||
upstreams: Upstreams,
|
||||
subtitles: SubtitleUpstreams,
|
||||
notifier: Notifier,
|
||||
operator_topic: String,
|
||||
/// Which upstreams are currently notified as broken. Transient — a
|
||||
/// restart re-probes and re-notifies whatever is still down.
|
||||
broken: Arc<Mutex<HashSet<&'static str>>>,
|
||||
broken: Arc<Mutex<HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl BrokenAction {
|
||||
#[must_use]
|
||||
pub fn new(upstreams: Upstreams, notifier: Notifier, operator_topic: String) -> Self {
|
||||
pub fn new(
|
||||
upstreams: Upstreams,
|
||||
subtitles: SubtitleUpstreams,
|
||||
notifier: Notifier,
|
||||
operator_topic: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
http: Client::new(),
|
||||
upstreams,
|
||||
subtitles,
|
||||
notifier,
|
||||
operator_topic,
|
||||
broken: Arc::new(Mutex::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn tick(&self) -> Vec<Outcome> {
|
||||
let (prowlarr, transmission, tmdb) = tokio::join!(
|
||||
async fn tick(&self, database: &Db) -> Vec<Outcome> {
|
||||
let (prowlarr, transmission, tmdb, subtitles) = tokio::join!(
|
||||
self.probe_prowlarr(),
|
||||
self.probe_transmission(),
|
||||
self.probe_tmdb(),
|
||||
self.subtitles.probe(database),
|
||||
);
|
||||
|
||||
let mut outcomes = Vec::new();
|
||||
outcomes.extend(self.notify_transition("prowlarr", prowlarr).await);
|
||||
outcomes.extend(self.notify_transition("transmission", transmission).await);
|
||||
outcomes.extend(self.notify_transition("tmdb", tmdb).await);
|
||||
for (name, reachable) in subtitles {
|
||||
outcomes.extend(self.notify_transition(&name, reachable).await);
|
||||
}
|
||||
outcomes
|
||||
}
|
||||
|
||||
/// `reachable` is `true` when the upstream answered, or when it needs no
|
||||
/// key and none is configured (not an outage — see `probe_tmdb`).
|
||||
async fn notify_transition(&self, name: &'static str, reachable: bool) -> Option<Outcome> {
|
||||
async fn notify_transition(&self, name: &str, reachable: bool) -> Option<Outcome> {
|
||||
let mut broken = self.broken.lock().await;
|
||||
if reachable {
|
||||
broken.remove(name);
|
||||
return None;
|
||||
}
|
||||
if !broken.insert(name) {
|
||||
if !broken.insert(name.to_owned()) {
|
||||
return None;
|
||||
}
|
||||
match self
|
||||
@@ -153,8 +222,8 @@ impl Action for BrokenAction {
|
||||
"broken"
|
||||
}
|
||||
|
||||
fn run<'a>(&'a self, _database: &'a Db) -> ActionFuture<'a> {
|
||||
Box::pin(async move { Ok(self.tick().await) })
|
||||
fn run<'a>(&'a self, database: &'a Db) -> ActionFuture<'a> {
|
||||
Box::pin(async move { Ok(self.tick(database).await) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +245,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A migrated database whose settings enable nothing — the classic
|
||||
/// upstreams under test here must not share the tick with subtitle
|
||||
/// lamps the seed row would otherwise put in use.
|
||||
async fn database() -> (tempfile::TempDir, Db) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = Db::connect(dir.path().join("broken-test.db"))
|
||||
.await
|
||||
.unwrap();
|
||||
db.migrate().await.unwrap();
|
||||
sqlx::query("UPDATE subtitle_settings SET providers_enabled = '[]'")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
(dir, db)
|
||||
}
|
||||
|
||||
fn subtitles() -> SubtitleUpstreams {
|
||||
SubtitleUpstreams {
|
||||
providers: Vec::new(),
|
||||
backends: Vec::new(),
|
||||
// Present on every machine that runs these tests.
|
||||
alass_path: "sh".into(),
|
||||
ffmpeg_path: "sh".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unreachable_upstream_notifies_once_then_re_arms() {
|
||||
let prowlarr = MockServer::start().await;
|
||||
@@ -192,14 +287,16 @@ mod tests {
|
||||
.mount(&ntfy)
|
||||
.await;
|
||||
|
||||
let (_dir, db) = database().await;
|
||||
let action = BrokenAction::new(
|
||||
upstreams(prowlarr.uri(), transmission.uri()),
|
||||
subtitles(),
|
||||
Notifier::new(ntfy.uri()).unwrap(),
|
||||
"operator-topic".to_string(),
|
||||
);
|
||||
|
||||
let first = action.tick().await;
|
||||
let second = action.tick().await;
|
||||
let first = action.tick(&db).await;
|
||||
let second = action.tick(&db).await;
|
||||
|
||||
assert_eq!(first.len(), 1, "notifies on the tick it goes unreachable");
|
||||
assert_eq!(second.len(), 0, "does not repeat while still broken");
|
||||
@@ -209,12 +306,106 @@ mod tests {
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&prowlarr)
|
||||
.await;
|
||||
let recovered = action.tick().await;
|
||||
let recovered = action.tick(&db).await;
|
||||
assert_eq!(recovered.len(), 0, "recovery is silent, no fourth event");
|
||||
|
||||
// Take it down again: a fresh outage re-arms and notifies again.
|
||||
prowlarr.reset().await;
|
||||
let broken_again = action.tick().await;
|
||||
let broken_again = action.tick(&db).await;
|
||||
assert_eq!(broken_again.len(), 1, "re-arms after recovering");
|
||||
}
|
||||
|
||||
/// #200: an enabled provider nobody configured is a broken lamp like any
|
||||
/// other, and it notifies once — then stays quiet while it stays broken.
|
||||
#[tokio::test]
|
||||
async fn an_enabled_but_missing_subtitle_provider_notifies_once() {
|
||||
let ntfy = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&ntfy)
|
||||
.await;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = Db::connect(dir.path().join("broken-subs.db"))
|
||||
.await
|
||||
.unwrap();
|
||||
db.migrate().await.unwrap();
|
||||
// The seed row already enables opensubtitles and podnapisi; none are
|
||||
// attached to this action.
|
||||
let prowlarr = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/ping"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&prowlarr)
|
||||
.await;
|
||||
let transmission = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(409))
|
||||
.mount(&transmission)
|
||||
.await;
|
||||
|
||||
let action = BrokenAction::new(
|
||||
upstreams(prowlarr.uri(), transmission.uri()),
|
||||
subtitles(),
|
||||
Notifier::new(ntfy.uri()).unwrap(),
|
||||
"operator-topic".to_string(),
|
||||
);
|
||||
|
||||
let first = action.tick(&db).await;
|
||||
let second = action.tick(&db).await;
|
||||
|
||||
assert_eq!(first.len(), 2, "one lamp per missing provider");
|
||||
assert_eq!(second.len(), 0, "does not repeat while still broken");
|
||||
|
||||
// Attaching nothing but disabling them silences the lamps.
|
||||
sqlx::query("UPDATE subtitle_settings SET providers_enabled = '[]'")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let third = action.tick(&db).await;
|
||||
assert_eq!(third.len(), 0, "a disabled provider cannot be broken");
|
||||
}
|
||||
|
||||
/// #200: binaries are judged at their configured paths.
|
||||
#[tokio::test]
|
||||
async fn a_missing_binary_is_a_broken_lamp() {
|
||||
let ntfy = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&ntfy)
|
||||
.await;
|
||||
|
||||
let (_dir, db) = database().await;
|
||||
let prowlarr = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/ping"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&prowlarr)
|
||||
.await;
|
||||
let transmission = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(409))
|
||||
.mount(&transmission)
|
||||
.await;
|
||||
|
||||
let subs = SubtitleUpstreams {
|
||||
alass_path: "/nowhere/alass".into(),
|
||||
..subtitles()
|
||||
};
|
||||
let action = BrokenAction::new(
|
||||
upstreams(prowlarr.uri(), transmission.uri()),
|
||||
subs,
|
||||
Notifier::new(ntfy.uri()).unwrap(),
|
||||
"operator-topic".to_string(),
|
||||
);
|
||||
|
||||
let first = action.tick(&db).await;
|
||||
assert_eq!(first.len(), 1, "only the missing binary notifies");
|
||||
assert!(
|
||||
first
|
||||
.iter()
|
||||
.any(|outcome| format!("{outcome:?}").contains("alass")),
|
||||
"{first:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +388,33 @@ fn reconcile_loop(
|
||||
Tick::Reconcile,
|
||||
AttentionAction::new(notifier.clone(), operator_topic.clone()),
|
||||
);
|
||||
reconcile = register_broken(
|
||||
reconcile,
|
||||
config,
|
||||
translators,
|
||||
notifier,
|
||||
operator_topic.clone(),
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"ARR_NTFY_OPERATOR_TOPIC is not configured: needs-a-decision and broken notifications are disabled"
|
||||
);
|
||||
}
|
||||
|
||||
let reconcile = reconcile.register(Tick::Reaper, ReaperAction::new(transmission.clone()));
|
||||
Ok((reconcile, manual_grab, manual_tv))
|
||||
}
|
||||
|
||||
/// The *broken* lane (#200 included): Prowlarr, Transmission and TMDB, plus
|
||||
/// the subtitle lamps — an enabled provider, the selected engine, or a
|
||||
/// missing `alass`/`ffmpeg` all fold into the same operator message.
|
||||
fn register_broken(
|
||||
reconcile: ReconcileLoop,
|
||||
config: &Config,
|
||||
translators: &Translators,
|
||||
notifier: &Notifier,
|
||||
operator_topic: String,
|
||||
) -> ReconcileLoop {
|
||||
let broken_upstreams = broken::Upstreams {
|
||||
prowlarr_url: config.prowlarr_url.clone(),
|
||||
prowlarr_api_key: config.prowlarr_api_key.clone(),
|
||||
@@ -398,18 +425,25 @@ fn reconcile_loop(
|
||||
.unwrap_or_else(|| arr_api::DEFAULT_TMDB_URL.to_string()),
|
||||
tmdb_api_key: config.tmdb_api_key.clone(),
|
||||
};
|
||||
reconcile = reconcile.register(
|
||||
let broken_subtitles = broken::SubtitleUpstreams {
|
||||
providers: subtitle_providers(
|
||||
config.opensubtitles_api_key.clone(),
|
||||
config.opensubtitles_username.clone(),
|
||||
config.opensubtitles_password.clone(),
|
||||
),
|
||||
backends: translators.backends.clone(),
|
||||
alass_path: config.alass_path.clone(),
|
||||
ffmpeg_path: config.ffmpeg_path.clone(),
|
||||
};
|
||||
reconcile.register(
|
||||
Tick::Reconcile,
|
||||
BrokenAction::new(broken_upstreams, notifier.clone(), operator_topic.clone()),
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"ARR_NTFY_OPERATOR_TOPIC is not configured: needs-a-decision and broken notifications are disabled"
|
||||
);
|
||||
}
|
||||
|
||||
let reconcile = reconcile.register(Tick::Reaper, ReaperAction::new(transmission.clone()));
|
||||
Ok((reconcile, manual_grab, manual_tv))
|
||||
BrokenAction::new(
|
||||
broken_upstreams,
|
||||
broken_subtitles,
|
||||
notifier.clone(),
|
||||
operator_topic,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Register the TV grab lane on `reconcile` and hand back a second,
|
||||
|
||||
@@ -57,10 +57,10 @@ pub enum SubtitleError {
|
||||
|
||||
/// The runtime-editable half of §15's configuration, read once per tick.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Settings {
|
||||
pub(crate) struct Settings {
|
||||
wanted: Vec<String>,
|
||||
providers_enabled: BTreeSet<String>,
|
||||
translation_engine: Option<String>,
|
||||
pub(crate) providers_enabled: BTreeSet<String>,
|
||||
pub(crate) translation_engine: Option<String>,
|
||||
/// Provider id -> daily download allowance (§15, #197). A name absent
|
||||
/// from the map has no cap configured, which is unlimited (§10), not
|
||||
/// zero.
|
||||
@@ -80,7 +80,9 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_settings(database: &Db) -> Result<Settings, SubtitleError> {
|
||||
/// Shared with the broken-upstream action (#200), which needs the enabled
|
||||
/// set and the chosen engine per tick.
|
||||
pub(crate) async fn load_settings(database: &Db) -> Result<Settings, SubtitleError> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT wanted_languages AS "wanted_languages!: String",
|
||||
providers_enabled AS "providers_enabled!: String",
|
||||
@@ -1273,6 +1275,10 @@ mod tests {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn probe(&self) -> arr_subs::ProbeFuture<'_> {
|
||||
Box::pin(async move { Ok(()) })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -1299,6 +1305,10 @@ mod tests {
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
fn probe(&self) -> arr_subs::translate::ProbeFuture<'_> {
|
||||
Box::pin(async move { Ok(()) })
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate(provider: &str, id: &str, language: Language) -> Candidate {
|
||||
|
||||
@@ -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,38 @@ 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,12 @@ 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
|
||||
@@ -639,4 +683,29 @@ 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:?}");
|
||||
}
|
||||
|
||||
@@ -446,6 +446,8 @@
|
||||
<p class="module-detail readout" data-role="detail">rpc · grab and seed</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="chain-lane" id="chain-subs" aria-label="subtitle lane" hidden></section>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="group-roots" hidden aria-labelledby="label-roots">
|
||||
|
||||
@@ -9,12 +9,28 @@ export interface Check {
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** One enabled subtitle provider (#200). */
|
||||
export interface ProviderCheck {
|
||||
id: string;
|
||||
status: CheckStatus;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** The subtitle lane: providers in use, the selected engine, the binaries. */
|
||||
export interface SubtitleHealth {
|
||||
providers: ProviderCheck[];
|
||||
translation?: Check | null;
|
||||
alass: Check;
|
||||
ffmpeg: Check;
|
||||
}
|
||||
|
||||
export interface HealthReport {
|
||||
status: "ok" | "degraded";
|
||||
version: string;
|
||||
prowlarr: Check;
|
||||
transmission: Check;
|
||||
tmdb: Check;
|
||||
subtitles: SubtitleHealth;
|
||||
}
|
||||
|
||||
export type Probe =
|
||||
|
||||
+59
-1
@@ -1,4 +1,10 @@
|
||||
import { type CheckStatus, type Probe, probeHealth } from "./health";
|
||||
import {
|
||||
type Check,
|
||||
type CheckStatus,
|
||||
type Probe,
|
||||
probeHealth,
|
||||
type SubtitleHealth,
|
||||
} from "./health";
|
||||
import {
|
||||
fetchLibrary,
|
||||
type LibrarySeries,
|
||||
@@ -171,6 +177,54 @@ function setModule(refs: ModuleRefs, state: string, tone: Tone, word: string, de
|
||||
refs.detail.textContent = detail ?? refs.defaultDetail;
|
||||
}
|
||||
|
||||
/** One lamp of the subtitle lane (#200): same module shape as the chain,
|
||||
* rendered per poll — which providers are in use is a settings row, not
|
||||
* markup. */
|
||||
function subtitleModule(name: string, role: string, check: Check): HTMLElement {
|
||||
const article = document.createElement("article");
|
||||
article.className = "module";
|
||||
|
||||
const head = document.createElement("header");
|
||||
head.className = "module-head";
|
||||
const lamp = document.createElement("span");
|
||||
lamp.className = "lamp";
|
||||
lamp.dataset.state = check.status;
|
||||
const title = document.createElement("h2");
|
||||
title.className = "module-name";
|
||||
title.textContent = name;
|
||||
const status = document.createElement("span");
|
||||
status.className = "module-status readout";
|
||||
status.dataset.tone = TONE_BY_STATUS[check.status];
|
||||
status.textContent = check.status;
|
||||
head.append(lamp, title, status);
|
||||
|
||||
const roleLine = document.createElement("p");
|
||||
roleLine.className = "module-role";
|
||||
roleLine.textContent = role;
|
||||
const detail = document.createElement("p");
|
||||
detail.className = "module-detail readout";
|
||||
detail.textContent = check.detail ?? "";
|
||||
|
||||
article.append(head, roleLine, detail);
|
||||
return article;
|
||||
}
|
||||
|
||||
function renderSubtitles(subLane: HTMLElement, subs: SubtitleHealth) {
|
||||
const entries: { name: string; role: string; check: Check }[] = subs.providers.map(
|
||||
(provider) => ({ name: provider.id, role: "subtitles in", check: provider }),
|
||||
);
|
||||
if (subs.translation) {
|
||||
entries.push({ name: "translate", role: "engine out", check: subs.translation });
|
||||
}
|
||||
entries.push({ name: "alass", role: "timing sync", check: subs.alass });
|
||||
entries.push({ name: "ffmpeg", role: "track extraction", check: subs.ffmpeg });
|
||||
|
||||
subLane.replaceChildren(
|
||||
...entries.map((entry) => subtitleModule(entry.name, entry.role, entry.check)),
|
||||
);
|
||||
subLane.hidden = false;
|
||||
}
|
||||
|
||||
function must<T extends Element>(selector: string): T {
|
||||
const element = document.querySelector<T>(selector);
|
||||
if (!element) {
|
||||
@@ -181,6 +235,7 @@ function must<T extends Element>(selector: string): T {
|
||||
|
||||
function main() {
|
||||
const chain = must<HTMLElement>(".chain");
|
||||
const subLane = must<HTMLElement>("#chain-subs");
|
||||
const masterLamp = must<HTMLElement>("#master-lamp");
|
||||
const version = must<HTMLElement>("#version");
|
||||
|
||||
@@ -235,6 +290,8 @@ function main() {
|
||||
for (const name of ["tmdb", "prowlarr", "master"] as const) {
|
||||
setTrace(name, null);
|
||||
}
|
||||
subLane.hidden = true;
|
||||
subLane.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -259,6 +316,7 @@ function main() {
|
||||
setTrace(name, TONE_BY_STATUS[check.status]);
|
||||
}
|
||||
}
|
||||
renderSubtitles(subLane, report.subtitles);
|
||||
}
|
||||
|
||||
let inFlight = false;
|
||||
|
||||
@@ -315,6 +315,17 @@ body {
|
||||
|
||||
/* ---- modules --------------------------------------------------------- */
|
||||
|
||||
/* The subtitle lane (#200): one lamp per upstream actually in use, rendered
|
||||
by the health poll under the main chain. Same module vocabulary, no bus —
|
||||
these feed the reconcile loop, not each other. */
|
||||
.chain-lane {
|
||||
margin-top: var(--space-6);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
gap: var(--space-4);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.module {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
|
||||
Reference in New Issue
Block a user