Skip to content

Instantly share code, notes, and snippets.

@Mathias8405
Created April 12, 2026 09:12
Show Gist options
  • Select an option

  • Save Mathias8405/ac60a0b571937c2199083a8201bfb552 to your computer and use it in GitHub Desktop.

Select an option

Save Mathias8405/ac60a0b571937c2199083a8201bfb552 to your computer and use it in GitHub Desktop.
Debugging 403 Forbidden errors in Android YouTube stream downloader (using Cobalt API and CookieJar)
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace 'com.ytextractor'
compileSdk 36
defaultConfig {
applicationId "com.ytextractor"
minSdk 26
//noinspection OldTargetApi
targetSdk 36
versionCode 1
versionName "1.0"
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
}
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
buildFeatures {
compose true
viewBinding true
}
}
dependencies {
implementation libs.androidx.core.ktx
implementation libs.androidx.appcompat
implementation libs.material
implementation libs.androidx.constraintlayout
implementation libs.androidx.recyclerview
implementation libs.androidx.lifecycle.runtime.ktx
implementation libs.androidx.activity.compose
implementation platform(libs.androidx.compose.bom)
implementation libs.androidx.compose.ui
implementation libs.androidx.compose.ui.graphics
implementation libs.androidx.compose.ui.tooling.preview
implementation libs.androidx.compose.material3
// Network
implementation libs.okhttp
implementation libs.okhttp.urlconnection
// JSON parsing
// JS engine for dynamic cipher evaluation
implementation libs.rhino
// Coroutines
implementation libs.kotlinx.coroutines.android
implementation libs.newpipe.extractor
testImplementation libs.junit
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core
androidTestImplementation platform(libs.androidx.compose.bom)
androidTestImplementation libs.androidx.compose.ui.test.junit4
debugImplementation libs.androidx.compose.ui.tooling
debugImplementation libs.androidx.compose.ui.test.manifest
}
package com.ytextractor
import okhttp3.CookieJar
import okhttp3.JavaNetCookieJar
import java.net.CookieManager
import java.net.CookiePolicy
object CookieHolder {
val cookieJar: CookieJar by lazy {
val cookieManager = CookieManager().apply {
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
}
JavaNetCookieJar(cookieManager)
}
}
package com.ytextractor
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.ytextractor.databinding.ActivityMainBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var downloader: VideoDownloader
private lateinit var videoAdapter: StreamAdapter
private lateinit var audioAdapter: StreamAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
downloader = VideoDownloader(this)
setupRecyclerViews()
setupClickListeners()
handleIncomingIntent()
}
private fun setupRecyclerViews() {
videoAdapter = StreamAdapter(this, downloader)
audioAdapter = StreamAdapter(this, downloader)
binding.recyclerVideo.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = videoAdapter
}
binding.recyclerAudio.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = audioAdapter
}
}
private fun setupClickListeners() {
binding.buttonExtract.setOnClickListener {
extractStreams()
}
binding.buttonPaste.setOnClickListener {
pasteFromClipboard()
}
binding.inputLayout.setEndIconOnClickListener {
binding.inputUrl.text?.clear()
}
}
private fun handleIncomingIntent() {
val intent = intent
if (intent.action == Intent.ACTION_SEND && intent.type == "text/plain") {
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
if (sharedText != null && (sharedText.contains("youtube.com") || sharedText.contains("youtu.be"))) {
binding.inputUrl.setText(sharedText)
extractStreams()
}
}
}
private fun extractStreams() {
val url = binding.inputUrl.text.toString().trim()
if (url.isEmpty()) {
showError("Enter a YouTube URL")
return
}
val videoId = extractVideoId(url)
if (videoId == null) {
showError("Invalid YouTube URL")
return
}
showLoading(true)
hideError()
lifecycleScope.launch(Dispatchers.IO) {
try {
val extractor = YouTubeExtractor()
val (videoItems, audioItems) = extractor.extractAllStreams(videoId)
withContext(Dispatchers.Main) {
showLoading(false)
if (videoItems.isEmpty() && audioItems.isEmpty()) {
showError("No streams found. YouTube may be blocking the request.")
} else {
showResults(videoItems, audioItems)
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
showLoading(false)
showError("Error: ${e.message}")
}
}
}
}
private fun extractVideoId(url: String): String? {
val patterns = listOf(
Regex("""(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/|youtube\.com/v/|youtube\.com/shorts/)([a-zA-Z0-9_-]{11})"""),
Regex("""[?&]v=([a-zA-Z0-9_-]{11})"""),
Regex("""(?:p\/|watch\?.*v=|embed\/|v\/|shorts\/)([a-zA-Z0-9_-]{11})""")
)
for (pattern in patterns) {
val match = pattern.find(url)
if (match != null) {
return match.groupValues[1]
}
}
if (url.matches(Regex("""^[a-zA-Z0-9_-]{11}$"""))) {
return url
}
return null
}
private fun showResults(
videoStreams: List<StreamItem.Video>,
audioStreams: List<StreamItem.Audio>
) {
binding.cardVideoInfo.visibility = View.VISIBLE
binding.textVideoTitle.text = "Video: ${binding.inputUrl.text}"
binding.textVideoAuthor.text = "Streams extracted successfully"
binding.textStreamCount.text = "${videoStreams.size} video • ${audioStreams.size} audio streams"
if (videoStreams.isNotEmpty()) {
binding.sectionVideo.visibility = View.VISIBLE
videoAdapter.submitItems(videoStreams, true)
}
if (audioStreams.isNotEmpty()) {
binding.sectionAudio.visibility = View.VISIBLE
audioAdapter.submitItems(audioStreams, false)
}
}
private fun showLoading(show: Boolean) {
binding.progressBar.visibility = if (show) View.VISIBLE else View.GONE
binding.buttonExtract.isEnabled = !show
binding.buttonExtract.text = if (show) "Extracting…" else "Extract Streams"
}
private fun showError(message: String) {
binding.cardError.visibility = View.VISIBLE
binding.textError.text = message
}
private fun hideError() {
binding.cardError.visibility = View.GONE
}
private fun pasteFromClipboard() {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = clipboard.primaryClip
if (clip != null && clip.itemCount > 0) {
val text = clip.getItemAt(0)?.text?.toString()
if (text != null) {
binding.inputUrl.setText(text)
extractStreams()
}
} else {
Toast.makeText(this, "Nothing in clipboard", Toast.LENGTH_SHORT).show()
}
}
}
package com.ytextractor
sealed class StreamItem {
data class Video(
val itag: Int,
val url: String,
val mimeType: String,
val bitrate: Int,
val width: Int?,
val height: Int?,
val fps: Int?,
val qualityLabel: String?,
val codec: String,
val isProgressive: Boolean
) : StreamItem()
data class Audio(
val itag: Int,
val url: String,
val mimeType: String,
val bitrate: Int,
val audioQuality: String,
val audioSampleRate: Int?,
val audioChannels: Int?,
val codec: String,
val loudnessDb: Double?
) : StreamItem()
}
package com.ytextractor
import android.app.DownloadManager
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.util.Log
import android.webkit.MimeTypeMap
import android.widget.Toast
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.FileOutputStream
class VideoDownloader(private val context: Context) {
private val TAG = "VideoDownloader"
private val client = OkHttpClient.Builder()
.followRedirects(true)
.followSslRedirects(true)
.cookieJar(CookieHolder.cookieJar)
.build()
fun downloadStream(streamItem: StreamItem, filename: String? = null) {
val url = when (streamItem) {
is StreamItem.Video -> streamItem.url
is StreamItem.Audio -> streamItem.url
}
val mimeType = when (streamItem) {
is StreamItem.Video -> streamItem.mimeType
is StreamItem.Audio -> streamItem.mimeType
}
val quality = when (streamItem) {
is StreamItem.Video -> streamItem.qualityLabel ?: "${streamItem.height}p"
is StreamItem.Audio -> streamItem.audioQuality ?: "${streamItem.bitrate}bps"
}
val name = filename ?: "yt_${System.currentTimeMillis()}"
Log.d(TAG, "=== Starting download ===")
Log.d(TAG, "URL: $url")
Thread {
try {
downloadWithOkHttp(url, name, mimeType, quality)
} catch (e: Exception) {
Log.e(TAG, "Download failed", e)
android.os.Handler(android.os.Looper.getMainLooper()).post {
Toast.makeText(context, "Download failed: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}.start()
}
private fun downloadWithOkHttp(url: String, filename: String, mimeType: String, quality: String) {
val userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
val request = Request.Builder()
.url(url)
.header("User-Agent", userAgent)
.header("Referer", "https://www.youtube.com/")
.header("Origin", "https://www.youtube.com")
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Connection", "keep-alive")
.build()
Log.d(TAG, "Request URL: ${request.url}")
Log.d(TAG, "Request Headers: \n${request.headers}")
val response = client.newCall(request).execute()
Log.d(TAG, "Response Code: ${response.code}")
Log.d(TAG, "Response Headers: \n${response.headers}")
if (!response.isSuccessful && response.code != 206) {
val errorMsg = "Server returned ${response.code}: ${response.message}"
Log.e(TAG, errorMsg)
throw Exception(errorMsg)
}
val ext = getExtensionFromMimeType(mimeType)
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val file = File(downloadsDir, "$filename.$ext")
var finalFile = file
var counter = 1
while (finalFile.exists()) {
finalFile = File(downloadsDir, "${filename}_${counter}.${ext}")
counter++
}
val body = response.body ?: throw Exception("Empty response body")
body.byteStream().use { input ->
FileOutputStream(finalFile).use { output ->
val buffer = ByteArray(8192)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
}
}
}
android.os.Handler(android.os.Looper.getMainLooper()).post {
Toast.makeText(context, "Downloaded: ${finalFile.name}", Toast.LENGTH_LONG).show()
}
}
private fun getExtensionFromMimeType(mimeType: String): String {
return when {
mimeType.contains("mp4") -> "mp4"
mimeType.contains("webm") -> "webm"
mimeType.contains("mp3") -> "mp3"
mimeType.contains("opus") -> "opus"
mimeType.contains("ogg") -> "ogg"
mimeType.contains("aac") -> "aac"
else -> "mp4"
}
}
}
package com.ytextractor
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class YouTubeExtractor {
private val TAG = "YouTubeExtractor"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.cookieJar(CookieHolder.cookieJar)
.build()
suspend fun extractAllStreams(videoId: String): Pair<List<StreamItem.Video>, List<StreamItem.Audio>> =
withContext(Dispatchers.IO) {
val videoStreams = mutableListOf<StreamItem.Video>()
val audioStreams = mutableListOf<StreamItem.Audio>()
val youtubeUrl = "https://www.youtube.com/watch?v=$videoId"
try {
Log.d(TAG, "=== Requesting cobalt.tools for: $youtubeUrl ===")
val jsonBody = JSONObject().apply {
put("url", youtubeUrl)
put("videoQuality", "720")
put("videoCodec", "h264")
put("isAudioOnly", false)
}
val request = Request.Builder()
.url("https://api.cobalt.tools/")
.addHeader("Accept", "application/json")
.addHeader("Content-Type", "application/json")
.addHeader("User-Agent", "YTExtractor/1.0 (Android)")
.post(jsonBody.toString().toRequestBody("application/json".toMediaType()))
.build()
var downloadUrl: String? = null
var pickerItems: List<String> = emptyList()
client.newCall(request).execute().use { response ->
val responseBody = response.body?.string()
Log.d(TAG, "Cobalt response code: ${response.code}")
if (!response.isSuccessful || responseBody == null) {
Log.e(TAG, "Cobalt request failed: ${response.code}")
return@withContext Pair(emptyList(), emptyList())
}
val json = JSONObject(responseBody)
val status = json.optString("status")
when (status) {
"redirect", "tunnel", "stream" -> {
downloadUrl = json.optString("url")
}
"picker" -> {
val picker = json.optJSONArray("picker")
if (picker != null) {
val urls = mutableListOf<String>()
for (i in 0 until picker.length()) {
urls.add(picker.getJSONObject(i).getString("url"))
}
pickerItems = urls
}
}
else -> {
Log.e(TAG, "Cobalt returned status: $status. Msg: ${json.optString("text")}")
}
}
}
val urlsToProcess = if (downloadUrl != null) listOf(downloadUrl!!) else pickerItems
for (url in urlsToProcess) {
val verified = verifyAndGetMetadata(url)
if (verified != null) {
videoStreams.add(
StreamItem.Video(
itag = 0,
url = url,
mimeType = "video/mp4",
bitrate = 0,
width = 1280,
height = 720,
fps = 30,
qualityLabel = "720p",
codec = "h264",
isProgressive = true
)
)
}
}
} catch (e: Exception) {
Log.e(TAG, "Cobalt extraction failed", e)
}
Log.d(TAG, "=== Extraction complete: ${videoStreams.size} video streams ===")
return@withContext Pair(videoStreams, audioStreams)
}
private fun verifyAndGetMetadata(url: String): Boolean? {
// Cobalt URLs sometimes need a moment or specific headers
// We try a simple HEAD request first
repeat(3) { attempt ->
try {
val request = Request.Builder()
.url(url)
.head()
.addHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36")
.build()
client.newCall(request).execute().use { response ->
Log.d(TAG, "Verify attempt ${attempt + 1} code: ${response.code} for $url")
if (response.isSuccessful) return true
if (response.code == 403) {
Log.w(TAG, "403 Forbidden on stream URL - YouTube might be blocking the cobalt server's IP or the URL is restricted")
}
}
} catch (e: Exception) {
Log.e(TAG, "Verification error", e)
}
Thread.sleep(1000) // Wait before retry
}
return null
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment