perf(extract): add lightweight stream_metadata() for feed backfill

stream_metadata(video_id) is the cheap tail of stream_info_with: the same
Android /player fetch + playability + decoy checks, then populate_video_details
only — skipping the JS sig/nsig eval, the extra WEB /player metadata round-trip,
the iOS client, and stream/manifest/caption extraction.

view_count + duration_seconds come from the same videoDetails the full path
uses (populate_microformat never touches either), so they are identical to what
stream_info returns — without the ~500ms JS/stream tail or the redundant WEB
round-trip. Backs the subscription-feed view-count/duration backfill, which
discarded everything but those two fields.
This commit is contained in:
Sulkta 2026-06-21 06:55:27 -07:00
parent 5e0ec08928
commit 57efb7326a

View file

@ -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<StreamInfo, ExtractionError> {
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<PoTokenResult> = 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>,