From b5dde59464c5f430b70a6d6cb16acb3b0bc10311 Mon Sep 17 00:00:00 2001 From: Cobb Date: Wed, 29 Jul 2026 07:22:00 -0700 Subject: [PATCH] fix: add the xtags.rs module file (was left untracked, breaking a clean-clone build) The Loop-2 npe-sync commit added `pub mod xtags;` but the new xtags.rs file itself was never staged (committed with a broad -a, which doesn't add new files), so strawcore main referenced a missing module and failed to compile from a fresh clone (E0583). Local builds passed only because the untracked file was present in the working tree. --- src/youtube/xtags.rs | 297 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 src/youtube/xtags.rs diff --git a/src/youtube/xtags.rs b/src/youtube/xtags.rs new file mode 100644 index 0000000..54c7d0b --- /dev/null +++ b/src/youtube/xtags.rs @@ -0,0 +1,297 @@ +// Minimal reader for an itag format's `xtags` field → AudioTrackType. +// +// Mirrors NPE YoutubeParsingHelper.extractAudioTrackType (commits b0bca7e7 + +// e3479a7c, 2026-06-01), which replaced the old `audioTrack.audioIsDefault` +// heuristic with the authoritative `acont` value carried in the format's +// `xtags` blob. +// +// `xtags` is a base64url-encoded `youtube.video.XTags` protobuf (upstream +// proto/youtube/video/xtags.proto): +// +// message KeyValuePair { optional string key = 1; optional string value = 2; } +// message XTags { repeated KeyValuePair xtags = 1; } +// +// The structure is trivial (two nested length-delimited string fields), so we +// read it with a ~40-line varint/field walker instead of pulling in a protobuf +// crate — matching the codebase's minimal-dependency approach. Everything is +// best-effort: any malformed input (bad base64, truncated protobuf, unknown +// `acont` value) yields `None`, and the caller falls back to the legacy +// `audioIsDefault` heuristic — never a panic, never a break. + +use crate::stream::AudioTrackType; + +/// Decode a format's `xtags` string and return its audio track type, mirroring +/// NPE `extractAudioTrackType`. Returns `None` when the blob is absent-shaped, +/// undecodable, carries no `acont` key, or maps to an unknown value. +pub fn extract_audio_track_type(xtags: &str) -> Option { + let bytes = base64url_decode(xtags)?; + let acont = xtags_find(&bytes, "acont")?; + match acont.as_str() { + "original" => Some(AudioTrackType::Original), + // NPE maps both "dubbed" and "dubbed-auto" → DUBBED. + "dubbed" | "dubbed-auto" => Some(AudioTrackType::Dubbed), + "descriptive" => Some(AudioTrackType::Descriptive), + "secondary" => Some(AudioTrackType::Secondary), + _ => None, + } +} + +/// Walk the top-level `XTags` message, returning the `value` of the first +/// `KeyValuePair` (field 1) whose `key` equals `wanted`. Unknown fields and +/// non-string wire types are skipped; any framing error bails to `None`. +fn xtags_find(buf: &[u8], wanted: &str) -> Option { + let mut i = 0; + while i < buf.len() { + let (tag, adv) = read_varint(buf, i)?; + i += adv; + let field = tag >> 3; + match wire_type(tag) { + // Length-delimited: field 1 is a KeyValuePair submessage. + 2 => { + let payload = read_len_delimited(buf, &mut i)?; + if field == 1 { + if let Some((k, v)) = parse_key_value_pair(payload) { + if k.as_deref() == Some(wanted) { + return v; + } + } + } + } + wt => skip_scalar(buf, &mut i, wt)?, + } + } + None +} + +/// Parse a `KeyValuePair` submessage into (key, value); either may be absent. +fn parse_key_value_pair(buf: &[u8]) -> Option<(Option, Option)> { + let mut key = None; + let mut val = None; + let mut i = 0; + while i < buf.len() { + let (tag, adv) = read_varint(buf, i)?; + i += adv; + let field = tag >> 3; + match wire_type(tag) { + 2 => { + let payload = read_len_delimited(buf, &mut i)?; + let s = std::str::from_utf8(payload).ok()?.to_string(); + match field { + 1 => key = Some(s), + 2 => val = Some(s), + _ => {} + } + } + wt => skip_scalar(buf, &mut i, wt)?, + } + } + Some((key, val)) +} + +#[inline] +fn wire_type(tag: u64) -> u8 { + (tag & 0x7) as u8 +} + +/// Read a length-delimited (wire type 2) payload, advancing `*i` past it. +fn read_len_delimited<'a>(buf: &'a [u8], i: &mut usize) -> Option<&'a [u8]> { + let (len, adv) = read_varint(buf, *i)?; + *i += adv; + // `usize::try_from` (not `as usize`) so a >4 GiB length can't wrap on a + // 32-bit target (armeabi-v7a is a real Straw ABI) — it fails closed instead. + let len = usize::try_from(len).ok()?; + let end = i.checked_add(len)?; + if end > buf.len() { + return None; + } + let payload = &buf[*i..end]; + *i = end; + Some(payload) +} + +/// Advance `*i` past a non-length-delimited field. Groups (3/4) and any +/// unrecognized wire type bail out — they never appear in a well-formed XTags. +fn skip_scalar(buf: &[u8], i: &mut usize, wire: u8) -> Option<()> { + match wire { + 0 => { + let (_, adv) = read_varint(buf, *i)?; + *i += adv; + } + 1 => { + *i = i.checked_add(8)?; + if *i > buf.len() { + return None; + } + } + 5 => { + *i = i.checked_add(4)?; + if *i > buf.len() { + return None; + } + } + _ => return None, + } + Some(()) +} + +/// Read a base-128 varint at `start`; returns (value, bytes_consumed). Caps at +/// 10 bytes (64 bits) so a malformed run can't spin. +fn read_varint(buf: &[u8], start: usize) -> Option<(u64, usize)> { + let mut result: u64 = 0; + let mut shift: u32 = 0; + let mut i = start; + loop { + if i >= buf.len() || shift >= 64 { + return None; + } + let byte = buf[i]; + result |= u64::from(byte & 0x7f) << shift; + i += 1; + if byte & 0x80 == 0 { + return Some((result, i - start)); + } + shift += 7; + } +} + +/// Strict base64url (RFC 4648 §5, no padding required) decoder. Rejects any +/// non-url-safe byte (`+`, `/`, whitespace) → `None`, matching upstream's use +/// of `Base64.getUrlDecoder()`. Stops at the first `=` padding byte. +fn base64url_decode(s: &str) -> Option> { + fn sextet(c: u8) -> Option { + match c { + b'A'..=b'Z' => Some(c - b'A'), + b'a'..=b'z' => Some(c - b'a' + 26), + b'0'..=b'9' => Some(c - b'0' + 52), + b'-' => Some(62), + b'_' => Some(63), + _ => None, + } + } + if s.is_empty() { + return None; + } + let mut out = Vec::with_capacity(s.len() * 3 / 4 + 3); + let mut acc: u32 = 0; + let mut bits: u32 = 0; + for &c in s.as_bytes() { + if c == b'=' { + break; + } + let v = u32::from(sextet(c)?); + acc = (acc << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Independently-generated (Python urlsafe_b64encode of a hand-built XTags + // protobuf) so these vectors are an external oracle, not a round-trip of + // our own encoder. See the Loop-2 build notes for the generator. + const XT_ORIGINAL: &str = "ChEKBWFjb250EghvcmlnaW5hbA"; + const XT_DUBBED: &str = "Cg8KBWFjb250EgZkdWJiZWQ"; + const XT_DUBBED_AUTO: &str = "ChQKBWFjb250EgtkdWJiZWQtYXV0bw"; + const XT_DESCRIPTIVE: &str = "ChQKBWFjb250EgtkZXNjcmlwdGl2ZQ"; + const XT_SECONDARY: &str = "ChIKBWFjb250EglzZWNvbmRhcnk"; + // Two pairs: {lang=en},{acont=descriptive} — proves we scan past a + // non-matching pair to find `acont`. + const XT_MULTI: &str = "CgoKBGxhbmcSAmVuChQKBWFjb250EgtkZXNjcmlwdGl2ZQ"; + // Single pair {lang=en} — no `acont` key. + const XT_NO_ACONT: &str = "CgoKBGxhbmcSAmVu"; + + #[test] + fn maps_each_known_acont_value() { + assert_eq!( + extract_audio_track_type(XT_ORIGINAL), + Some(AudioTrackType::Original) + ); + assert_eq!( + extract_audio_track_type(XT_DUBBED), + Some(AudioTrackType::Dubbed) + ); + assert_eq!( + extract_audio_track_type(XT_DUBBED_AUTO), + Some(AudioTrackType::Dubbed) + ); + assert_eq!( + extract_audio_track_type(XT_DESCRIPTIVE), + Some(AudioTrackType::Descriptive) + ); + assert_eq!( + extract_audio_track_type(XT_SECONDARY), + Some(AudioTrackType::Secondary) + ); + } + + #[test] + fn finds_acont_among_multiple_pairs() { + assert_eq!( + extract_audio_track_type(XT_MULTI), + Some(AudioTrackType::Descriptive) + ); + } + + #[test] + fn absent_acont_key_yields_none() { + assert_eq!(extract_audio_track_type(XT_NO_ACONT), None); + } + + #[test] + fn malformed_input_yields_none_never_panics() { + // Empty, non-base64url chars, valid base64url but not a protobuf, + // and a truncated length-delimited frame. + assert_eq!(extract_audio_track_type(""), None); + assert_eq!(extract_audio_track_type("!!!not base64!!!"), None); + assert_eq!(extract_audio_track_type("++//"), None); // standard-b64 chars rejected + assert_eq!(extract_audio_track_type("Zm9vYmFy"), None); // "foobar", valid b64url, junk proto + // 0x0A (field1,LEN) claiming length 0x7F with no body → truncated. + assert_eq!(extract_audio_track_type("Cn8"), None); + } + + #[test] + fn unknown_acont_value_yields_none() { + // {acont=weird} — well-formed protobuf, value not in the enum. + // ld(1, ld(1,"acont")+ld(2,"weird")) + let bytes = { + let kv = [ + &[0x0a, 0x05][..], + b"acont", + &[0x12, 0x05], + b"weird", + ] + .concat(); + let mut top = vec![0x0a, kv.len() as u8]; + top.extend_from_slice(&kv); + top + }; + // sanity: our decoder round-trips what we assert on + let b64 = { + // encode without padding, url-safe + const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::new(); + for chunk in bytes.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + let take = chunk.len() + 1; + for k in 0..take { + out.push(A[((n >> (18 - 6 * k)) & 0x3f) as usize] as char); + } + } + out + }; + assert_eq!(base64url_decode(&b64).unwrap(), bytes); + assert_eq!(extract_audio_track_type(&b64), None); + } +}