fix: add the xtags.rs module file (was left untracked, breaking a clean-clone build)
Some checks failed
gitleaks / scan (push) Failing after 1s
Some checks failed
gitleaks / scan (push) Failing after 1s
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.
This commit is contained in:
parent
2452f1785d
commit
b5dde59464
1 changed files with 297 additions and 0 deletions
297
src/youtube/xtags.rs
Normal file
297
src/youtube/xtags.rs
Normal file
|
|
@ -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<AudioTrackType> {
|
||||
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<String> {
|
||||
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<String>, Option<String>)> {
|
||||
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<Vec<u8>> {
|
||||
fn sextet(c: u8) -> Option<u8> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue