//! Caption-track probe over the watch page `ytInitialPlayerResponse`.
//!
//! This module is a **classifier**, never a download path. It reads the
//! `ytInitialPlayerResponse` JavaScript object embedded in a `YouTube`
//! watch page and deserialises only
//! `captions.playerCaptionsTracklistRenderer.captionTracks[]`, so a run
//! can tell three facts apart *before* spending a provider attempt:
//!
//! 1. the video publishes no captions at all,
//! 2. it publishes captions but not in the requested language, and
//! 3. it publishes a track the request can use.
//!
//! # Why `baseUrl` is read and never fetched
//!
//! [`CaptionTrack::base_url`] exists so a track can be *identified*, not
//! retrieved. A direct `GET` on that URL was measured to answer
//! `HTTP 200` with a zero-byte body, so fetching it produces a false
//! success rather than a subtitle. Nothing in this module performs I/O.
//!
//! # Why the whole body is required
//!
//! Measured on 2026-08-31 against `watch?v=Ze0i7zxpyrw`: the page is
//! 1 314 762 bytes and the `captionTracks` key starts at byte 737 363.
//! A probe fed a truncated prefix answers "no captions" with apparent
//! success, which is the one failure mode worse than no probe at all.
//! [`caption_tracks`] therefore takes the entire body.
//!
//! # Probe failure means "I do not know"
//!
//! Every error this module returns is a statement about the *page*, not
//! about the video's captions. A caller that already holds a decisive
//! error must return that original error unchanged rather than replace
//! it with a probe failure: an inconclusive probe adds no information.
use serde::{Deserialize, Serialize};
use crate::error::{AppError, AppResult, NoSubtitleReason};
/// The JavaScript identifier the watch page assigns the player response
/// to. Measured verbatim as `var ytInitialPlayerResponse = {…};`.
const PLAYER_RESPONSE_MARKER: &str = "ytInitialPlayerResponse";
/// Largest watch page the probe will scan, in bytes.
///
/// The measured page is 1 314 762 bytes, so 8 MiB leaves upstream room
/// to grow by a factor of six before the guard trips, while still
/// refusing a body large enough to make `serde_json` allocate without
/// bound. Exceeding it yields [`AppError::PlayerResponseTooLarge`],
/// which records both numbers so the cap can be reasoned about.
const MAX_WATCH_PAGE_BYTES: usize = 8 * 1024 * 1024;
/// A single caption track the video publishes.
///
/// The field set and the serialised names are those of
/// `docs/schemas/caption-track.schema.json`, so serialising this struct
/// produces a document of that published contract.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CaptionTrack {
/// Absolute timedtext URL identifying the track. Read for identity
/// only; see the module documentation for why it is never fetched.
#[serde(rename = "baseUrl")]
pub base_url: String,
/// BCP 47 tag of the track, e.g. `pt`, `en`, `pt-BR`.
#[serde(rename = "languageCode")]
pub language_code: String,
/// Human-readable label `YouTube` shows in the captions menu.
///
/// Upstream sends this as an object; the private `RawTrackName`
/// helper carries the three shapes observed and why this crate
/// normalises them to the string the published schema declares.
pub name: String,
/// Video-specific stream id: `.<lang>` for a manual track,
/// `a.<lang>` for one produced by speech recognition.
#[serde(rename = "vssId")]
pub vss_id: String,
/// `"asr"` for a speech-recognition track, `""` for a manual one.
/// The published schema constrains this to exactly those two values.
pub kind: String,
}
impl CaptionTrack {
/// `true` when this track was generated by `YouTube`'s speech
/// recognition rather than uploaded by the video owner.
///
/// `kind` is the primary signal and `vssId`'s `a.` prefix the
/// secondary one, which is what lets a payload that carries only
/// one of the two still classify correctly.
#[must_use]
pub fn is_asr(&self) -> bool {
self.kind == "asr" || self.vss_id.starts_with("a.")
}
}
/// Human-readable track label as upstream actually sends it.
///
/// Measured on 2026-08-31: `"name":{"simpleText":"Portuguese
/// (auto-generated)"}` — an object, not the string
/// `docs/schemas/caption-track.schema.json` declares. `Runs` covers the
/// alternative rich-text envelope `YouTube` uses elsewhere in the same
/// document, and `Plain` covers the string form the repository's own
/// redacted snapshots carry. All three collapse into the schema's
/// string, so this crate keeps publishing the declared contract.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawTrackName {
Plain(String),
Simple {
#[serde(rename = "simpleText")]
simple_text: String,
},
Runs {
runs: Vec<RawRun>,
},
}
#[derive(Debug, Deserialize)]
struct RawRun {
#[serde(default)]
text: String,
}
impl RawTrackName {
fn into_string(self) -> String {
match self {
RawTrackName::Plain(text) | RawTrackName::Simple { simple_text: text } => text,
RawTrackName::Runs { runs } => runs.into_iter().map(|run| run.text).collect::<String>(),
}
}
}
/// The slice of the player response this probe depends on. Every level
/// is optional because a page that omits the captions block is a normal
/// video without subtitles, not a malformed document.
#[derive(Debug, Deserialize)]
struct PlayerResponse {
#[serde(default)]
captions: Option<Captions>,
}
#[derive(Debug, Deserialize)]
struct Captions {
#[serde(rename = "playerCaptionsTracklistRenderer", default)]
renderer: Option<Tracklist>,
}
#[derive(Debug, Deserialize)]
struct Tracklist {
#[serde(rename = "captionTracks", default)]
caption_tracks: Vec<RawCaptionTrack>,
}
/// One raw entry of `captionTracks[]`.
///
/// Upstream sends `isTranslatable` and `trackName` alongside these
/// fields; they are ignored rather than rejected, because a probe that
/// fails on a field it does not use would turn every upstream addition
/// into a false "I do not know".
#[derive(Debug, Deserialize)]
struct RawCaptionTrack {
#[serde(rename = "baseUrl", default)]
base_url: String,
#[serde(rename = "languageCode", default)]
language_code: String,
#[serde(default)]
name: Option<RawTrackName>,
#[serde(rename = "vssId", default)]
vss_id: String,
#[serde(default)]
kind: String,
}
/// Extract the caption tracks a watch page publishes.
///
/// `html` must be the **complete** response body. See the module
/// documentation for the measured offset that makes a truncated body a
/// silent false negative.
///
/// An absent captions block yields an empty vector, which is the honest
/// reading of a video that simply has no subtitles. Errors are reserved
/// for pages this crate could not read at all.
///
/// # Errors
///
/// - [`AppError::PlayerResponseTooLarge`] when `html` exceeds the
/// 8 MiB scan cap, before `serde_json` is allowed to allocate.
/// - [`AppError::PlayerResponseMissing`] when the
/// `ytInitialPlayerResponse` assignment is absent or its object is
/// not brace-balanced, which is what an anti-bot interstitial, an age
/// gate or a layout change looks like.
/// - [`AppError::Serde`] when the extracted object is not valid JSON.
/// - [`AppError::CaptionTrackNotFound`] when the tracklist renderer is
/// present but every entry lacks a `languageCode`, so the block
/// exists and yields zero usable tracks.
pub fn caption_tracks(html: &str) -> AppResult<Vec<CaptionTrack>> {
if html.len() > MAX_WATCH_PAGE_BYTES {
return Err(AppError::PlayerResponseTooLarge {
bytes: html.len(),
limit: MAX_WATCH_PAGE_BYTES,
});
}
let object = player_response_object(html)?;
let parsed: PlayerResponse = serde_json::from_str(object).map_err(AppError::Serde)?;
let Some(raw_tracks) = parsed
.captions
.and_then(|captions| captions.renderer)
.map(|renderer| renderer.caption_tracks)
else {
return Ok(Vec::new());
};
let declared = raw_tracks.len();
let tracks: Vec<CaptionTrack> = raw_tracks
.into_iter()
.filter(|raw| !raw.language_code.is_empty())
.map(|raw| CaptionTrack {
base_url: raw.base_url,
language_code: raw.language_code,
name: raw.name.map(RawTrackName::into_string).unwrap_or_default(),
vss_id: raw.vss_id,
kind: raw.kind,
})
.collect();
// A renderer that declared tracks and yielded none means every entry
// was unusable — the case `CaptionTrackNotFound` was written for.
// Zero declared tracks is a different fact and stays an empty list.
if declared > 0 && tracks.is_empty() {
return Err(AppError::CaptionTrackNotFound);
}
Ok(tracks)
}
/// Classify a track list against the requested language.
///
/// Returns the tracks the request can use, in the order upstream listed
/// them. Both failure modes are answers about the video, not about the
/// probe.
///
/// # Errors
///
/// - [`AppError::NoSubtitle`] carrying
/// [`NoSubtitleReason::NotPublished`] when `tracks` is empty. This is
/// the exit-66 short circuit: nothing published means no provider can
/// help, so no provider is worth trying.
/// - [`AppError::LanguageUnavailable`] when tracks exist but none match
/// `requested`. It carries the languages actually found, which is
/// what fills `available_languages` in the error envelope.
pub fn classify<'a>(
tracks: &'a [CaptionTrack],
requested: &str,
) -> AppResult<Vec<&'a CaptionTrack>> {
if tracks.is_empty() {
return Err(AppError::NoSubtitle(NoSubtitleReason::NotPublished));
}
let needle = primary_subtag(requested);
let matched: Vec<&CaptionTrack> = tracks
.iter()
.filter(|track| primary_subtag(&track.language_code) == needle)
.collect();
if matched.is_empty() {
return Err(AppError::LanguageUnavailable {
available: available_languages(tracks),
});
}
Ok(matched)
}
/// The BCP 47 tags a track list publishes, deduplicated and sorted so
/// the envelope is byte-stable across runs.
#[must_use]
pub fn available_languages(tracks: &[CaptionTrack]) -> Vec<String> {
let mut tags: Vec<String> = tracks
.iter()
.map(|track| track.language_code.clone())
.collect();
tags.sort();
tags.dedup();
tags
}
/// Reduce a BCP 47 tag to its primary subtag: `pt-BR` -> `pt`.
fn primary_subtag(tag: &str) -> String {
tag.split(['-', '_'])
.next()
.unwrap_or_default()
.to_ascii_lowercase()
}
/// Slice out the `{…}` the `ytInitialPlayerResponse` assignment holds.
///
/// The object is located by brace balance rather than by regex because
/// the payload contains braces inside string literals; a regex that
/// stops at the first `}` truncates the document into invalid JSON.
fn player_response_object(html: &str) -> AppResult<&str> {
let marker_at = html.find(PLAYER_RESPONSE_MARKER).ok_or_else(|| {
AppError::PlayerResponseMissing(format!("{PLAYER_RESPONSE_MARKER} absent"))
})?;
let after_marker = &html[marker_at + PLAYER_RESPONSE_MARKER.len()..];
let open_at = after_marker.find('{').ok_or_else(|| {
AppError::PlayerResponseMissing(format!("{PLAYER_RESPONSE_MARKER} has no object"))
})?;
let body = &after_marker[open_at..];
let end = balanced_object_end(body).ok_or_else(|| {
AppError::PlayerResponseMissing(format!("{PLAYER_RESPONSE_MARKER} object is unbalanced"))
})?;
Ok(&body[..end])
}
/// Byte length of the brace-balanced object starting at `body[0]`, or
/// `None` when the braces never close.
///
/// Braces inside JSON string literals are skipped, and a backslash
/// escapes the next byte, so `"}"` and `"\\"` are read correctly. The
/// scan works on bytes: every character it reacts to is ASCII, and a
/// UTF-8 continuation byte can never collide with one.
fn balanced_object_end(body: &str) -> Option<usize> {
let mut depth = 0_usize;
let mut in_string = false;
let mut escaped = false;
for (index, byte) in body.bytes().enumerate() {
if escaped {
escaped = false;
continue;
}
match byte {
b'\\' if in_string => escaped = true,
b'"' => in_string = !in_string,
b'{' if !in_string => depth += 1,
b'}' if !in_string => {
depth -= 1;
if depth == 0 {
return Some(index + 1);
}
}
_ => {}
}
}
None
}
/// Watch-page builders shared by this module's own tests and by the
/// provider chain's tests.
///
/// The chain probe classifies exactly what this module parses, so its
/// tests need the same synthetic page. Duplicating the generator there
/// would let the two drift and let a chain test pass against a page
/// this parser no longer accepts.
#[cfg(test)]
pub(crate) mod test_pages {
/// The `captions` object as upstream sends it, reproducing a capture
/// of `watch?v=Ze0i7zxpyrw` taken on 2026-08-31: field names,
/// nesting and the `simpleText` name object are verbatim, and the
/// signed `baseUrl` query was reduced to its public parameters.
pub(crate) const CAPTIONS_BLOCK: &str = r#""captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[{"baseUrl":"https://www.youtube.com/api/timedtext?v=Ze0i7zxpyrw&caps=asr&hl=en&lang=pt&kind=asr&fmt=json3","name":{"simpleText":"Portuguese (auto-generated)"},"vssId":"a.pt","languageCode":"pt","kind":"asr","isTranslatable":true,"trackName":""},{"baseUrl":"https://www.youtube.com/api/timedtext?v=Ze0i7zxpyrw&hl=en&lang=en&fmt=json3","name":{"simpleText":"English"},"vssId":".en","languageCode":"en","kind":"","isTranslatable":true,"trackName":""}],"audioTracks":[{"captionTrackIndices":[0,1]}],"translationLanguages":[],"defaultAudioTrackIndex":0}}"#;
/// Eight-byte word repeated ahead of the captions block.
const PADDING_WORD: &str = "padding-";
/// Repetition count that lands the block near 96 KiB, three times
/// the 32 KiB truncation trap, so the margin survives edits to the
/// prologue rather than depending on its exact length.
const PADDING_REPEATS: usize = 12 * 1024;
/// Builds the watch page in memory instead of reading a snapshot.
///
/// A 1.3 MiB file used to live under `tests/fixtures/snapshots/`,
/// which `.gitignore` excludes because real snapshots can carry
/// signed URLs. `include_str!` resolves against the filesystem at
/// compile time and never against the git index, so the untracked
/// file made a fresh clone fail to *compile* while every local gate
/// stayed green — a defect the suite is structurally unable to
/// observe, because it always runs on the side that has the file.
/// Generating the page removes the file, the contradiction and the
/// blob in one move, and the offset becomes an asserted property
/// instead of an opaque byte count.
///
/// `captions_block` is spliced in verbatim, so a caller can hand in
/// an ASR-only tracklist or an empty one without a second builder.
pub(crate) fn watch_page_with(captions_block: &str) -> String {
let mut page = String::with_capacity(PADDING_WORD.len() * PADDING_REPEATS + 4096);
page.push_str("<!doctype html>\n<html lang=\"en\">\n<head>\n");
page.push_str("<meta charset=\"utf-8\">\n<title>watch page</title>\n");
page.push_str("</head>\n<body>\n<script nonce=\"REDACTED\">");
page.push_str(r#"var ytInitialPlayerResponse = {"playabilityStatus":{"status":"OK"},"videoDetails":{"videoId":"Ze0i7zxpyrw"},"filler":""#);
page.push_str(&PADDING_WORD.repeat(PADDING_REPEATS));
page.push_str("\",");
page.push_str(captions_block);
page.push_str("};</script>\n<noscript><p>page</p></noscript>\n</body>\n</html>\n");
page
}
/// A tracklist whose every entry is machine-generated, in two
/// languages. This is the shape the ASR-only classification exists
/// for, and the mixed [`CAPTIONS_BLOCK`] above is its control.
pub(crate) const ASR_ONLY_BLOCK: &str = r#""captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[{"baseUrl":"https://www.youtube.com/api/timedtext?lang=pt&kind=asr","name":{"simpleText":"Portuguese (auto-generated)"},"vssId":"a.pt","languageCode":"pt","kind":"asr"},{"baseUrl":"https://www.youtube.com/api/timedtext?lang=en&kind=asr","name":{"simpleText":"English (auto-generated)"},"vssId":"a.en","languageCode":"en","kind":"asr"}]}}"#;
/// A page that publishes no captions block at all.
pub(crate) const NO_CAPTIONS_BLOCK: &str = r#""filler2":"none""#;
/// A page that publishes exactly one human track, in Portuguese.
///
/// This is the shape of the video measured on 2026-09-04 that
/// returned Portuguese under `--lang en` with exit 0. One language
/// is the case with no ambiguity left: a delivered body can only
/// have come from this track, so it is both the input that must be
/// refused for `en` and the input that lets `delivered_language`
/// finally carry an observation.
pub(crate) const PT_ONLY_BLOCK: &str = r#""captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[{"baseUrl":"https://www.youtube.com/api/timedtext?lang=pt","name":{"simpleText":"Portuguese"},"vssId":".pt","languageCode":"pt","kind":"","isTranslatable":true,"trackName":""}],"audioTracks":[{"captionTrackIndices":[0]}],"translationLanguages":[],"defaultAudioTrackIndex":0}}"#;
}
#[cfg(test)]
mod tests {
use super::test_pages::{watch_page_with, CAPTIONS_BLOCK};
use super::*;
/// The mixed page the parser tests assert against: one ASR track
/// and one manual track, past the truncation trap.
fn deep_offset_page() -> String {
watch_page_with(CAPTIONS_BLOCK)
}
/// The byte offset past which a truncating reader stops finding the
/// block. 32 KiB is the truncation that was measured to answer "no
/// captions" with apparent success.
const TRUNCATION_TRAP_BYTES: usize = 32 * 1024;
fn track(language: &str, kind: &str) -> CaptionTrack {
CaptionTrack {
base_url: format!("https://www.youtube.com/api/timedtext?lang={language}"),
language_code: language.to_string(),
name: format!("{language} label"),
vss_id: format!(".{language}"),
kind: kind.to_string(),
}
}
#[test]
fn the_fixture_really_puts_the_block_past_the_truncation_trap() {
let page = deep_offset_page();
let offset = page
.find("captionTracks")
.expect("generated page carries the block");
assert!(
offset > TRUNCATION_TRAP_BYTES,
"fixture offset {offset} is inside the 32 KiB prefix, so it cannot catch truncation"
);
// A reader that stopped at 32 KiB would report zero tracks with
// no error at all — the silent false negative this guards.
let truncated = &page[..TRUNCATION_TRAP_BYTES];
assert!(caption_tracks(truncated).is_err());
}
#[test]
fn reads_every_track_from_the_deep_offset_page() {
let tracks = caption_tracks(&deep_offset_page()).expect("page parses");
assert_eq!(available_languages(&tracks), vec!["en", "pt"]);
}
#[test]
fn normalises_the_simple_text_name_object_into_the_published_string() {
let tracks = caption_tracks(&deep_offset_page()).expect("page parses");
let pt = tracks
.iter()
.find(|t| t.language_code == "pt")
.expect("pt track present");
assert_eq!(pt.name, "Portuguese (auto-generated)");
assert!(pt.is_asr());
}
#[test]
fn a_track_serialises_as_the_published_caption_track_schema() {
let value = serde_json::to_value(track("pt", "asr")).expect("serialises");
let object = value.as_object().expect("object");
let mut keys: Vec<&str> = object.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(keys, ["baseUrl", "kind", "languageCode", "name", "vssId"]);
}
#[test]
fn a_page_without_a_captions_block_yields_no_tracks() {
let html = r#"<script>var ytInitialPlayerResponse = {"playabilityStatus":{"status":"OK"}};</script>"#;
assert!(caption_tracks(html).expect("parses").is_empty());
}
#[test]
fn braces_inside_string_literals_do_not_end_the_object() {
let html = r#"var ytInitialPlayerResponse = {"a":"}{\"","captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[{"baseUrl":"https://x/","languageCode":"en","name":{"simpleText":"English"},"vssId":".en","kind":""}]}}};"#;
let tracks = caption_tracks(html).expect("parses");
assert_eq!(tracks.len(), 1);
assert_eq!(tracks[0].name, "English");
}
#[test]
fn a_runs_name_envelope_is_joined_into_one_label() {
let html = r#"var ytInitialPlayerResponse = {"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[{"baseUrl":"https://x/","languageCode":"en","name":{"runs":[{"text":"Eng"},{"text":"lish"}]},"vssId":".en","kind":""}]}}};"#;
let tracks = caption_tracks(html).expect("parses");
assert_eq!(tracks[0].name, "English");
}
#[test]
fn a_missing_assignment_is_a_page_failure_not_an_absence_of_captions() {
let err = caption_tracks("<html><body>challenge</body></html>").unwrap_err();
assert!(matches!(err, AppError::PlayerResponseMissing(_)));
}
#[test]
fn an_unbalanced_object_is_a_page_failure() {
let err = caption_tracks(r#"var ytInitialPlayerResponse = {"captions":{"#).unwrap_err();
assert!(matches!(err, AppError::PlayerResponseMissing(_)));
}
#[test]
fn an_oversized_body_trips_the_guard_before_parsing() {
let big = "a".repeat(MAX_WATCH_PAGE_BYTES + 1);
let err = caption_tracks(&big).unwrap_err();
assert!(matches!(err, AppError::PlayerResponseTooLarge { .. }));
}
#[test]
fn a_renderer_whose_entries_are_all_unusable_is_not_an_absence() {
let html = r#"var ytInitialPlayerResponse = {"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[{"baseUrl":"https://x/","languageCode":""}]}}};"#;
let err = caption_tracks(html).unwrap_err();
assert!(matches!(err, AppError::CaptionTrackNotFound));
}
#[test]
fn zero_tracks_classifies_as_not_published_and_exits_66() {
let err = classify(&[], "pt").unwrap_err();
assert!(matches!(
err,
AppError::NoSubtitle(NoSubtitleReason::NotPublished)
));
assert_eq!(err.exit_code(), crate::error::sysexits::EX_NOINPUT);
}
#[test]
fn a_language_miss_reports_the_languages_that_do_exist() {
let tracks = vec![track("en", "asr"), track("pt", "")];
let err = classify(&tracks, "de").unwrap_err();
match err {
AppError::LanguageUnavailable { available } => {
assert_eq!(available, vec!["en", "pt"]);
}
other => panic!("expected LanguageUnavailable, got {other:?}"),
}
}
#[test]
fn a_regional_request_matches_the_primary_subtag() {
let tracks = vec![track("pt", "")];
let matched = classify(&tracks, "pt-BR").expect("pt-BR matches pt");
assert_eq!(matched.len(), 1);
}
#[test]
fn available_languages_are_deduplicated_and_sorted() {
let tracks = vec![track("pt", "asr"), track("en", ""), track("pt", "")];
assert_eq!(available_languages(&tracks), vec!["en", "pt"]);
}
#[test]
fn the_asr_flag_reads_the_vss_id_when_kind_is_absent() {
let mut asr = track("pt", "");
asr.vss_id = "a.pt".to_string();
assert!(asr.is_asr());
assert!(!track("pt", "").is_asr());
}
}