youtube: don't reset visitorData / retry VISIONOS on permanent content failures
Some checks failed
gitleaks / scan (push) Failing after 1s
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:
parent
6df61c1f52
commit
d12e92fb60
1 changed files with 81 additions and 5 deletions
|
|
@ -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()),
|
android_token.as_ref().map(|t| t.visitor_data.as_str()),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// A primary-android rejection (playability failure or decoy) is exactly the
|
// A primary-android rejection is the symptom a poisoned visitorData would
|
||||||
// symptom a poisoned visitorData would produce on the primary reel call —
|
// produce, so drop the cache to self-heal — but ONLY for session/transient
|
||||||
// and since it aborts extraction the value would otherwise never self-heal.
|
// failures (bot-wall, decoy). A permanent content property (age/geo/paid/…)
|
||||||
// Drop the cached visitorData so the next extraction fetches a fresh one.
|
// 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) {
|
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);
|
return Err(e);
|
||||||
}
|
}
|
||||||
if is_player_response_not_valid(&player_response, video_id) {
|
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
|
/// primary_is_visionos)` for the winning client, or the ORIGINAL ANDROID error
|
||||||
/// when VISIONOS also fails. `fetch_visionos` is only invoked on the fallback
|
/// when VISIONOS also fails. `fetch_visionos` is only invoked on the fallback
|
||||||
/// path (so a usable ANDROID never triggers a visionOS round-trip).
|
/// 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(
|
fn select_primary(
|
||||||
android_result: Result<Value, ExtractionError>,
|
android_result: Result<Value, ExtractionError>,
|
||||||
video_id: &str,
|
video_id: &str,
|
||||||
|
|
@ -733,6 +756,17 @@ fn select_primary(
|
||||||
},
|
},
|
||||||
Err(e) => e,
|
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
|
// ANDROID unusable → drop the (likely poisoned) visitorData, then try
|
||||||
// VISIONOS as the primary. On any VISIONOS failure, surface the ORIGINAL
|
// VISIONOS as the primary. On any VISIONOS failure, surface the ORIGINAL
|
||||||
// ANDROID error rather than the visionOS one.
|
// 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]
|
#[test]
|
||||||
fn cascade_falls_back_on_android_fetch_error() {
|
fn cascade_falls_back_on_android_fetch_error() {
|
||||||
// A hard ANDROID fetch error (not just a playability fail) also cascades.
|
// A hard ANDROID fetch error (not just a playability fail) also cascades.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue