Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/ZemerTeam/zemer-cipher/llms.txt

Use this file to discover all available pages before exploring further.

CipherDeobfuscator is the main entry point for resolving YouTube streaming URLs. It handles both signature deobfuscation (converting a signatureCipher query string into a valid stream URL) and the n-parameter transformation needed to prevent CDN throttling. Both operations execute inside an Android WebView that runs the real YouTube player JavaScript.

Prerequisites

ZemerCipher.initialize() must have been called before using any method on CipherDeobfuscator. All public methods are suspend functions — call them from a coroutine or a suspend context.

Getting the signatureTimestamp

Before making an InnerTube /player API call, retrieve the signatureTimestamp (sts) so the server signs the response with the same player version currently loaded in the WebView. Mismatching player versions cause the CDN to reject the deciphered URL with a 403.
val sts: Int? = CipherDeobfuscator.signatureTimestamp()
// Include sts in your /player request:
// { "playbackContext": { "contentPlaybackContext": { "signatureTimestamp": sts } } }
signatureTimestamp() fetches (or reuses the cached) player JS before returning, so it may trigger a network request on first call or after cache expiry.

Deobfuscating a Signature Cipher

YouTube’s streamingData.adaptiveFormats[n].signatureCipher is a URL-encoded query string of the form s=<obfuscated-sig>&sp=signature&url=<base-url>. Pass it directly to deobfuscateStreamUrl:
// streamingData.adaptiveFormats[n].signatureCipher looks like:
// "s=ABCDEF...&sp=signature&url=https%3A%2F%2Frr..."
val streamUrl: String? = CipherDeobfuscator.deobfuscateStreamUrl(
    signatureCipher = format.signatureCipher,
    videoId = videoId
)
if (streamUrl == null) {
    // Deobfuscation failed — fall back to an alternate client
    return
}
deobfuscateStreamUrl returns null on failure and never throws. Internally, on the first failure it invalidates the cached player JS, closes the WebView, and retries once with a freshly fetched player. If the retry also fails, it returns null.

Transforming the N-Parameter

After deobfuscating the signature, transform the n= query parameter in the resulting URL to prevent CDN throttling. This is a separate step from signature deobfuscation:
val finalUrl: String = CipherDeobfuscator.transformNParamInUrl(streamUrl)
// Always call this even if deobfuscateStreamUrl already returned the URL —
// the n-transform is a separate step.
transformNParamInUrl returns the original URL unchanged if no n= parameter is present, if the n-transform function was not extracted at WebView creation time, or if the transform itself fails. It never throws.

Full Playback Pipeline

Combine both steps into a single helper:
suspend fun resolveStreamUrl(
    signatureCipher: String,
    videoId: String
): String? {
    // Step 1: Deobfuscate the signature
    val deciphered = CipherDeobfuscator.deobfuscateStreamUrl(
        signatureCipher = signatureCipher,
        videoId = videoId
    ) ?: return null

    // Step 2: Transform the n-parameter
    return CipherDeobfuscator.transformNParamInUrl(deciphered)
}

Handling CDN Rejections (403)

A 403 on a correctly deciphered URL typically means the player config entry is stale or incorrect — for example, the sig call expression was valid JavaScript but computed the wrong output. The exception-retry path inside deobfuscateStreamUrl cannot detect this because no exception is thrown; the rejected URL is the only signal. Call onStreamRejected() when your HTTP client or ExoPlayer receives a 403 on a deciphered URL:
// When ExoPlayer or your HTTP client receives a 403 on a deciphered URL:
val configChanged = CipherDeobfuscator.onStreamRejected()
if (configChanged) {
    // The player config was updated — retry stream resolution
    val newUrl = resolveStreamUrl(signatureCipher, videoId)
}
onStreamRejected() triggers PlayerConfigStore.refreshAfterStreamRejection(), which re-fetches the remote player_configs.json (rate-limited with its own 5-minute cooldown). If the remote config has been updated with a corrected entry, configChanged = true signals that the WebView will be rebuilt with the new config on the next decipher call, and retrying resolution is worthwhile.

Diagnostic: Last Used Player Hash

CipherDeobfuscator.lastUsedPlayerHash exposes the 8-hex player hash of the player JS currently loaded in the WebView:
val hash: String? = CipherDeobfuscator.lastUsedPlayerHash
// e.g. "445213fb"
This is a read-only diagnostic property. It returns null when no WebView has been created yet in the current process.
deobfuscateStreamUrl and transformNParamInUrl both acquire the same internal Mutex before accessing the WebView. They cannot run concurrently — calling both from different coroutines simultaneously for the same instance will cause the second call to suspend until the first completes.

Build docs developers (and LLMs) love