Smoke-tested against current YT player c2f7551f (May 2026): test ios_player_returns_streams ........... ok test default_client_order_returns_streams . ok (audio Range-GET 206 Partial Content, 1024 bytes) test tv_player_returns_streams ............ ok (or env-skipped on IP-banned egress) Fork changes since upstream v0.11.4: - client::ClientType::needs_deobf: skip player.js deobf for Android too - client::player::player_client_order: prefer iOS first (no botguard), iOS/Android/Tv/Desktop (with botguard) - deobfuscate::DeobfData::extract_fns: soft-fail sig_fn/nsig_fn extraction so Tv/Desktop callers keep working when YT rotates player.js to a shape our regex doesn't recognise — only the load-bearing sig_timestamp is required for the request payload - tests/sulkta_smoke.rs: end-to-end sanity covering iOS, Tv, default-order and a Range-GET probe to confirm YT actually serves the audio bytes
142 lines
4.8 KiB
Rust
142 lines
4.8 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"
|
|
);
|
|
}
|
|
|
|
/// TV path exercises the `needs_deobf=true` branch: the sig_timestamp request
|
|
/// payload is required, but the soft-fail patch keeps the call alive even when
|
|
/// sig_fn/nsig_fn regex extraction fails on a rotated player.js.
|
|
///
|
|
/// YouTube IP-bans some shared egress IPs (datacenters, LAN-routed servers)
|
|
/// for the TV client with "Sign in to confirm you're not a bot". That's
|
|
/// environmental, not a rustypipe regression, so we tolerate it here as long
|
|
/// as the error is recognisable.
|
|
#[rstest]
|
|
#[tokio::test]
|
|
async fn tv_player_returns_streams(rp: RustyPipe) {
|
|
match rp
|
|
.query()
|
|
.player_from_client(TEST_VIDEO_ID, ClientType::Tv)
|
|
.await
|
|
{
|
|
Ok(pd) => {
|
|
assert_eq!(pd.details.id, TEST_VIDEO_ID);
|
|
assert!(
|
|
!pd.video_streams.is_empty() || !pd.video_only_streams.is_empty(),
|
|
"TV path returned no video streams"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
let msg = format!("{e}");
|
|
assert!(
|
|
msg.contains("Sign in") || msg.contains("IpBan") || msg.contains("bot"),
|
|
"TV path failed for a non-environmental reason: {msg}"
|
|
);
|
|
eprintln!("TV path skipped: YT IP-banned this egress (expected on shared/datacenter IPs)");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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"
|
|
);
|
|
}
|