youtube: don't reset visitorData / retry VISIONOS on permanent content failures
Some checks failed
gitleaks / scan (push) Failing after 1s

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.
This commit is contained in:
Cobb 2026-08-06 10:03:46 -07:00
parent 6df61c1f52
commit d12e92fb60

View file

@ -453,12 +453,15 @@ pub fn stream_metadata(video_id: &str) -> Result<StreamInfo, ExtractionError> {
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<Value, ExtractionError>,
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.