diff --git a/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt b/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt index a5506b6..379890c 100644 --- a/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt +++ b/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt @@ -15,6 +15,7 @@ import okhttp3.Request import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.jsoup.Jsoup import org.jsoup.nodes.Element +import org.json.JSONArray import java.nio.charset.StandardCharsets class StreamRepository { @@ -176,6 +177,10 @@ class StreamRepository { val finalUrl = it.request.url.toString() + extractConfiguredStream(finalUrl, html)?.let { stream -> + return Result.success(stream) + } + extractDashStream(finalUrl, html)?.let { stream -> return Result.success(stream) } @@ -192,20 +197,44 @@ class StreamRepository { } } - private fun extractDashStream(pageUrl: String, html: String): StreamUrl? { + private fun extractConfiguredStream(pageUrl: String, html: String): StreamUrl? { val pageId = pageUrl.toHttpUrlOrNull()?.queryParameter("id") ?: return null - val idPattern = Regex( - """(?s)\b${Regex.escape(pageId)}\s*:\s*\{\s*url:\s*["']([^"']+\.mpd[^"']*)["']\s*,\s*clearkey:\s*\{(.*?)\}\s*,?\s*\}""" - ) - val match = idPattern.find(html) ?: return null - val mediaUrl = normalizeMediaUrl(match.groupValues[1]) ?: return null - val clearKeys = extractClearKeys(match.groupValues[2]) + val configBlock = extractJsObjectForKey(html, pageId) ?: return null + val mediaUrl = extractConfiguredMediaUrl(configBlock) ?: return null + val streamType = when { + isDirectDashUrl(mediaUrl) -> StreamType.DASH + isDirectHlsUrl(mediaUrl) -> StreamType.HLS + else -> return null + } return StreamUrl( url = mediaUrl, referer = pageUrl, + streamType = streamType, + clearKeys = extractClearKeys(configBlock) + ) + } + + private fun extractDashStream(pageUrl: String, html: String): StreamUrl? { + extractObfuscatedDashStream(pageUrl, html)?.let { return it } + + val mediaUrl = extractAssignedString(html, "MPD") + ?: extractNamedUrlProperty(html, "file", ".mpd") + ?: Regex( + """https?://[^\s"'\\]+\.mpd(?:\?[^\s"'\\]*)?""", + setOf(RegexOption.IGNORE_CASE) + ).find(html)?.value + ?: Regex( + """["'](//[^"']+\.mpd(?:\?[^"']*)?)["']""", + setOf(RegexOption.IGNORE_CASE) + ).find(html)?.groupValues?.getOrNull(1) + val normalizedMediaUrl = normalizeMediaUrl(mediaUrl) ?: return null + + return StreamUrl( + url = normalizedMediaUrl, + referer = pageUrl, streamType = StreamType.DASH, - clearKeys = clearKeys + clearKeys = extractClearKeys(html) ) } @@ -250,10 +279,18 @@ class StreamRepository { .find(rawContent) ?.groupValues ?.getOrNull(1) + ?: Regex("""\bk1\s*:\s*['"]([0-9a-fA-F]{16,})['"]""") + .find(rawContent) + ?.groupValues + ?.getOrNull(1) val keyMatch = Regex("""['"]key['"]\s*:\s*['"]([0-9a-fA-F]{16,})['"]""") .find(rawContent) ?.groupValues ?.getOrNull(1) + ?: Regex("""\bk2\s*:\s*['"]([0-9a-fA-F]{16,})['"]""") + .find(rawContent) + ?.groupValues + ?.getOrNull(1) return if (!keyIdMatch.isNullOrBlank() && !keyMatch.isNullOrBlank()) { mapOf(keyIdMatch to keyMatch) @@ -262,6 +299,27 @@ class StreamRepository { } } + private fun extractObfuscatedDashStream(pageUrl: String, html: String): StreamUrl? { + val mediaUrl = extractAssignedString(html, "MPD") ?: return null + val normalizedMediaUrl = normalizeMediaUrl(mediaUrl) ?: return null + if (!isDirectDashUrl(normalizedMediaUrl)) { + return null + } + + val obfuscatedList = extractAssignedJsonArray(html, "OBF_LIST") ?: return null + val clearKeys = decodeObfuscatedClearKeys(obfuscatedList) + if (clearKeys.isEmpty()) { + return null + } + + return StreamUrl( + url = normalizedMediaUrl, + referer = pageUrl, + streamType = StreamType.DASH, + clearKeys = clearKeys + ) + } + private fun extractIframeUrl(pageUrl: String, html: String): String? { val doc = Jsoup.parse(html, pageUrl) val iframe = doc.selectFirst("iframe[src]") ?: return null @@ -310,6 +368,198 @@ class StreamRepository { return regex.find(html)?.groupValues?.getOrNull(1)?.toIntOrNull() } + private fun extractConfiguredMediaUrl(configBlock: String): String? { + val directUrl = extractNamedUrlProperty(configBlock, "url", ".mpd") + ?: extractNamedUrlProperty(configBlock, "url", ".m3u8") + ?: extractNamedUrlProperty(configBlock, "file", ".mpd") + ?: extractNamedUrlProperty(configBlock, "file", ".m3u8") + + return normalizeMediaUrl(directUrl) + } + + private fun extractNamedUrlProperty(content: String, propertyName: String, extension: String): String? { + val propertyRegex = Regex( + """(?is)(?:(["'])${Regex.escape(propertyName)}\1|${Regex.escape(propertyName)})\s*:\s*["']([^"']*${Regex.escape(extension)}[^"']*)["']""" + ) + return propertyRegex.find(content)?.groupValues?.getOrNull(2) + } + + private fun extractAssignedString(content: String, variableName: String): String? { + val pattern = Regex( + """(?is)(?:const|let|var)?\s*${Regex.escape(variableName)}\s*=\s*["']([^"']+)["']""" + ) + return pattern.find(content)?.groupValues?.getOrNull(1) + } + + private fun extractAssignedJsonArray(content: String, variableName: String): JSONArray? { + val pattern = Regex( + """(?is)(?:const|let|var)?\s*${Regex.escape(variableName)}\s*=\s*(\[[\s\S]*?])\s*;""" + ) + val rawArray = pattern.find(content)?.groupValues?.getOrNull(1) ?: return null + return try { + JSONArray(rawArray) + } catch (_: Exception) { + null + } + } + + private fun extractJsObjectForKey(content: String, keyName: String): String? { + val candidateKeys = linkedSetOf(keyName, keyName.uppercase(), keyName.lowercase()) + for (candidate in candidateKeys) { + val entryPattern = Regex( + """(?is)(?:(["'])${Regex.escape(candidate)}\1|${Regex.escape(candidate)})\s*:\s*\{""" + ) + val match = entryPattern.find(content) ?: continue + val objectStart = match.range.last + extractBalancedBlock(content, objectStart, '{', '}')?.let { return it } + } + + return null + } + + private fun extractBalancedBlock(content: String, startIndex: Int, openChar: Char, closeChar: Char): String? { + if (startIndex !in content.indices || content[startIndex] != openChar) { + return null + } + + var depth = 0 + var inSingleQuote = false + var inDoubleQuote = false + var escaped = false + + for (index in startIndex until content.length) { + val current = content[index] + + if (escaped) { + escaped = false + continue + } + + when { + current == '\\' && (inSingleQuote || inDoubleQuote) -> { + escaped = true + } + current == '\'' && !inDoubleQuote -> { + inSingleQuote = !inSingleQuote + } + current == '"' && !inSingleQuote -> { + inDoubleQuote = !inDoubleQuote + } + !inSingleQuote && !inDoubleQuote && current == openChar -> { + depth++ + } + !inSingleQuote && !inDoubleQuote && current == closeChar -> { + depth-- + if (depth == 0) { + return content.substring(startIndex, index + 1) + } + } + } + } + + return null + } + + private fun decodeObfuscatedClearKeys(obfuscatedList: JSONArray): Map { + val clearKeys = linkedMapOf() + + for (index in 0 until obfuscatedList.length()) { + val item = obfuscatedList.optJSONObject(index) ?: continue + val bytes = decodeObfuscatedBytes(item) ?: continue + if (bytes.size < 32) { + continue + } + + val kidHex = bytes.copyOfRange(0, 16).toHexString() + val keyHex = bytes.copyOfRange(16, 32).toHexString() + clearKeys[kidHex] = keyHex + } + + return clearKeys + } + + private fun decodeObfuscatedBytes(item: org.json.JSONObject): ByteArray? { + val chunksA = readNestedIntLists(item, "chunksA") ?: return null + val chunksB = readNestedIntLists(item, "chunksB") ?: return null + val posA = readNestedIntLists(item, "posA") ?: return null + val posB = readNestedIntLists(item, "posB") ?: return null + val invPerm = readIntList(item.optJSONArray("invPerm")) ?: return null + val expectedLength = item.optInt("len", -1) + + val acc = ArrayList(expectedLength.coerceAtLeast(0)) + val accMask = ArrayList(expectedLength.coerceAtLeast(0)) + + val sectionCount = minOf(chunksA.size, chunksB.size, posA.size, posB.size) + for (sectionIndex in 0 until sectionCount) { + val chunkA = chunksA[sectionIndex] + val chunkB = chunksB[sectionIndex] + val positionsA = posA[sectionIndex] + val positionsB = posB[sectionIndex] + val length = minOf(positionsA.size, positionsB.size) + + for (positionIndex in 0 until length) { + val aIndex = positionsA[positionIndex] + val bIndex = positionsB[positionIndex] + if (aIndex !in chunkA.indices || bIndex !in chunkB.indices) { + return null + } + acc += chunkA[aIndex] + accMask += chunkB[bIndex] + } + } + + if (expectedLength <= 0 || acc.size != expectedLength || accMask.size != expectedLength || invPerm.size != expectedLength) { + return null + } + + val permuted = ByteArray(expectedLength) + for (i in 0 until expectedLength) { + permuted[i] = ((acc[i] xor accMask[i]) and 0xFF).toByte() + } + + val output = ByteArray(expectedLength) + for (i in 0 until expectedLength) { + val sourceIndex = invPerm[i] + if (sourceIndex !in permuted.indices) { + return null + } + output[i] = permuted[sourceIndex] + } + + return output + } + + private fun readNestedIntLists(item: org.json.JSONObject, key: String): List>? { + val array = item.optJSONArray(key) ?: return null + return buildList(array.length()) { + for (index in 0 until array.length()) { + val row = readIntList(array.optJSONArray(index)) ?: return null + add(row) + } + } + } + + private fun readIntList(array: JSONArray?): List? { + if (array == null) { + return null + } + + return buildList(array.length()) { + for (index in 0 until array.length()) { + if (array.isNull(index)) { + return null + } + add(array.optInt(index)) + } + } + } + + private fun ByteArray.toHexString(): String { + return joinToString(separator = "") { byte -> + "%02x".format(byte.toInt() and 0xFF) + } + } + private fun fetchHtml(url: String, referer: String?): String { val request = buildRequest(url, referer) val response = client.newCall(request).execute()