3 Commits
v1.1.0 ... v2.1

Author SHA1 Message Date
renato97
5bc90fd101 feat: migrate to futbol-libre.su with resilient domain architecture
- Add SiteConfig: centralized domain management with dynamic resolution
- Fix stream extraction: pattern-based URL detection instead of hardcoded host check
- decodeEmbeddedEventUrl now detects eventos URLs by structure (path + param), not domain
- StreamRepository and StreamOptionMetadata use SiteConfig as single source of truth
- Domain auto-discovery: probes known domains on app start, persists working one
- Update network_security_config with new domain + CDN
- Add unit test infrastructure (JUnit, MockK, Truth) with 24 tests
- Improve error handling in MainFragment with descriptive messages
2026-04-28 21:01:04 -03:00
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
10 changed files with 642 additions and 37 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 {
@@ -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")
}

View File

@@ -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)
}
}

View File

@@ -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
}
)

View File

@@ -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? {

View 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)
}
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View 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"
)
}
}

View File

@@ -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()
}
}