2 Commits
v1.1.0 ... main

Author SHA1 Message Date
renato97
f395fbbfcc chore: bump release version to 2.0 2026-03-11 16:17:09 -03:00
renato97
ddb2ca8bba fix: support updated drm event formats 2026-03-11 16:15:06 -03:00
2 changed files with 260 additions and 10 deletions

View File

@@ -13,8 +13,8 @@ android {
applicationId = "com.futbollibre.tv"
minSdk = 21
targetSdk = 34
versionCode = 2
versionName = "1.1.0"
versionCode = 3
versionName = "2.0"
}
buildTypes {

View File

@@ -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<String, String> {
val clearKeys = linkedMapOf<String, String>()
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<Int>(expectedLength.coerceAtLeast(0))
val accMask = ArrayList<Int>(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<List<Int>>? {
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<Int>? {
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()