diff --git a/src/youtube/stream_extractor.rs b/src/youtube/stream_extractor.rs index 27303d1..73ab2e8 100644 --- a/src/youtube/stream_extractor.rs +++ b/src/youtube/stream_extractor.rs @@ -198,6 +198,68 @@ pub fn stream_info_with( Ok(info) } +/// Metadata-only fetch — the Android `/player` `videoDetails` and nothing +/// else. Returns a `StreamInfo` populated with title / uploader / view +/// count / duration / thumbnails but EMPTY stream lists. +/// +/// This is the cheap tail of `stream_info_with`: it shares the exact same +/// Android fetch + playability + decoy checks, then runs only +/// `populate_video_details`. It deliberately skips the heavy work the full +/// path does after that: the JS signature/nsig eval (`signature_timestamp` +/// plus the per-format throttling deobfuscation), the extra WEB `/player` +/// metadata round-trip (`fetch_web_metadata`), the iOS client fetch, and +/// the stream / manifest / caption extraction. +/// +/// `view_count` and `duration_seconds` are read from the SAME +/// `videoDetails` the full path uses, and `populate_microformat` (the only +/// later step that could touch them) never writes either field — so for +/// those two values this returns exactly what `stream_info` would, just +/// without the ~500ms JS/stream tail and the redundant WEB round-trip. +/// Built for the subscription-feed view-count/duration backfill. +pub fn stream_metadata(video_id: &str) -> Result { + let localization = NewPipe::preferred_localization(); + let content_country = NewPipe::preferred_content_country(); + + // Same anonymous-or-provider token resolution as the full path so the + // playability + decoy checks behave identically. With no PoTokenProvider + // registered (the default) this resolves to None → anonymous reel. + let provider = po_token_provider(); + let android_token: Option = options_or_provider(None, None, None, || { + provider + .as_ref() + .and_then(|p| p.get_android_client_po_token(video_id).ok().flatten()) + }); + + let android_cpn = generate_content_playback_nonce(); + let player_response = fetch_android( + video_id, + &localization, + &content_country, + &android_cpn, + android_token + .as_ref() + .map(|t| t.player_request_po_token.as_str()), + android_token.as_ref().map(|t| t.visitor_data.as_str()), + )?; + + check_playability_status(&player_response)?; + if is_player_response_not_valid(&player_response, video_id) { + return Err(ExtractionError::Other( + "ANDROID player response is not valid (decoy detected)".into(), + )); + } + + let mut info = StreamInfo { + service_id: 0, + url: format!("https://www.youtube.com/watch?v={video_id}"), + video_id: video_id.to_string(), + stream_type: Some(StreamType::VideoStream), + ..StreamInfo::default() + }; + populate_video_details(&mut info, &player_response); + Ok(info) +} + fn options_or_provider( opt_player_token: Option<&str>, opt_streaming_token: Option<&str>,