youtube-legend-cli 0.4.0

Non-interactive Rust CLI that downloads YouTube subtitles through third-party providers, using a native Unix stdin/stdout interface.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
//! JSON [`Provider`] backed by the noiz.io landing endpoint.
//!
//! A single `GET` returns the whole transcript. Unlike
//! [`crate::provider::decopy`], which exposes no language parameter at
//! all, this endpoint accepts one — but accepting it and honouring it
//! are not the same thing, and this module claimed the second for a
//! long time on the strength of the first.
//!
//! MEASURED on 2026-09-04 against `zHGqpjCV6Tg`, a video whose watch
//! page publishes a single ASR track in `pt`. Requesting `en` and
//! requesting `pt` returned the same body: 128 309 bytes, identical
//! SHA-256, differing only in the language this crate had itself put
//! into `source_url`. The `404` that `classify_status` documents for
//! "no track in this language" never came; the native track came back
//! under whatever label was asked for.
//!
//! This is why `delivered_language` is pinned to `None` (GAP-2026-158)
//! rather than echoing the request: the envelope names no track, so the
//! only honest statement about what arrived is that we do not know.
//!
//! # Why it is last in the chain
//!
//! The anonymous quota is five requests per day and the 429 body says
//! so literally. Ten measured attempts with distinct languages returned
//! 429 for every one of them, including from inside a real browser
//! session. The provider is therefore useful as a last resort and
//! useless as a primary, which is exactly where the `auto` chain puts
//! it.
//!
//! # Format
//!
//! Segments carry a floating-point start plus a duration, which is
//! enough to render real `SubRip`. The provider advertises
//! [`SubtitleFormat::Srt`].

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};

/// Stable provider identifier. Mirrored into
/// [`SubtitleInfo::provider`] and into every tracing event.
pub const PROVIDER_NAME: &str = "provider-noiz";

/// Wall-clock ceiling for the single upstream request.
///
/// Compiled default behind `providers.noiz.request_timeout_secs`.
const DEFAULT_NOIZ_REQUEST_TIMEOUT_SECS: u64 = 60;

/// Wall-clock ceiling for the single upstream request.
///
/// Resolves `providers.noiz.request_timeout_secs`. A zero would abort
/// before the request leaves, so the range starts at one second.
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,
    ))
}

/// Maximum response body accepted before parsing.
///
/// Compiled default behind `providers.noiz.max_body_bytes`.
const DEFAULT_NOIZ_MAX_BODY_BYTES: usize = 16 * 1024 * 1024;

/// Maximum response body accepted before parsing.
///
/// Resolves `providers.noiz.max_body_bytes`.
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,
    )
}

/// Query parameter carrying the bare 11-character video id.
const PARAM_VIDEO_ID: &str = "video_id";

/// Query parameter carrying the wanted track, upper-cased and
/// hyphenated (`PT-BR`, `EN`).
const PARAM_LANGUAGE: &str = "language";

/// Response envelope. Only the transcript array is modelled.
#[derive(Debug, Deserialize)]
struct NoizEnvelope {
    /// Timed segments, in presentation order.
    #[serde(default)]
    transcript_parts: Vec<NoizPart>,
}

/// One timed segment as the upstream spells it.
#[derive(Debug, Deserialize)]
struct NoizPart {
    /// Offset from the start of the video, in seconds.
    #[serde(default)]
    start: f64,
    /// Length of the segment, in seconds.
    #[serde(default)]
    duration: f64,
    /// Segment text.
    #[serde(default)]
    text: String,
}

/// noiz.io provider. Construct with [`ProviderNoiz::new`].
pub struct ProviderNoiz {
    base_url: String,
    cache: Mutex<HashMap<String, Vec<u8>>>,
}

impl Default for ProviderNoiz {
    fn default() -> Self {
        Self::new()
    }
}

impl ProviderNoiz {
    /// Build a provider against the production endpoint.
    #[must_use]
    #[tracing::instrument(level = "debug")]
    pub fn new() -> Self {
        Self {
            base_url: noiz_api_base(),
            cache: Mutex::new(HashMap::new()),
        }
    }

    /// Builder-style: point the provider at a different origin.
    ///
    /// The only production origin is the provider's compiled-in API
    /// base; this exists so
    /// the integration suite can aim the provider at a local mock
    /// server without a network round trip.
    #[must_use]
    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
        self.base_url = base_url.into();
        self
    }

    /// Absolute URL of the subtitles endpoint, without a query string.
    fn subtitles_url(&self) -> String {
        format!(
            "{}{}",
            self.base_url.trim_end_matches('/'),
            noiz_subtitles_path()
        )
    }

    /// Absolute URL of the subtitles endpoint for one video and track.
    ///
    /// The query string is assembled here rather than through
    /// `RequestBuilder::query`, which this crate's `reqwest` feature set
    /// does not compile in.
    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)
    }
}

/// Render a BCP 47 tag the way the endpoint expects it: upper case,
/// hyphen-separated, region preserved.
///
/// `pt-BR` becomes `PT-BR` and `en` becomes `EN`. An underscore-joined
/// POSIX locale is normalised on the way through so `pt_BR` does not
/// reach the wire as an unrecognised token.
pub(crate) fn wire_language(tag: &str) -> String {
    tag.trim().replace('_', "-").to_ascii_uppercase()
}

/// Turn a parsed envelope into `SubRip` bytes.
///
/// # Errors
///
/// Returns [`AppError::NoSubtitle`] carrying
/// [`NoSubtitleReason::LanguageUnavailable`] when the endpoint answered
/// successfully with an empty transcript. The endpoint is language-
/// scoped, so an empty answer means "not in this language", never "this
/// video has no captions at all" — collapsing the two would let the
/// chain report a confirmed absence it never observed.
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)
}

/// Classify a non-success HTTP status from this endpoint.
///
/// The endpoint answers `404` when the requested language has no track,
/// which is [`NoSubtitleReason::LanguageUnavailable`] and not the
/// generic `NotFound` that [`super::http_failure`] would infer from the
/// status alone. Everything else defers to the shared classifier, so
/// `429` keeps its `Retry-After` and `5xx` stays degraded.
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,
            });
        }

        // NFR-007: confirm the ruleset permits the endpoint before the
        // first byte leaves the process.
        crate::provider::robots::check_allowed(
            &noiz_api_host(),
            &noiz_subtitles_path(),
            &crate::net::user_agent(),
            PROVIDER_NAME,
        )
        .await?;

        // Canonicalise before hitting the wire so `pt_BR.UTF-8` becomes
        // `pt-BR` and a malformed tag fails as `LanguageParseError`
        // rather than as an opaque upstream 404.
        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,
            // GAP-2026-158: the noiz envelope carries only
            // `transcript_parts`; nothing in it names the track, so the
            // delivered language stays declared-unknown.
            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:?}"
        );
    }

    /// noiz cannot enumerate what a video offers, so the honest menu is
    /// the empty one the trait default provides.
    #[tokio::test]
    async fn list_tracks_is_the_empty_default() {
        assert!(ProviderNoiz::new()
            .list_tracks("dQw4w9WgXcQ")
            .await
            .expect("menu")
            .is_empty());
    }
}