From a21a6e14ab06f0b271b12bceb6db097d22aa659d Mon Sep 17 00:00:00 2001 From: Cobb Date: Wed, 29 Jul 2026 11:28:33 -0700 Subject: [PATCH] youtube: S3 narrowed-$fields feed metadata + S7 player.js byte-release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-outage-review batch (Fable adversarially audited: SHIP-WITH-FIXES; the one MED is folded below). S3 — cheaper stream_metadata feed-enrich. On the anonymous reel path, ask reel_item_watch for only playabilityStatus+videoDetails via a narrowed nested $fields selector (~3-6 KB vs the full ~150-500 KB reel body). BEST-EFFORT with a HARD fallback to the unchanged full fetch_android, so feed enrichment can never regress: the narrowed response is trusted only when videoDetails.videoId equals the requested id — a bare is_some() would accept a gutted videoDetails:{} or wave through a videoId-stripped decoy, so the id binding also subsumes the decoy check for this path (audit MED). Full extraction path untouched; po_token path skips the narrowing. S7 — reclaim the raw ~1.5 MB player.js once the hot-path snippets (signature timestamp + nsig) are built, keeping player_url as the installed-generation sentinel. memo_verdict is rekeyed off player_url (was player_code): under the old lockstep invariant the two were equivalent, and decoupling is REQUIRED so a byte-released generation is still "installed" for memo purposes. Release is single-site, build-success-only, under the state lock; a build FAILURE keeps the bytes so sibling artifacts still extract; the dead-in-android sig path re-fetches on demand via ensure_player_code. Also fixes three leftovers from the 2026-07-29 outage flip (23ab7ad) that the fdroid CI never caught (it builds the APK but runs no cargo test/clippy): a test still asserting the old visionOS-true default (cargo test was red on main), the stale struct doc, and a derivable_impls lint (kept the manual impl explicit + #[allow] so the load-bearing false default stays greppable). 161 tests, clippy clean. --- src/youtube/js/player_manager.rs | 132 ++++++++++++++++- src/youtube/stream_extractor.rs | 247 +++++++++++++++++++++++++++++-- src/youtube/stream_helper.rs | 50 ++++++- 3 files changed, 412 insertions(+), 17 deletions(-) diff --git a/src/youtube/js/player_manager.rs b/src/youtube/js/player_manager.rs index 020fc32..9dc65e9 100644 --- a/src/youtube/js/player_manager.rs +++ b/src/youtube/js/player_manager.rs @@ -3,7 +3,11 @@ // sole public class in the JS subsystem). // // Cache layout: -// * cached_player_code — process-lifetime, until invalidate() +// * cached_player_code — the raw ~1.5 MB player.js. Retained only +// until the hot-path snippets (signature timestamp + nsig) are built, +// then DROPPED to reclaim native heap (S7); `player_url` remains as the +// installed-generation sentinel. Re-fetched on demand if a later, +// un-built artifact (the dead-in-android sig path) ever needs the bytes. // * cached_signature_timestamp // * cached_sig_snippet — assembled JS, ready for runtime::run // * cached_nsig_name + snippet @@ -122,15 +126,20 @@ fn memo_verdict(state: &ManagerState, memo: &Option) -> MemoVerdict { return MemoVerdict::Proceed; }; let live = m.at.elapsed() < FAILURE_COOLDOWN; - let installed_same = state.player_code.is_some() - && state.player_url.as_deref() == Some(m.player_url.as_str()); + // Installed-generation sentinel: `player_url` marks WHICH player.js is + // installed, set/cleared in lockstep with install/invalidate and keyed the + // same way the memos are. It is DECOUPLED from `player_code` (the raw bytes), + // which S7 drops once the hot-path snippets are built — so a byte-released + // player.js is still "installed" for memo purposes. (ensure_player_code keeps + // keying off `player_code` because IT needs the bytes to build from.) + let installed_same = state.player_url.as_deref() == Some(m.player_url.as_str()); if installed_same { if live { MemoVerdict::Replay(m.error.clone()) } else { MemoVerdict::Probe(m.player_url.clone()) } - } else if state.player_code.is_some() { + } else if state.player_url.is_some() { // A different player.js has been installed since the failure — // retry this artifact against it. MemoVerdict::Proceed @@ -141,6 +150,28 @@ fn memo_verdict(state: &ManagerState, memo: &Option) -> MemoVerdict { } } +/// S7: reclaim the raw ~1.5 MB player.js once the hot-path artifacts are built. +/// +/// The bytes exist only to build the derived snippets. The android-primary flow +/// needs exactly two — the signature timestamp and the nsig deobfuscator — and +/// both are always built per open (timestamp by `stream_info_with`, nsig by the +/// first throttled format). Once both are present, every later call hits the +/// snippet/timestamp cache and never touches the bytes again, so we drop them +/// and keep `player_url` as the installed-generation sentinel. +/// +/// Deliberately keyed on {timestamp, nsig} and NOT on the sig snippet: the sig +/// path is dead in the android-primary flow (formats carry direct URLs), so +/// requiring it would pin the bytes forever. If that dead path IS ever hit after +/// a release, `ensure_player_code` (which keys off `player_code`) simply +/// re-fetches — the audit-sanctioned worst case. Called ONLY on a clean build +/// success; a build FAILURE keeps the bytes so sibling artifacts can still +/// extract from them. +fn release_player_code_if_built(state: &mut ManagerState) { + if state.signature_timestamp.is_some() && state.nsig_snippet.is_some() { + state.player_code = None; + } +} + /// Outcome of ensure_player_code. enum Ensured { /// player_code is installed; `fresh` = this call downloaded it. @@ -152,6 +183,13 @@ enum Ensured { #[derive(Default)] struct ManagerState { + // `player_url` is the installed-GENERATION sentinel — Some iff a player.js + // has been discovered+installed and its derived snippets are the current + // generation. `player_code` is the raw ~1.5 MB bytes, dropped by + // `release_player_code_if_built` once the hot-path snippets exist (S7), so + // it can be None while `player_url` is Some. memo/rotation logic keys off + // `player_url`; only `ensure_player_code` (which needs to build) keys off + // `player_code`, re-fetching when the bytes are gone. player_url: Option, player_code: Option, @@ -409,6 +447,9 @@ impl PlayerManager { // A successful build proves any lingering memo for this // artifact records a superseded player.js. *which.memo_mut(&mut state) = None; + // S7: once the hot-path snippets exist, the raw player.js + // bytes are dead weight — release them (keeping player_url). + release_player_code_if_built(&mut state); Ok((v, fresh)) } Err(e) => { @@ -539,6 +580,13 @@ impl PlayerManager { fn state_lock_is_free(&self) -> bool { self.state.try_lock().is_some() } + + /// Test hook: are the raw player.js bytes still retained? (S7 drops them + /// once the hot-path snippets are built, keeping `player_url`.) + #[cfg(test)] + fn player_code_is_retained(&self) -> bool { + self.state.lock().player_code.is_some() + } } impl Default for PlayerManager { @@ -868,4 +916,80 @@ mod tests { let out = worker.join().unwrap().unwrap(); assert_eq!(out, "https://x/?n=cba"); } + + // ---- S7 player_code byte-release lifecycle ---------------------------- + + #[test] + fn player_code_released_after_hot_artifacts_built_but_stays_installed() { + let _g = GLOBAL_DOWNLOADER_LOCK.lock(); + let stub = Arc::new(StubDownloader::new("aaaaaaaa", PLAYER_GOOD)); + install(&stub); + let mgr = PlayerManager::new(); + + // Timestamp built first (mirrors stream_info_with's worker). Only ONE of + // the two hot artifacts exists → bytes MUST still be retained so nsig can + // build without a re-download. + assert_eq!(mgr.signature_timestamp("vid").unwrap(), 20244); + assert_eq!(stub.count(), 2, "discovery + body download"); + assert!( + mgr.player_code_is_retained(), + "bytes kept until BOTH hot snippets exist" + ); + + // nsig built from the SAME retained bytes (no new network) → now both + // hot artifacts exist → bytes released, but the generation stays + // installed (player_url intact). + let out = mgr + .url_with_throttling_parameter_deobfuscated("vid", "https://x/?n=abc") + .unwrap(); + assert_eq!(out, "https://x/?n=cba"); + assert_eq!(stub.count(), 2, "nsig built from retained bytes, no refetch"); + assert!( + !mgr.player_code_is_retained(), + "bytes dropped once timestamp + nsig are built" + ); + assert!( + mgr.player_url().is_some(), + "installed-generation sentinel survives the byte release" + ); + + // Warm re-request of BOTH hot artifacts after the release: pure cache + // hits, zero further network — the release is invisible to the hot path. + assert_eq!(mgr.signature_timestamp("vid").unwrap(), 20244); + let out = mgr + .url_with_throttling_parameter_deobfuscated("vid", "https://x/?n=xyz") + .unwrap(); + assert_eq!(out, "https://x/?n=zyx"); + assert_eq!(stub.count(), 2, "warm path never re-downloads after release"); + assert!(!mgr.player_code_is_retained()); + } + + #[test] + fn build_failure_keeps_player_code_for_sibling_extraction() { + let _g = GLOBAL_DOWNLOADER_LOCK.lock(); + // nsig build fails on this player.js; the signature timestamp extracts. + let stub = Arc::new(StubDownloader::new("aaaaaaaa", PLAYER_NSIG_BROKEN)); + install(&stub); + let mgr = PlayerManager::new(); + + // nsig build FAILS → memo armed, bytes must be KEPT (never released on a + // failure) so the sibling timestamp artifact can still extract. + assert!(mgr + .url_with_throttling_parameter_deobfuscated("vid", "https://x/?n=AAA") + .is_err()); + assert_eq!(stub.count(), 2); + assert!( + mgr.player_code_is_retained(), + "a build failure must NOT drop the bytes" + ); + + // Sibling timestamp extracts from the retained bytes — no re-download — + // and because nsig never built, the release condition stays unmet. + assert_eq!(mgr.signature_timestamp("vid").unwrap(), 19999); + assert_eq!(stub.count(), 2, "sibling built from retained bytes"); + assert!( + mgr.player_code_is_retained(), + "release requires the nsig snippet, which never built here" + ); + } } diff --git a/src/youtube/stream_extractor.rs b/src/youtube/stream_extractor.rs index f71a61a..36c4336 100644 --- a/src/youtube/stream_extractor.rs +++ b/src/youtube/stream_extractor.rs @@ -59,12 +59,14 @@ pub struct ExtractOptions { /// videos where ANDROID/IOS cap at 360p. Its formats are merged in /// (android-preferred on itag ties); a failure changes nothing. /// - /// Defaults to `true` (see `impl Default`), matching upstream NPE, which - /// runs the visionOS fetch UNCONDITIONALLY in `onFetchPage`. The shipped app - /// path (`stream_info` → `stream_info_with(.., ExtractOptions::default())`) - /// therefore fetches visionOS on every open. Set to `false` per-call to skip - /// it (e.g. a latency-sensitive path). The cheap `stream_metadata` path does - /// not build streams and never fetches visionOS regardless. + /// Defaults to `false` (see `impl Default`) as of the 2026-07-29 playback + /// outage fix. Upstream NPE runs the visionOS fetch UNCONDITIONALLY in + /// `onFetchPage`, but its adaptive video-only + HLS output has no working + /// playback path in the Straw app yet, so the shipped path (`stream_info` → + /// `stream_info_with(.., ExtractOptions::default())`) keeps it OFF. Set to + /// `true` per-call to opt in (e.g. once the app's adaptive path is fixed and + /// verified via the NPE differential harness). The cheap `stream_metadata` + /// path does not build streams and never fetches visionOS regardless. pub fetch_visionos_client: bool, pub android_streaming_pot: Option, pub ios_streaming_pot: Option, @@ -74,6 +76,12 @@ pub struct ExtractOptions { pub ios_player_request_pot: Option, } +// Explicit manual impl (not a derive) so the load-bearing `fetch_visionos_client: +// false` default is spelled out and greppable — a silent flip of exactly this +// value caused the 2026-07-29 playback outage. clippy flags it as derivable only +// because every field currently equals its type default; keep it explicit on +// purpose so re-enabling visionOS is a deliberate, visible edit here. +#[allow(clippy::derivable_impls)] impl Default for ExtractOptions { fn default() -> Self { Self { @@ -350,7 +358,7 @@ pub fn stream_metadata(video_id: &str) -> Result { }); let android_cpn = generate_content_playback_nonce(); - let player_response = fetch_android( + let player_response = fetch_android_metadata( video_id, &localization, &content_country, @@ -429,6 +437,69 @@ fn fetch_android( } } +/// Metadata-scoped Android fetch for the cheap `stream_metadata` feed-enrich +/// path — do NOT use on the full extraction path, which needs `streamingData`. +/// +/// On the anonymous reel path (the default — no PoTokenProvider registered) this +/// tries the NARROWED-`$fields` reel variant, which asks the endpoint for only +/// `playabilityStatus` + `videoDetails` (~3-6 KB vs the full ~150-500 KB reel +/// body). It is BEST-EFFORT with a HARD full-fetch fallback: `reel_item_watch` +/// may not honor the nested `$fields` selector, so if the narrowed request errors +/// (e.g. a 400 rejecting the selector) OR returns a shape MISSING `videoDetails` +/// (endpoint ignored it into an error/empty response), we fall back to the full +/// `fetch_android`, which is byte-for-byte today's behavior. Net: narrowed +/// honored → bytes saved; narrowed not honored → identical to today. Feed +/// enrichment can never regress from this optimization. +/// +/// The po_token `/player` path (non-default) has no `$fields` narrowing to apply, +/// so it goes straight to the shared `fetch_android`. +fn fetch_android_metadata( + video_id: &str, + localization: &Localization, + content_country: &ContentCountry, + cpn: &str, + po_token: Option<&str>, + visitor_data: Option<&str>, +) -> Result { + // Only the anonymous reel path carries a $fields selector to narrow. + if po_token.is_none() { + if let Ok(r) = stream_helper::get_android_reel_player_response_metadata( + video_id, + localization, + content_country, + cpn, + ) { + let unwrapped = unwrap_reel_player_response(r); + // Guard: only trust the narrowed response if its videoDetails is + // bound to the REQUESTED video — i.e. `videoDetails.videoId` is + // present AND equals `video_id`. A bare `is_some()` would accept a + // present-but-gutted `videoDetails: {}` (endpoint half-honored the + // nested selector) → silently-empty title/views/duration, and would + // also wave through a videoId-stripped decoy (the downstream decoy + // check treats a missing videoId as valid). Binding to the id + // subsumes the decoy check for this path and rejects every + // not-fully-honored shape into the full-fetch fallback. + let honored = unwrapped + .get("videoDetails") + .and_then(|vd| vd.get("videoId")) + .and_then(|id| id.as_str()) + == Some(video_id); + if honored { + return Ok(unwrapped); + } + } + } + // Fallback (and the po_token path): the full, unchanged fetch. + fetch_android( + video_id, + localization, + content_country, + cpn, + po_token, + visitor_data, + ) +} + /// Unwrap the reel endpoint's one-level `playerResponse` nesting, MOVING the /// (~150-500 KB) subtree out instead of deep-cloning it. `r` is owned and dead /// after this call, so `serde_json::Map::remove` returns the owned subtree with @@ -1254,10 +1325,12 @@ mod tests { } #[test] - fn default_options_fetch_visionos() { - // Fix 1: the shipped app path (stream_info → ExtractOptions::default()) - // must fetch visionOS on every open. - assert!(ExtractOptions::default().fetch_visionos_client); + fn default_options_visionos_off() { + // 2026-07-29 outage fix: the shipped app path (stream_info → + // ExtractOptions::default()) must keep visionOS OFF — its adaptive/HLS + // output has no working playback path in the app yet. Re-enable only + // after the app handles it and the NPE harness verifies the shape. + assert!(!ExtractOptions::default().fetch_visionos_client); } // ---- I-3 xtags audioTrackType ----------------------------------------- @@ -1328,4 +1401,156 @@ mod tests { assert_eq!(a.track_type, None); assert!(a.is_descriptive); } + + // ---- S3 narrowed-$fields feed-enrich fetch + HARD fallback ------------ + // + // These touch the process-global Downloader, so they serialize on + // DOWNLOADER_TEST_LOCK (shared with the player_manager / stream_helper + // stub tests). They drive `fetch_android_metadata` DIRECTLY with an + // explicit `po_token: None` so they exercise the anonymous narrowed-reel + // path without depending on the process-global PoTokenProvider state that + // the potoken tests mutate. + + use crate::downloader::request::Request; + use crate::downloader::response::Response; + use crate::downloader::Downloader; + use crate::exceptions::NetworkError; + use crate::newpipe::DOWNLOADER_TEST_LOCK; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + /// Serves the reel endpoint two ways, distinguished by the `$fields` + /// selector in the URL: the NARROWED metadata request (selector contains a + /// nested `playerResponse.` path) vs the FULL request (`$fields=playerResponse`). + /// Counts each so tests can assert whether the fallback fired. `visitor_id` + /// is declined (best-effort None) so the reel proceeds without it. + struct ReelStub { + narrowed_body: String, + full_body: String, + narrowed_hits: AtomicUsize, + full_hits: AtomicUsize, + } + + impl ReelStub { + fn new(narrowed_body: &str, full_body: &str) -> Self { + Self { + narrowed_body: narrowed_body.to_string(), + full_body: full_body.to_string(), + narrowed_hits: AtomicUsize::new(0), + full_hits: AtomicUsize::new(0), + } + } + } + + impl Downloader for ReelStub { + fn execute(&self, request: Request) -> Result { + let url = request.url().to_string(); + if url.contains("visitor_id") { + // Best-effort path: decline → reel proceeds without visitorData. + return Err(NetworkError::Transport("reel-stub: no visitor".into())); + } + if url.contains("reel_item_watch") { + // The nested dot-selector (`playerResponse.playabilityStatus,...`) + // only appears on the narrowed metadata request; the full request + // ends `&$fields=playerResponse` with no dot. + let (body, counter) = if url.contains("playerResponse.") { + (&self.narrowed_body, &self.narrowed_hits) + } else { + (&self.full_body, &self.full_hits) + }; + counter.fetch_add(1, Ordering::SeqCst); + return Ok(Response::new(200, "OK", Default::default(), body.clone(), url)); + } + Err(NetworkError::Transport("reel-stub: unexpected request".into())) + } + } + + #[test] + fn feed_metadata_falls_back_to_full_reel_when_narrowed_lacks_videodetails() { + let _g = DOWNLOADER_TEST_LOCK.lock(); + stream_helper::reset_visitor_data_cache(); + // Narrowed response has playabilityStatus but NO videoDetails — the + // shape the endpoint would return if it ignored/rejected the nested + // `$fields` selector into an error/empty body. The full response carries + // the real videoDetails + streamingData. + let stub = Arc::new(ReelStub::new( + r#"{"playerResponse":{"playabilityStatus":{"status":"OK"}}}"#, + r#"{"playerResponse":{"playabilityStatus":{"status":"OK"},"videoDetails":{"videoId":"vid","title":"FULL","lengthSeconds":"100","viewCount":"5"},"streamingData":{"formats":[]}}}"#, + )); + NewPipe::init(stub.clone() as Arc); + + let loc = NewPipe::preferred_localization(); + let cc = NewPipe::preferred_content_country(); + let out = fetch_android_metadata("vid", &loc, &cc, "cpn", None, None).unwrap(); + + // The narrowed request was tried, found videoDetails-less, and the full + // fetch was used as the safety fallback — feed enrich never regresses. + assert_eq!(stub.narrowed_hits.load(Ordering::SeqCst), 1, "narrowed tried"); + assert_eq!(stub.full_hits.load(Ordering::SeqCst), 1, "fell back to full"); + // The value returned is the FULL response (unwrapped playerResponse). + assert_eq!( + out.get("videoDetails").and_then(|v| v.get("title")).and_then(|v| v.as_str()), + Some("FULL") + ); + assert!(out.get("streamingData").is_some()); + stream_helper::reset_visitor_data_cache(); + } + + #[test] + fn feed_metadata_uses_narrowed_response_when_videodetails_present() { + let _g = DOWNLOADER_TEST_LOCK.lock(); + stream_helper::reset_visitor_data_cache(); + // Narrowed response DOES carry videoDetails → the endpoint honored the + // selector; use it directly, NO full-fetch fallback (this is the + // bytes-saved happy path). + let stub = Arc::new(ReelStub::new( + r#"{"playerResponse":{"playabilityStatus":{"status":"OK"},"videoDetails":{"videoId":"vid","title":"NARROWED","lengthSeconds":"42","viewCount":"7"}}}"#, + r#"{"playerResponse":{"playabilityStatus":{"status":"OK"},"videoDetails":{"videoId":"vid","title":"FULL"},"streamingData":{"formats":[]}}}"#, + )); + NewPipe::init(stub.clone() as Arc); + + let loc = NewPipe::preferred_localization(); + let cc = NewPipe::preferred_content_country(); + let out = fetch_android_metadata("vid", &loc, &cc, "cpn", None, None).unwrap(); + + assert_eq!(stub.narrowed_hits.load(Ordering::SeqCst), 1, "narrowed used"); + assert_eq!(stub.full_hits.load(Ordering::SeqCst), 0, "no fallback needed"); + assert_eq!( + out.get("videoDetails").and_then(|v| v.get("title")).and_then(|v| v.as_str()), + Some("NARROWED") + ); + // Narrowed body has no streamingData — proof we did NOT silently fall + // back to the full response. + assert!(out.get("streamingData").is_none()); + stream_helper::reset_visitor_data_cache(); + } + + #[test] + fn feed_metadata_falls_back_when_narrowed_videoid_mismatches() { + let _g = DOWNLOADER_TEST_LOCK.lock(); + stream_helper::reset_visitor_data_cache(); + // Narrowed response carries a videoDetails, but its videoId is NOT the + // requested one (a gutted/decoy shape the endpoint could return if it + // only half-honored the nested selector). The videoId-binding guard must + // REJECT it — a bare is_some() would have accepted this empty/decoy + // metadata — and fall through to the full fetch. + let stub = Arc::new(ReelStub::new( + r#"{"playerResponse":{"playabilityStatus":{"status":"OK"},"videoDetails":{"videoId":"DECOY","title":"WRONG"}}}"#, + r#"{"playerResponse":{"playabilityStatus":{"status":"OK"},"videoDetails":{"videoId":"vid","title":"FULL","lengthSeconds":"100","viewCount":"5"},"streamingData":{"formats":[]}}}"#, + )); + NewPipe::init(stub.clone() as Arc); + + let loc = NewPipe::preferred_localization(); + let cc = NewPipe::preferred_content_country(); + let out = fetch_android_metadata("vid", &loc, &cc, "cpn", None, None).unwrap(); + + assert_eq!(stub.narrowed_hits.load(Ordering::SeqCst), 1, "narrowed tried"); + assert_eq!(stub.full_hits.load(Ordering::SeqCst), 1, "mismatch -> fell back to full"); + assert_eq!( + out.get("videoDetails").and_then(|v| v.get("title")).and_then(|v| v.as_str()), + Some("FULL"), + "returned the full (correct-id) response, not the decoy" + ); + stream_helper::reset_visitor_data_cache(); + } } diff --git a/src/youtube/stream_helper.rs b/src/youtube/stream_helper.rs index 69a2524..e43c1f7 100644 --- a/src/youtube/stream_helper.rs +++ b/src/youtube/stream_helper.rs @@ -276,12 +276,58 @@ pub fn get_android_player_response( /// ANDROID `/reel/reel_item_watch` fallback — used when no poToken is /// available. Returns a `playerResponse`-shaped JSON wrapped inside the -/// reel response. +/// reel response. Requests the FULL `playerResponse` (`$fields=playerResponse`) +/// — the stream-extraction path needs `streamingData` and everything else. pub fn get_android_reel_player_response( video_id: &str, localization: &Localization, content_country: &ContentCountry, cpn: &str, +) -> Result { + reel_player_response_with_fields(video_id, localization, content_country, cpn, "playerResponse") +} + +/// Metadata-scoped variant of [`get_android_reel_player_response`] for the cheap +/// `stream_metadata` feed-enrich path. Requests a NARROWED `$fields` selector so +/// only `playabilityStatus` + `videoDetails` come back (~3-6 KB) instead of the +/// full ~150-500 KB reel body carrying every adaptive format's ~1.5 KB URL — the +/// feed path reads nothing outside those two subtrees (S3). +/// +/// BEST-EFFORT, with a full-fetch fallback owned by the caller +/// (`stream_extractor::fetch_android_metadata`): the `reel_item_watch` endpoint's +/// acceptance of the NESTED `$fields` selector (`playerResponse.videoDetails`) is +/// not guaranteed. If it honors the selector we save the bytes; if it rejects it +/// (400 → `Err` here) or returns a shape without `videoDetails`, the caller falls +/// back to the full reel fetch, so feed enrichment can NEVER regress. The +/// dot-form nested selector matches the spelling upstream/`get_web_metadata_player_response` +/// already use against the /player endpoint (`videoDetails.thumbnail.thumbnails`), +/// so the syntax itself is proven — only this endpoint's honoring of it is not. +pub fn get_android_reel_player_response_metadata( + video_id: &str, + localization: &Localization, + content_country: &ContentCountry, + cpn: &str, +) -> Result { + reel_player_response_with_fields( + video_id, + localization, + content_country, + cpn, + "playerResponse.playabilityStatus,playerResponse.videoDetails", + ) +} + +/// Shared body of the two reel helpers. `fields` is the `$fields` selector: the +/// full path passes `"playerResponse"` (byte-for-byte today's request), the +/// metadata path passes the narrowed nested selector. Everything else — the +/// best-effort visitorData, the envelope, the `playerRequest`/body shape, the +/// headers — is identical across both. +fn reel_player_response_with_fields( + video_id: &str, + localization: &Localization, + content_country: &ContentCountry, + cpn: &str, + fields: &str, ) -> Result { let mut info = InnertubeClientRequestInfo::of_android_client(); let ua = android_user_agent(content_country); @@ -310,7 +356,7 @@ pub fn get_android_reel_player_response( ); add_player_body_fields(&mut body, video_id, cpn); let url = format!( - "{YOUTUBEI_V1_GAPIS_URL}reel/reel_item_watch{DISABLE_PRETTY_PRINT_PARAM}&t={t}&id={video_id}&$fields=playerResponse", + "{YOUTUBEI_V1_GAPIS_URL}reel/reel_item_watch{DISABLE_PRETTY_PRINT_PARAM}&t={t}&id={video_id}&$fields={fields}", t = generate_content_playback_nonce() ); post_youtube(&url, &Value::Object(body), mobile_post_headers(&ua))