Created
April 12, 2026 09:12
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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