deobfuscate: soft-fail sig_fn/nsig_fn extraction

When YouTube rotates player.js to a shape our six sig/nsig regex
patterns don't recognise (eg. c2f7551f, May 2026), the whole player
path used to die at extract_fns even for clients that don't need the
sig fn at all (iOS, Android, Tv all get pre-signed stream URLs).

Now sig_fn / nsig_fn extraction is best-effort. Only the signature
timestamp is required — every `needs_deobf` client needs sts in
the request payload, but the actual deobfuscation functions are only
consumed by map_url when a stream URL carries `&s=` or `&n=`.
On failure we log a warning and store an empty string; Deobfuscator
then skips the JS eval, and any deobfuscate_sig/deobfuscate_nsig
call will fail loudly with "sig fn unavailable" instead of crashing
the player.

Keeps the Tv fallback alive even when sig deobf regex breaks.
This commit is contained in:
Kayos 2026-05-24 11:53:12 -07:00
parent a6df2ff7f4
commit bda0fea193

View file

@ -61,10 +61,34 @@ impl DeobfData {
}
pub fn extract_fns(js_url: &str, player_js: &str) -> Result<Self, Error> {
let sig_fn = get_sig_fn(player_js)?;
let nsig_fn = get_nsig_fn(player_js)?;
// The signature timestamp is the only piece every "needs_deobf" client
// actually requires in its request payload — without it, those clients
// get an error back. So we hard-fail on sts extraction.
let sts = get_sts(player_js)?;
// sig_fn and nsig_fn are needed only when YouTube returns stream URLs
// containing the &s= cipher / &n= throttling params. Most clients
// (iOS, Android, Tv) get pre-signed URLs and never touch these.
// Tolerate extraction failures here so a single rotated player.js
// shape doesn't bring down the whole player path for those clients.
// The dead-code fallback is preserved: if a stream URL DOES need
// deobfuscation, `Deobfuscator::deobfuscate_sig` will fail with a
// clear "sig fn unavailable" error instead of crashing the player.
let sig_fn = match get_sig_fn(player_js) {
Ok(f) => f,
Err(e) => {
tracing::warn!("could not extract sig deobf fn (sig deobfuscation disabled until YT rotates player.js again): {}", e);
String::new()
}
};
let nsig_fn = match get_nsig_fn(player_js) {
Ok(f) => f,
Err(e) => {
tracing::warn!("could not extract nsig deobf fn (throttling parameter deobf disabled until YT rotates player.js again): {}", e);
String::new()
}
};
Ok(Self {
js_url: js_url.to_owned(),
sig_fn,
@ -79,13 +103,23 @@ impl Deobfuscator {
pub fn new(data: &DeobfData) -> Result<Self, DeobfError> {
let rt = Runtime::new()?;
let ctx = Context::full(&rt)?;
ctx.with(|ctx| {
let mut opts = rquickjs::context::EvalOptions::default();
opts.strict = false;
ctx.eval_with_options::<(), _>(data.sig_fn.as_bytes(), opts)?;
let mut opts = rquickjs::context::EvalOptions::default();
opts.strict = false;
ctx.eval_with_options::<(), _>(data.nsig_fn.as_bytes(), opts)
ctx.with(|ctx| -> Result<(), rquickjs::Error> {
// Skip JS eval for any deobf fn we couldn't extract. The matching
// `deobfuscate_sig` / `deobfuscate_nsig` calls will then return an
// Err naturally because the global won't be defined — and that
// only matters if a stream actually has obfuscated params, which
// shouldn't happen on the iOS/Android/Tv InnerTube paths.
if !data.sig_fn.is_empty() {
let mut opts = rquickjs::context::EvalOptions::default();
opts.strict = false;
ctx.eval_with_options::<(), _>(data.sig_fn.as_bytes(), opts)?;
}
if !data.nsig_fn.is_empty() {
let mut opts = rquickjs::context::EvalOptions::default();
opts.strict = false;
ctx.eval_with_options::<(), _>(data.nsig_fn.as_bytes(), opts)?;
}
Ok(())
})?;
Ok(Self { ctx })
}