//! 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" ); }