From d12e92fb605463f14b80458a7fbc0e8979b17420 Mon Sep 17 00:00:00 2001 From: Cobb Date: Thu, 6 Aug 2026 10:03:46 -0700 Subject: [PATCH] youtube: don't reset visitorData / retry VISIONOS on permanent content failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rejected primary response triggered a visitorData cache reset (and, in the full path, a VISIONOS retry) for ANY playability failure. But age-restricted / geo / paid / private / premium / SoundCloud-Go+ / terminated are permanent properties of the video — client- and visitorData-independent — so resetting + retrying VISIONOS just churns the (visitorData-based) cache the VISIONOS path relies on and burns a wasted /player round-trip. The feed enrich hits many such items per refresh, so the dogfood logs showed a visitorData reset before every age-restricted item. Add is_permanent_content_failure() and gate on it: select_primary returns the error immediately (no reset, no VISIONOS) for permanent failures; stream_metadata skips the reset for them. BotDetected (the actual bot-wall), decoys, and network/transient errors stay fully recoverable — reset + VISIONOS as before. --- src/youtube/stream_extractor.rs | 86 +++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 5 deletions(-) diff --git a/src/youtube/stream_extractor.rs b/src/youtube/stream_extractor.rs index bc10fdd..e403b3e 100644 --- a/src/youtube/stream_extractor.rs +++ b/src/youtube/stream_extractor.rs @@ -453,12 +453,15 @@ pub fn stream_metadata(video_id: &str) -> Result { android_token.as_ref().map(|t| t.visitor_data.as_str()), )?; - // A primary-android rejection (playability failure or decoy) is exactly the - // symptom a poisoned visitorData would produce on the primary reel call — - // and since it aborts extraction the value would otherwise never self-heal. - // Drop the cached visitorData so the next extraction fetches a fresh one. + // A primary-android rejection is the symptom a poisoned visitorData would + // produce, so drop the cache to self-heal — but ONLY for session/transient + // failures (bot-wall, decoy). A permanent content property (age/geo/paid/…) + // is client- and visitorData-independent, so resetting on it is pointless + // churn — and the feed enrich hits many such items per refresh. if let Err(e) = check_playability_status(&player_response) { - stream_helper::reset_visitor_data_cache(); + if !is_permanent_content_failure(&e) { + stream_helper::reset_visitor_data_cache(); + } return Err(e); } if is_player_response_not_valid(&player_response, video_id) { @@ -720,6 +723,26 @@ fn primary_response_usable(resp: &Value, video_id: &str) -> Result<(), Extractio /// primary_is_visionos)` for the winning client, or the ORIGINAL ANDROID error /// when VISIONOS also fails. `fetch_visionos` is only invoked on the fallback /// path (so a usable ANDROID never triggers a visionOS round-trip). +/// A failure that a different client (VISIONOS) or a fresh visitorData cannot +/// change — a permanent property of the video/account. For these we skip both +/// the visitorData reset and the VISIONOS retry: they'd fail identically and +/// only churn the visitorData cache the VISIONOS path itself depends on. +/// `BotDetected`, decoy (`Other`), and network/transport errors stay recoverable. +fn is_permanent_content_failure(err: &ExtractionError) -> bool { + matches!( + err, + ExtractionError::ContentUnavailable( + ContentUnavailable::AgeRestricted + | ContentUnavailable::GeoRestricted + | ContentUnavailable::Paid + | ContentUnavailable::Private + | ContentUnavailable::YoutubeMusicPremium + | ContentUnavailable::SoundCloudGoPlus + | ContentUnavailable::AccountTerminated + ) + ) +} + fn select_primary( android_result: Result, video_id: &str, @@ -733,6 +756,17 @@ fn select_primary( }, Err(e) => e, }; + // A permanent content property (age/geo/paid/private/premium/terminated) + // fails identically on every client — return it immediately rather than + // burning a visitorData reset + a second /player round-trip that VISIONOS + // can't recover (and which would needlessly churn the visitorData the + // VISIONOS path relies on). + if is_permanent_content_failure(&android_error) { + log::debug!( + "[{video_id}] ANDROID unusable ({android_error}); permanent content — skipping VISIONOS" + ); + return Err(android_error); + } // ANDROID unusable → drop the (likely poisoned) visitorData, then try // VISIONOS as the primary. On any VISIONOS failure, surface the ORIGINAL // ANDROID error rather than the visionOS one. @@ -1661,6 +1695,48 @@ mod tests { ); } + #[test] + fn cascade_skips_visionos_for_permanent_content_failure() { + // A permanent content property (geo-restricted here) fails identically + // on every client → NO visitorData reset, NO VISIONOS retry; surface the + // original error immediately. This is the fix for the feed enrich + // churning visitorData on every age-restricted item. + let fallback_ran = std::cell::Cell::new(false); + let visionos_ran = std::cell::Cell::new(false); + let geo = json!({ + "playabilityStatus": { + "status": "UNPLAYABLE", + "reason": "This video is not available in your country" + }, + "videoDetails": {"videoId": "vid"} + }); + let err = select_primary( + Ok(geo), + "vid", + || fallback_ran.set(true), + || { + visionos_ran.set(true); + Ok(ok_response("vid", 18)) + }, + ) + .expect_err("a permanent content failure must not be recovered"); + assert!( + matches!( + err, + ExtractionError::ContentUnavailable(ContentUnavailable::GeoRestricted) + ), + "the original permanent error must be returned, got {err:?}" + ); + assert!( + !fallback_ran.get(), + "visitorData reset must NOT run for a permanent content failure" + ); + assert!( + !visionos_ran.get(), + "VISIONOS must NOT be fetched for a permanent content failure" + ); + } + #[test] fn cascade_falls_back_on_android_fetch_error() { // A hard ANDROID fetch error (not just a playability fail) also cascades.