use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use async_trait::async_trait;
use serde::Deserialize;
use super::decopy::cue;
use super::{Format, Provider, SubtitleFormat, SubtitleInfo};
use crate::error::{AppError, AppResult, NoSubtitleReason};
use crate::secret_endpoints::{noiz_api_base, noiz_api_host, noiz_subtitles_path};
pub const PROVIDER_NAME: &str = "provider-noiz";
const DEFAULT_NOIZ_REQUEST_TIMEOUT_SECS: u64 = 60;
fn noiz_request_timeout() -> Duration {
Duration::from_secs(crate::config::tuning_u64_in_range(
"providers.noiz.request_timeout_secs",
DEFAULT_NOIZ_REQUEST_TIMEOUT_SECS,
1,
3_600,
))
}
const DEFAULT_NOIZ_MAX_BODY_BYTES: usize = 16 * 1024 * 1024;
fn noiz_max_body_bytes() -> usize {
crate::config::tuning_usize_in_range(
"providers.noiz.max_body_bytes",
DEFAULT_NOIZ_MAX_BODY_BYTES,
1_024,
1_073_741_824,
)
}
const PARAM_VIDEO_ID: &str = "video_id";
const PARAM_LANGUAGE: &str = "language";
#[derive(Debug, Deserialize)]
struct NoizEnvelope {
#[serde(default)]
transcript_parts: Vec<NoizPart>,
}
#[derive(Debug, Deserialize)]
struct NoizPart {
#[serde(default)]
start: f64,
#[serde(default)]
duration: f64,
#[serde(default)]
text: String,
}
pub struct ProviderNoiz {
base_url: String,
cache: Mutex<HashMap<String, Vec<u8>>>,
}
impl Default for ProviderNoiz {
fn default() -> Self {
Self::new()
}
}
impl ProviderNoiz {
#[must_use]
#[tracing::instrument(level = "debug")]
pub fn new() -> Self {
Self {
base_url: noiz_api_base(),
cache: Mutex::new(HashMap::new()),
}
}
#[must_use]
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
fn subtitles_url(&self) -> String {
format!(
"{}{}",
self.base_url.trim_end_matches('/'),
noiz_subtitles_path()
)
}
fn subtitles_url_for(&self, video_id: &str, wire_language: &str) -> String {
let query = url::form_urlencoded::Serializer::new(String::new())
.append_pair(PARAM_VIDEO_ID, video_id)
.append_pair(PARAM_LANGUAGE, wire_language)
.finish();
format!("{}?{}", self.subtitles_url(), query)
}
}
pub(crate) fn wire_language(tag: &str) -> String {
tag.trim().replace('_', "-").to_ascii_uppercase()
}
fn envelope_to_srt(envelope: NoizEnvelope) -> AppResult<String> {
let cues: Vec<cue::Cue> = envelope
.transcript_parts
.into_iter()
.map(|p| cue::Cue {
start_secs: p.start,
end_secs: p.start + p.duration.max(0.0),
text: p.text.trim().to_string(),
})
.collect();
let srt = cue::render_srt(&cues);
if srt.is_empty() {
return Err(AppError::NoSubtitle(NoSubtitleReason::LanguageUnavailable));
}
Ok(srt)
}
fn classify_status(status: reqwest::StatusCode, headers: &reqwest::header::HeaderMap) -> AppError {
if status == reqwest::StatusCode::NOT_FOUND {
return AppError::NoSubtitle(NoSubtitleReason::LanguageUnavailable);
}
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
tracing::warn!(
target: "events",
provider = PROVIDER_NAME,
"noiz daily quota exhausted; degrading"
);
}
super::http_failure(status, headers, PROVIDER_NAME)
}
#[async_trait]
impl Provider for ProviderNoiz {
fn name(&self) -> &'static str {
PROVIDER_NAME
}
async fn fetch_subtitle(
&self,
video_id: &str,
language: &str,
_format: Format,
) -> AppResult<SubtitleInfo> {
if crate::provider::is_offline() {
return Err(AppError::ProviderUnavailable {
provider: PROVIDER_NAME,
});
}
crate::provider::robots::check_allowed(
&noiz_api_host(),
&noiz_subtitles_path(),
&crate::net::user_agent(),
PROVIDER_NAME,
)
.await?;
let requested = crate::cli::LanguageArg::parse(language)?;
let tag = requested.as_str().to_string();
let wire = wire_language(&tag);
let client = crate::net::session::chrome_client(noiz_request_timeout())?;
tracing::debug!(
target: "events",
provider = PROVIDER_NAME,
video_id,
language = %wire,
"fetch_subtitle_started"
);
let response = client
.get(self.subtitles_url_for(video_id, &wire))
.send()
.await
.map_err(AppError::Http)?;
let status = response.status();
if !status.is_success() {
return Err(classify_status(status, response.headers()));
}
let raw = response.text().await.map_err(AppError::Http)?;
if raw.len() > noiz_max_body_bytes() {
return Err(AppError::SubtitleTooLarge(raw.len()));
}
let envelope: NoizEnvelope =
serde_json::from_str(&raw).map_err(|e| AppError::ProviderProtocolError {
provider: PROVIDER_NAME,
detail: format!("returned a body this crate cannot model: {e}"),
})?;
let srt = envelope_to_srt(envelope)?;
let source_url = format!("noiz://{video_id}/{tag}/srt");
self.cache
.lock()
.map_err(|_| AppError::Internal("noiz cache poisoned".to_string()))?
.insert(source_url.clone(), srt.clone().into_bytes());
tracing::debug!(
target: "events",
provider = PROVIDER_NAME,
video_id,
language = %tag,
body_bytes = srt.len(),
"fetch_subtitle_completed"
);
Ok(SubtitleInfo {
video_id: video_id.to_string(),
language: tag,
delivered_language: None,
format: Format::Srt,
source_url,
byte_size: srt.len(),
format_hint: SubtitleFormat::Srt,
provider: PROVIDER_NAME,
})
}
async fn fetch_content(&self, info: &SubtitleInfo) -> AppResult<Vec<u8>> {
let bytes = self
.cache
.lock()
.map_err(|_| AppError::Internal("noiz cache poisoned".to_string()))?
.get(&info.source_url)
.cloned();
match bytes {
Some(b) if !b.is_empty() => Ok(b),
_ => Err(AppError::NoSubtitle(NoSubtitleReason::NotPublished)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_name_is_stable() {
assert_eq!(ProviderNoiz::new().name(), PROVIDER_NAME);
}
#[test]
fn wire_language_upper_cases_and_keeps_the_region() {
assert_eq!(wire_language("pt-BR"), "PT-BR");
assert_eq!(wire_language("en"), "EN");
}
#[test]
fn wire_language_normalises_a_posix_locale() {
assert_eq!(wire_language("pt_BR"), "PT-BR");
}
#[test]
fn subtitles_url_does_not_double_the_slash() {
let p = ProviderNoiz::new().with_base_url("https://example.test/");
assert_eq!(
p.subtitles_url(),
format!("https://example.test{}", noiz_subtitles_path())
);
}
#[test]
fn envelope_renders_subrip_from_start_plus_duration() {
let raw = r#"{"transcript_parts":[
{"start":0.0,"duration":2.5,"text":"hello"},
{"start":2.5,"duration":1.5,"text":"world"}]}"#;
let envelope: NoizEnvelope = serde_json::from_str(raw).expect("fixture parses");
let srt = envelope_to_srt(envelope).expect("cues render");
assert!(srt.starts_with("1\n00:00:00,000 --> 00:00:02,500\nhello\n\n"));
assert!(srt.contains("2\n00:00:02,500 --> 00:00:04,000\nworld"));
}
#[test]
fn empty_transcript_is_language_unavailable_not_not_published() {
let envelope: NoizEnvelope =
serde_json::from_str(r#"{"transcript_parts":[]}"#).expect("fixture parses");
let err = envelope_to_srt(envelope).expect_err("no cues");
assert!(
matches!(
err,
AppError::NoSubtitle(NoSubtitleReason::LanguageUnavailable)
),
"got {err:?}"
);
}
#[test]
fn status_404_is_language_unavailable() {
let headers = reqwest::header::HeaderMap::new();
let err = classify_status(reqwest::StatusCode::NOT_FOUND, &headers);
assert!(
matches!(
err,
AppError::NoSubtitle(NoSubtitleReason::LanguageUnavailable)
),
"got {err:?}"
);
}
#[test]
fn status_429_keeps_the_retry_after() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(reqwest::header::RETRY_AFTER, "30".parse().expect("ascii"));
let err = classify_status(reqwest::StatusCode::TOO_MANY_REQUESTS, &headers);
assert!(
matches!(
err,
AppError::RateLimited {
retry_after_secs: Some(30),
..
}
),
"got {err:?}"
);
}
#[test]
fn status_503_degrades_rather_than_claiming_absence() {
let headers = reqwest::header::HeaderMap::new();
let err = classify_status(reqwest::StatusCode::SERVICE_UNAVAILABLE, &headers);
assert!(
matches!(err, AppError::ProviderUnavailable { .. }),
"got {err:?}"
);
}
#[tokio::test]
async fn list_tracks_is_the_empty_default() {
assert!(ProviderNoiz::new()
.list_tracks("dQw4w9WgXcQ")
.await
.expect("menu")
.is_empty());
}
}