Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bc90fd101 | ||
|
|
f395fbbfcc | ||
|
|
ddb2ca8bba |
@@ -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 {
|
||||
@@ -71,4 +71,12 @@ dependencies {
|
||||
|
||||
// Coil for image loading
|
||||
implementation("io.coil-kt:coil:2.5.0")
|
||||
|
||||
// Testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("io.mockk:mockk:1.13.9")
|
||||
testImplementation("com.google.truth:truth:1.4.2")
|
||||
testImplementation("org.jsoup:jsoup:1.17.2")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package com.futbollibre.tv
|
||||
|
||||
import android.app.Application
|
||||
import com.futbollibre.tv.util.SiteConfig
|
||||
|
||||
class FutbolLibreApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Initialize any global components here
|
||||
SiteConfig.initialize(this)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import com.futbollibre.tv.model.Channel
|
||||
import com.futbollibre.tv.repository.StreamRepository
|
||||
import com.futbollibre.tv.ui.detail.ChannelDetailsActivity
|
||||
import com.futbollibre.tv.util.LeagueArt
|
||||
import com.futbollibre.tv.util.NetworkStack
|
||||
import com.futbollibre.tv.util.SiteConfig
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -115,18 +117,25 @@ class MainFragment : BrowseSupportFragment() {
|
||||
isLoadingAgenda = true
|
||||
showLoading()
|
||||
|
||||
SiteConfig.resolveActiveDomain(NetworkStack.httpClient)
|
||||
val result = repository.getChannels()
|
||||
|
||||
result.fold(
|
||||
onSuccess = { events ->
|
||||
lastLoadAt = System.currentTimeMillis()
|
||||
Log.d(TAG, "Loaded ${events.size} events")
|
||||
Log.d(TAG, "Loaded ${events.size} events from ${SiteConfig.activeBaseUrl}")
|
||||
displayChannels(events)
|
||||
isLoadingAgenda = false
|
||||
},
|
||||
onFailure = { error ->
|
||||
Log.e(TAG, "Error loading agenda", error)
|
||||
showError(error.message ?: "Error desconocido")
|
||||
Log.e(TAG, "Error loading agenda from ${SiteConfig.activeBaseUrl}", error)
|
||||
val message = when {
|
||||
error is java.net.UnknownHostException -> getString(R.string.error_server_unreachable)
|
||||
error is java.net.SocketTimeoutException -> getString(R.string.error_server_unreachable)
|
||||
error.message?.contains("HTTP") == true -> "Error del servidor: ${error.message}"
|
||||
else -> error.message ?: getString(R.string.error_loading)
|
||||
}
|
||||
showError(message)
|
||||
isLoadingAgenda = false
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.futbollibre.tv.model.StreamType
|
||||
import com.futbollibre.tv.model.StreamUrl
|
||||
import com.futbollibre.tv.util.AgendaTimeFormatter
|
||||
import com.futbollibre.tv.util.NetworkStack
|
||||
import com.futbollibre.tv.util.SiteConfig
|
||||
import com.futbollibre.tv.util.StreamOptionMetadata
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -15,14 +16,13 @@ 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 {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "StreamRepository"
|
||||
private const val BASE_URL = "https://futbollibretv.su"
|
||||
private const val AGENDA_URL = "$BASE_URL/agenda/"
|
||||
private const val MAX_RESOLUTION_DEPTH = 6
|
||||
const val USER_AGENT =
|
||||
"Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||
@@ -37,7 +37,7 @@ class StreamRepository {
|
||||
*/
|
||||
suspend fun getChannels(): Result<List<Channel>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val html = fetchHtml(AGENDA_URL, referer = BASE_URL)
|
||||
val html = fetchHtml(SiteConfig.agendaUrl, referer = SiteConfig.activeBaseUrl)
|
||||
val events = parseAgendaFromHtml(html)
|
||||
Result.success(events)
|
||||
} catch (e: Exception) {
|
||||
@@ -48,7 +48,7 @@ class StreamRepository {
|
||||
|
||||
suspend fun extractStreamUrl(streamPageUrl: String): Result<StreamUrl> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
resolveStream(streamPageUrl, referer = BASE_URL, depth = 0)
|
||||
resolveStream(streamPageUrl, referer = SiteConfig.activeBaseUrl, depth = 0)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error extracting stream", e)
|
||||
Result.failure(e)
|
||||
@@ -63,7 +63,7 @@ class StreamRepository {
|
||||
}
|
||||
|
||||
private fun parseAgendaFromHtml(html: String): List<Channel> {
|
||||
val doc = Jsoup.parse(html, AGENDA_URL)
|
||||
val doc = Jsoup.parse(html, SiteConfig.agendaUrl)
|
||||
val events = mutableListOf<Channel>()
|
||||
|
||||
doc.select("ul.menu > li").forEachIndexed { index, item ->
|
||||
@@ -87,7 +87,7 @@ class StreamRepository {
|
||||
val name = if (titleParts.size > 1) titleParts[1].trim() else fullTitle
|
||||
|
||||
val options = item.select("ul > li > a").mapNotNull { link ->
|
||||
val url = normalizeUrl(link.attr("href"), AGENDA_URL) ?: return@mapNotNull null
|
||||
val url = normalizeUrl(link.attr("href"), SiteConfig.agendaUrl) ?: return@mapNotNull null
|
||||
val quality = link.selectFirst("span")?.text()?.trim().orEmpty()
|
||||
val label = link.ownText().trim()
|
||||
val language = StreamOptionMetadata.inferLanguage(label, url)
|
||||
@@ -133,7 +133,7 @@ class StreamRepository {
|
||||
return Result.failure(Exception("Demasiadas resoluciones internas"))
|
||||
}
|
||||
|
||||
val normalizedUrl = normalizeUrl(rawUrl, referer ?: BASE_URL)
|
||||
val normalizedUrl = normalizeUrl(rawUrl, referer ?: SiteConfig.activeBaseUrl)
|
||||
?: return Result.failure(Exception("URL de stream invalida"))
|
||||
|
||||
if (isDirectHlsUrl(normalizedUrl)) {
|
||||
@@ -176,6 +176,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 +196,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 +278,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 +298,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 +367,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()
|
||||
@@ -330,7 +579,7 @@ class StreamRepository {
|
||||
.header("Cache-Control", "no-cache")
|
||||
.header("Pragma", "no-cache")
|
||||
|
||||
val normalizedReferer = normalizeUrl(referer.orEmpty(), BASE_URL)
|
||||
val normalizedReferer = normalizeUrl(referer.orEmpty(), SiteConfig.activeBaseUrl)
|
||||
if (!normalizedReferer.isNullOrBlank()) {
|
||||
builder.header("Referer", normalizedReferer)
|
||||
}
|
||||
@@ -339,16 +588,11 @@ class StreamRepository {
|
||||
}
|
||||
|
||||
private fun decodeEmbeddedEventUrl(url: String): String? {
|
||||
val httpUrl = url.toHttpUrlOrNull() ?: return null
|
||||
if (!httpUrl.host.contains("futbollibretv.su")) {
|
||||
return null
|
||||
}
|
||||
if (!httpUrl.encodedPath.contains("/eventos")) {
|
||||
return null
|
||||
}
|
||||
if (!SiteConfig.isEventosUrl(url)) return null
|
||||
|
||||
val httpUrl = url.toHttpUrlOrNull() ?: return null
|
||||
val encoded = httpUrl.queryParameter("r") ?: httpUrl.queryParameter("embed") ?: return null
|
||||
return decodeBase64Token(encoded)?.let { normalizeUrl(it, BASE_URL) }
|
||||
return decodeBase64Token(encoded)?.let { normalizeUrl(it, SiteConfig.activeBaseUrl) }
|
||||
}
|
||||
|
||||
private fun decodeBase64Token(value: String): String? {
|
||||
|
||||
97
app/src/main/java/com/futbollibre/tv/util/SiteConfig.kt
Normal file
97
app/src/main/java/com/futbollibre/tv/util/SiteConfig.kt
Normal file
@@ -0,0 +1,97 @@
|
||||
package com.futbollibre.tv.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
object SiteConfig {
|
||||
|
||||
private const val TAG = "SiteConfig"
|
||||
private const val PREFS_NAME = "site_config"
|
||||
private const val KEY_ACTIVE_DOMAIN = "active_domain"
|
||||
|
||||
val KNOWN_DOMAINS = listOf(
|
||||
"futbol-libre.su",
|
||||
"futbollibretv.su"
|
||||
)
|
||||
|
||||
private val CDN_DOMAINS = listOf(
|
||||
"cdn.futbol-libre.su"
|
||||
)
|
||||
|
||||
const val AGENDA_PATH = "/agenda/"
|
||||
|
||||
val allSecurityDomains: List<String>
|
||||
get() = KNOWN_DOMAINS + CDN_DOMAINS
|
||||
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
var activeBaseUrl: String = "https://${KNOWN_DOMAINS.first()}"
|
||||
internal set
|
||||
|
||||
val agendaUrl: String
|
||||
get() = "$activeBaseUrl$AGENDA_PATH"
|
||||
|
||||
fun initialize(context: Context) {
|
||||
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val saved = prefs?.getString(KEY_ACTIVE_DOMAIN, null)
|
||||
if (saved != null && saved in KNOWN_DOMAINS) {
|
||||
activeBaseUrl = "https://$saved"
|
||||
Log.d(TAG, "Restored saved domain: $saved")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resolveActiveDomain(client: OkHttpClient) {
|
||||
val currentDomain = activeBaseUrl.removePrefix("https://")
|
||||
|
||||
if (probeDomain(client, currentDomain)) {
|
||||
Log.d(TAG, "Active domain confirmed: $currentDomain")
|
||||
return
|
||||
}
|
||||
|
||||
Log.w(TAG, "Domain $currentDomain unreachable, probing alternatives...")
|
||||
|
||||
for (domain in KNOWN_DOMAINS) {
|
||||
if (domain == currentDomain) continue
|
||||
if (probeDomain(client, domain)) {
|
||||
activeBaseUrl = "https://$domain"
|
||||
prefs?.edit()?.putString(KEY_ACTIVE_DOMAIN, domain)?.apply()
|
||||
Log.d(TAG, "Switched to domain: $domain")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
Log.w(TAG, "All domains unreachable. Keeping: $currentDomain")
|
||||
}
|
||||
|
||||
private suspend fun probeDomain(client: OkHttpClient, domain: String): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://$domain/")
|
||||
.head()
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
val ok = response.isSuccessful
|
||||
if (!ok) {
|
||||
Log.d(TAG, "Domain $domain returned HTTP ${response.code}")
|
||||
}
|
||||
ok
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Domain $domain unreachable: ${e.javaClass.simpleName}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun isEventosUrl(url: String): Boolean {
|
||||
val httpUrl = url.toHttpUrlOrNull() ?: return false
|
||||
return httpUrl.encodedPath.contains("eventos") &&
|
||||
(httpUrl.queryParameter("r") != null || httpUrl.queryParameter("embed") != null)
|
||||
}
|
||||
}
|
||||
@@ -79,14 +79,9 @@ object StreamOptionMetadata {
|
||||
}
|
||||
|
||||
private fun decodeEmbeddedUrl(url: String): String? {
|
||||
val httpUrl = url.toHttpUrlOrNull() ?: return null
|
||||
if (!httpUrl.host.contains("futbollibretv.su")) {
|
||||
return null
|
||||
}
|
||||
if (!httpUrl.encodedPath.contains("/eventos")) {
|
||||
return null
|
||||
}
|
||||
if (!SiteConfig.isEventosUrl(url)) return null
|
||||
|
||||
val httpUrl = url.toHttpUrlOrNull() ?: return null
|
||||
val encoded = httpUrl.queryParameter("r") ?: httpUrl.queryParameter("embed") ?: return null
|
||||
val paddedValue = encoded.trim().let { raw ->
|
||||
val missingPadding = (4 - raw.length % 4) % 4
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
<string name="channel_name">Nombre del canal</string>
|
||||
<string name="stream_options">Opciones de stream</string>
|
||||
<string name="no_events">No se encontraron eventos</string>
|
||||
<string name="error_server_unreachable">No se pudo conectar al servidor. Verificá tu conexión o intentá más tarde.</string>
|
||||
<string name="error_stream_extract">No se pudo obtener el stream. Probá con otra opción.</string>
|
||||
<string name="error_stream_retry">Reintentar</string>
|
||||
<string name="rewind">Retroceder</string>
|
||||
<string name="fast_forward">Avanzar</string>
|
||||
<string name="play_pause">Reproducir/Pausar</string>
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">futbol-libre.su</domain>
|
||||
<domain includeSubdomains="true">cdn.futbol-libre.su</domain>
|
||||
<domain includeSubdomains="true">futbollibretv.su</domain>
|
||||
<domain includeSubdomains="true">latamvidz1.com</domain>
|
||||
<domain includeSubdomains="true">la14hd.com</domain>
|
||||
|
||||
91
app/src/test/java/com/futbollibre/tv/SiteConfigTest.kt
Normal file
91
app/src/test/java/com/futbollibre/tv/SiteConfigTest.kt
Normal file
@@ -0,0 +1,91 @@
|
||||
package com.futbollibre.tv
|
||||
|
||||
import com.futbollibre.tv.util.SiteConfig
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class SiteConfigTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SiteConfig.activeBaseUrl = "https://${SiteConfig.KNOWN_DOMAINS.first()}"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `known domains contains both old and new`() {
|
||||
assertThat(SiteConfig.KNOWN_DOMAINS).containsAtLeast(
|
||||
"futbol-libre.su",
|
||||
"futbollibretv.su"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `agenda url uses active base url`() {
|
||||
SiteConfig.activeBaseUrl = "https://futbol-libre.su"
|
||||
assertThat(SiteConfig.agendaUrl).isEqualTo("https://futbol-libre.su/agenda/")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEventosUrl returns true for new domain eventos URL`() {
|
||||
val url = "https://futbol-libre.su/eventos.html?r=aHR0cHM6Ly9leGFtcGxlLmNvbQ=="
|
||||
assertThat(SiteConfig.isEventosUrl(url)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEventosUrl returns true for old domain eventos URL`() {
|
||||
val url = "https://futbollibretv.su/eventos?r=aHR0cHM6Ly9leGFtcGxlLmNvbQ=="
|
||||
assertThat(SiteConfig.isEventosUrl(url)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEventosUrl returns true for embed parameter`() {
|
||||
val url = "https://futbol-libre.su/eventos.html?embed=aHR0cHM6Ly9leGFtcGxlLmNvbQ=="
|
||||
assertThat(SiteConfig.isEventosUrl(url)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEventosUrl returns false for agenda URL`() {
|
||||
val url = "https://futbol-libre.su/agenda/"
|
||||
assertThat(SiteConfig.isEventosUrl(url)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEventosUrl returns false for URL without r or embed param`() {
|
||||
val url = "https://futbol-libre.su/eventos.html"
|
||||
assertThat(SiteConfig.isEventosUrl(url)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEventosUrl returns false for unrelated URL`() {
|
||||
val url = "https://latamvidz1.com/canal.php?stream=test"
|
||||
assertThat(SiteConfig.isEventosUrl(url)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEventosUrl returns false for null URL`() {
|
||||
assertThat(SiteConfig.isEventosUrl("not-a-url")).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEventosUrl returns true for any domain with eventos path and r param`() {
|
||||
val url = "https://some-new-domain.com/eventos?r=dGVzdA=="
|
||||
assertThat(SiteConfig.isEventosUrl(url)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEventosUrl returns true for eventos in subpath`() {
|
||||
val url = "https://example.com/sub/eventos/page?r=dGVzdA=="
|
||||
assertThat(SiteConfig.isEventosUrl(url)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all security domains includes CDN`() {
|
||||
assertThat(SiteConfig.allSecurityDomains).containsAtLeast(
|
||||
"futbol-libre.su",
|
||||
"futbollibretv.su",
|
||||
"cdn.futbol-libre.su"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.futbollibre.tv
|
||||
|
||||
import com.futbollibre.tv.util.SiteConfig
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.jsoup.Jsoup
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Base64
|
||||
|
||||
class StreamRepositoryDecodeTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SiteConfig.activeBaseUrl = "https://futbol-libre.su"
|
||||
}
|
||||
|
||||
private fun encodeBase64(text: String): String =
|
||||
Base64.getEncoder().encodeToString(text.toByteArray())
|
||||
|
||||
@Test
|
||||
fun `decodeEmbeddedEventUrl decodes new domain eventos URL`() {
|
||||
val realPayload = "https://latamvidz1.com/canal.php?stream=fox_deportes_usa"
|
||||
val url = "https://futbol-libre.su/eventos.html?r=${encodeBase64(realPayload)}"
|
||||
assertThat(SiteConfig.isEventosUrl(url)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodeEmbeddedEventUrl decodes old domain eventos URL`() {
|
||||
val realPayload = "https://latamvidz1.com/canal.php?stream=espn"
|
||||
val url = "https://futbollibretv.su/eventos?r=${encodeBase64(realPayload)}"
|
||||
assertThat(SiteConfig.isEventosUrl(url)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `agenda HTML parses correctly with new domain links`() {
|
||||
val html = """
|
||||
<ul class="menu">
|
||||
<li class="CHA">
|
||||
<a href="#">Champions League: PSG vs Bayern<span class="t">20:00</span></a>
|
||||
<ul>
|
||||
<li><a href="https://futbol-libre.su/eventos.html?r=${encodeBase64("https://esvideofy.com/ote.php?id=max")}" target="_top">MAX<span>Calidad 1080p</span></a></li>
|
||||
<li><a href="https://futbol-libre.su/eventos.html?r=${encodeBase64("https://latamvidz1.com/canal.php?stream=disney1")}" target="_top">Disney+<span>Calidad 720p</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="AR">
|
||||
<a href="#">Liga Profesional: River vs Boca<span class="t">21:00</span></a>
|
||||
<ul>
|
||||
<li><a href="https://futbol-libre.su/eventos.html?r=${encodeBase64("https://latamvidz1.com/canal.php?stream=tyc")}" target="_top">TyC Sports<span>Calidad 720p</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
""".trimIndent()
|
||||
|
||||
val doc = Jsoup.parse(html, SiteConfig.agendaUrl)
|
||||
val items = doc.select("ul.menu > li")
|
||||
|
||||
assertThat(items).hasSize(2)
|
||||
|
||||
val firstHeader = items[0].children().firstOrNull { it.tagName() == "a" }
|
||||
assertThat(firstHeader).isNotNull()
|
||||
assertThat(firstHeader!!.selectFirst("span.t")?.text()?.trim()).isEqualTo("20:00")
|
||||
|
||||
val fullTitle = firstHeader.ownText().trim()
|
||||
assertThat(fullTitle).contains("PSG vs Bayern")
|
||||
|
||||
val allLinks = items[0].select("ul > li > a")
|
||||
assertThat(allLinks.size).isAtLeast(2)
|
||||
|
||||
val streamLinks = allLinks.toList().filter { it.attr("href") != "#" }
|
||||
assertThat(streamLinks).hasSize(2)
|
||||
|
||||
val firstOptionUrl = streamLinks[0].attr("href")
|
||||
assertThat(firstOptionUrl).startsWith("https://futbol-libre.su/eventos.html?r=")
|
||||
assertThat(SiteConfig.isEventosUrl(firstOptionUrl)).isTrue()
|
||||
|
||||
val secondHeader = items[1].children().firstOrNull { it.tagName() == "a" }
|
||||
assertThat(secondHeader).isNotNull()
|
||||
assertThat(secondHeader!!.ownText().trim()).contains("River vs Boca")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `base64 encoded stream URLs decode correctly`() {
|
||||
val encodedUrl = "aHR0cHM6Ly9sYXRhbXZpZHoxLmNvbS9jYW5hbC5waHA/c3RyZWFtPWZveF9kZXBvcnRlc191c2E="
|
||||
val decoded = String(Base64.getDecoder().decode(encodedUrl), Charsets.UTF_8)
|
||||
assertThat(decoded).isEqualTo("https://latamvidz1.com/canal.php?stream=fox_deportes_usa")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `base64 encoded URL from actual agenda decodes correctly`() {
|
||||
val encoded = "aHR0cHM6Ly9lc3ZpZGVvZnkuY29tL3Byby5waHA/aWQ9Rk9YREVQT1JURVM="
|
||||
val decoded = String(Base64.getDecoder().decode(encoded), Charsets.UTF_8)
|
||||
assertThat(decoded).isEqualTo("https://esvideofy.com/pro.php?id=FOXDEPORTES")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `agenda with empty options parses without crash`() {
|
||||
val html = """
|
||||
<ul class="menu">
|
||||
<li class="LIB">
|
||||
<a href="#">Copa Libertadores: Test vs Test<span class="t">23:00</span></a>
|
||||
<ul></ul>
|
||||
</li>
|
||||
</ul>
|
||||
""".trimIndent()
|
||||
|
||||
val doc = Jsoup.parse(html, SiteConfig.agendaUrl)
|
||||
val items = doc.select("ul.menu > li")
|
||||
assertThat(items).hasSize(1)
|
||||
|
||||
val allLinks = items[0].select("ul > li > a")
|
||||
val streamLinks = allLinks.toList().filter { it.attr("href") != "#" }
|
||||
assertThat(streamLinks).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `iframe extraction from stream page`() {
|
||||
val html = """
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<iframe src="https://latamvidz1.com/canal.php?stream=test" width="100%" height="100%"></iframe>
|
||||
</body></html>
|
||||
""".trimIndent()
|
||||
|
||||
val doc = Jsoup.parse(html, "https://futbol-libre.su/eventos.html")
|
||||
val iframe = doc.selectFirst("iframe[src]")
|
||||
assertThat(iframe).isNotNull()
|
||||
assertThat(iframe!!.attr("src")).isEqualTo("https://latamvidz1.com/canal.php?stream=test")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `iframe with empty src is detected as empty`() {
|
||||
val html = """
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<iframe src="" id="embedIframe"></iframe>
|
||||
</body></html>
|
||||
""".trimIndent()
|
||||
|
||||
val doc = Jsoup.parse(html, "https://futbol-libre.su/eventos.html")
|
||||
val iframe = doc.selectFirst("iframe[src]")
|
||||
assertThat(iframe!!.attr("src")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `m3u8 direct URL detection`() {
|
||||
val url = "https://example.com/stream.m3u8?token=abc123"
|
||||
assertThat(url.contains(".m3u8", ignoreCase = true)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mpd direct URL detection`() {
|
||||
val url = "https://example.com/stream.mpd?token=abc123"
|
||||
assertThat(url.contains(".mpd", ignoreCase = true)).isTrue()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user