YouTube googlevideo CDN 403s HEAD requests + 403s requests with a non-client User-Agent. Use the iOS client UA on the probe so the CDN treats it as the same client that requested the URL.
127 lines
4.2 KiB
Rust
127 lines
4.2 KiB
Rust
//! Sulkta-fork smoke tests for the player pipeline.
|
|
//!
|
|
//! Verifies the patched default client order (`Ios, Tv` without botguard) plus
|
|
//! the soft-fail DeobfData::extract works against current YouTube player.js.
|
|
//!
|
|
//! Run with: `cargo test --test sulkta_smoke -- --nocapture`
|
|
|
|
use rstest::{fixture, rstest};
|
|
use rustypipe::client::{ClientType, RustyPipe};
|
|
|
|
/// A stable, long-running, public-domain music video. Used by upstream
|
|
/// tests too (`n4tK7LYFxI0` = Spektrem - Shine, NCS).
|
|
const TEST_VIDEO_ID: &str = "n4tK7LYFxI0";
|
|
|
|
#[fixture]
|
|
fn rp() -> RustyPipe {
|
|
RustyPipe::builder()
|
|
.storage_dir(env!("CARGO_MANIFEST_DIR"))
|
|
.build()
|
|
.unwrap()
|
|
}
|
|
|
|
/// Sanity: iOS path returns stream URLs and never touches the deobf code.
|
|
#[rstest]
|
|
#[tokio::test]
|
|
async fn ios_player_returns_streams(rp: RustyPipe) {
|
|
let pd = rp
|
|
.query()
|
|
.player_from_client(TEST_VIDEO_ID, ClientType::Ios)
|
|
.await
|
|
.expect("iOS player_from_client should succeed");
|
|
|
|
assert_eq!(pd.details.id, TEST_VIDEO_ID);
|
|
assert!(
|
|
!pd.video_streams.is_empty() || !pd.video_only_streams.is_empty(),
|
|
"expected at least one video stream"
|
|
);
|
|
assert!(
|
|
!pd.audio_streams.is_empty(),
|
|
"expected at least one audio stream"
|
|
);
|
|
}
|
|
|
|
/// Sanity: TV path (which sets `needs_deobf=true` for the sig_timestamp request
|
|
/// payload, but the soft-fail patch keeps the call alive even when sig_fn/nsig_fn
|
|
/// regex extraction fails on a rotated player.js).
|
|
#[rstest]
|
|
#[tokio::test]
|
|
async fn tv_player_returns_streams(rp: RustyPipe) {
|
|
let pd = rp
|
|
.query()
|
|
.player_from_client(TEST_VIDEO_ID, ClientType::Tv)
|
|
.await
|
|
.expect("TV player_from_client should succeed even when sig deobf regex misses");
|
|
|
|
assert_eq!(pd.details.id, TEST_VIDEO_ID);
|
|
assert!(
|
|
!pd.video_streams.is_empty() || !pd.video_only_streams.is_empty(),
|
|
"expected at least one TV video stream"
|
|
);
|
|
}
|
|
|
|
/// The patched default-client order should pick iOS as primary and return
|
|
/// playable streams in the absence of botguard signing.
|
|
#[rstest]
|
|
#[tokio::test]
|
|
async fn default_client_order_returns_streams(rp: RustyPipe) {
|
|
let order = rp.query().player_client_order();
|
|
eprintln!("default client order (no botguard): {order:?}");
|
|
assert_eq!(
|
|
order[0],
|
|
ClientType::Ios,
|
|
"iOS should be the no-botguard primary"
|
|
);
|
|
|
|
let pd = rp
|
|
.query()
|
|
.player(TEST_VIDEO_ID)
|
|
.await
|
|
.expect("default-clients player() should succeed");
|
|
|
|
assert_eq!(pd.details.id, TEST_VIDEO_ID);
|
|
assert!(
|
|
!pd.video_streams.is_empty() || !pd.video_only_streams.is_empty(),
|
|
"expected at least one video stream from the default-clients path"
|
|
);
|
|
assert!(
|
|
!pd.audio_streams.is_empty(),
|
|
"expected at least one audio stream from the default-clients path"
|
|
);
|
|
|
|
// Probe one returned audio stream to confirm YT actually serves it.
|
|
// GET with Range 0-1023 + an iOS User-Agent because YT's googlevideo
|
|
// CDN tends to 403 HEAD requests and UA mismatches.
|
|
let stream_url = pd
|
|
.audio_streams
|
|
.first()
|
|
.expect("at least one audio stream")
|
|
.url
|
|
.clone();
|
|
eprintln!("probing first audio URL: {}", &stream_url[..stream_url.len().min(180)]);
|
|
let client = reqwest::Client::builder()
|
|
.user_agent(
|
|
"com.google.ios.youtube/19.45.4 (iPhone16,2; U; CPU iOS 18_1 like Mac OS X; en_US)",
|
|
)
|
|
.build()
|
|
.unwrap();
|
|
let resp = client
|
|
.get(&stream_url)
|
|
.header("Range", "bytes=0-1023")
|
|
.send()
|
|
.await
|
|
.expect("GET request to YT CDN should not error");
|
|
let status = resp.status();
|
|
let body_len = resp.bytes().await.map(|b| b.len()).unwrap_or(0);
|
|
eprintln!("response: {} bytes, status {}", body_len, status);
|
|
assert!(
|
|
status.is_success() || status.is_redirection(),
|
|
"audio URL Range-GET returned non-OK status: {} (body={} bytes; URL may need visitor_data or po_token)",
|
|
status,
|
|
body_len
|
|
);
|
|
assert!(
|
|
body_len > 0,
|
|
"audio URL returned OK but zero bytes — likely a sig-required URL we couldn't deobf"
|
|
);
|
|
}
|