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
This commit is contained in:
@@ -71,4 +71,12 @@ dependencies {
|
|||||||
|
|
||||||
// Coil for image loading
|
// Coil for image loading
|
||||||
implementation("io.coil-kt:coil:2.5.0")
|
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
|
package com.futbollibre.tv
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import com.futbollibre.tv.util.SiteConfig
|
||||||
|
|
||||||
class FutbolLibreApp : Application() {
|
class FutbolLibreApp : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.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.repository.StreamRepository
|
||||||
import com.futbollibre.tv.ui.detail.ChannelDetailsActivity
|
import com.futbollibre.tv.ui.detail.ChannelDetailsActivity
|
||||||
import com.futbollibre.tv.util.LeagueArt
|
import com.futbollibre.tv.util.LeagueArt
|
||||||
|
import com.futbollibre.tv.util.NetworkStack
|
||||||
|
import com.futbollibre.tv.util.SiteConfig
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,18 +117,25 @@ class MainFragment : BrowseSupportFragment() {
|
|||||||
isLoadingAgenda = true
|
isLoadingAgenda = true
|
||||||
showLoading()
|
showLoading()
|
||||||
|
|
||||||
|
SiteConfig.resolveActiveDomain(NetworkStack.httpClient)
|
||||||
val result = repository.getChannels()
|
val result = repository.getChannels()
|
||||||
|
|
||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { events ->
|
onSuccess = { events ->
|
||||||
lastLoadAt = System.currentTimeMillis()
|
lastLoadAt = System.currentTimeMillis()
|
||||||
Log.d(TAG, "Loaded ${events.size} events")
|
Log.d(TAG, "Loaded ${events.size} events from ${SiteConfig.activeBaseUrl}")
|
||||||
displayChannels(events)
|
displayChannels(events)
|
||||||
isLoadingAgenda = false
|
isLoadingAgenda = false
|
||||||
},
|
},
|
||||||
onFailure = { error ->
|
onFailure = { error ->
|
||||||
Log.e(TAG, "Error loading agenda", error)
|
Log.e(TAG, "Error loading agenda from ${SiteConfig.activeBaseUrl}", error)
|
||||||
showError(error.message ?: "Error desconocido")
|
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
|
isLoadingAgenda = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.futbollibre.tv.model.StreamType
|
|||||||
import com.futbollibre.tv.model.StreamUrl
|
import com.futbollibre.tv.model.StreamUrl
|
||||||
import com.futbollibre.tv.util.AgendaTimeFormatter
|
import com.futbollibre.tv.util.AgendaTimeFormatter
|
||||||
import com.futbollibre.tv.util.NetworkStack
|
import com.futbollibre.tv.util.NetworkStack
|
||||||
|
import com.futbollibre.tv.util.SiteConfig
|
||||||
import com.futbollibre.tv.util.StreamOptionMetadata
|
import com.futbollibre.tv.util.StreamOptionMetadata
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -22,8 +23,6 @@ class StreamRepository {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "StreamRepository"
|
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
|
private const val MAX_RESOLUTION_DEPTH = 6
|
||||||
const val USER_AGENT =
|
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"
|
"Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||||
@@ -38,7 +37,7 @@ class StreamRepository {
|
|||||||
*/
|
*/
|
||||||
suspend fun getChannels(): Result<List<Channel>> = withContext(Dispatchers.IO) {
|
suspend fun getChannels(): Result<List<Channel>> = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val html = fetchHtml(AGENDA_URL, referer = BASE_URL)
|
val html = fetchHtml(SiteConfig.agendaUrl, referer = SiteConfig.activeBaseUrl)
|
||||||
val events = parseAgendaFromHtml(html)
|
val events = parseAgendaFromHtml(html)
|
||||||
Result.success(events)
|
Result.success(events)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -49,7 +48,7 @@ class StreamRepository {
|
|||||||
|
|
||||||
suspend fun extractStreamUrl(streamPageUrl: String): Result<StreamUrl> = withContext(Dispatchers.IO) {
|
suspend fun extractStreamUrl(streamPageUrl: String): Result<StreamUrl> = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
resolveStream(streamPageUrl, referer = BASE_URL, depth = 0)
|
resolveStream(streamPageUrl, referer = SiteConfig.activeBaseUrl, depth = 0)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error extracting stream", e)
|
Log.e(TAG, "Error extracting stream", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
@@ -64,7 +63,7 @@ class StreamRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun parseAgendaFromHtml(html: String): List<Channel> {
|
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>()
|
val events = mutableListOf<Channel>()
|
||||||
|
|
||||||
doc.select("ul.menu > li").forEachIndexed { index, item ->
|
doc.select("ul.menu > li").forEachIndexed { index, item ->
|
||||||
@@ -88,7 +87,7 @@ class StreamRepository {
|
|||||||
val name = if (titleParts.size > 1) titleParts[1].trim() else fullTitle
|
val name = if (titleParts.size > 1) titleParts[1].trim() else fullTitle
|
||||||
|
|
||||||
val options = item.select("ul > li > a").mapNotNull { link ->
|
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 quality = link.selectFirst("span")?.text()?.trim().orEmpty()
|
||||||
val label = link.ownText().trim()
|
val label = link.ownText().trim()
|
||||||
val language = StreamOptionMetadata.inferLanguage(label, url)
|
val language = StreamOptionMetadata.inferLanguage(label, url)
|
||||||
@@ -134,7 +133,7 @@ class StreamRepository {
|
|||||||
return Result.failure(Exception("Demasiadas resoluciones internas"))
|
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"))
|
?: return Result.failure(Exception("URL de stream invalida"))
|
||||||
|
|
||||||
if (isDirectHlsUrl(normalizedUrl)) {
|
if (isDirectHlsUrl(normalizedUrl)) {
|
||||||
@@ -580,7 +579,7 @@ class StreamRepository {
|
|||||||
.header("Cache-Control", "no-cache")
|
.header("Cache-Control", "no-cache")
|
||||||
.header("Pragma", "no-cache")
|
.header("Pragma", "no-cache")
|
||||||
|
|
||||||
val normalizedReferer = normalizeUrl(referer.orEmpty(), BASE_URL)
|
val normalizedReferer = normalizeUrl(referer.orEmpty(), SiteConfig.activeBaseUrl)
|
||||||
if (!normalizedReferer.isNullOrBlank()) {
|
if (!normalizedReferer.isNullOrBlank()) {
|
||||||
builder.header("Referer", normalizedReferer)
|
builder.header("Referer", normalizedReferer)
|
||||||
}
|
}
|
||||||
@@ -589,16 +588,11 @@ class StreamRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun decodeEmbeddedEventUrl(url: String): String? {
|
private fun decodeEmbeddedEventUrl(url: String): String? {
|
||||||
val httpUrl = url.toHttpUrlOrNull() ?: return null
|
if (!SiteConfig.isEventosUrl(url)) return null
|
||||||
if (!httpUrl.host.contains("futbollibretv.su")) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (!httpUrl.encodedPath.contains("/eventos")) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
|
val httpUrl = url.toHttpUrlOrNull() ?: return null
|
||||||
val encoded = httpUrl.queryParameter("r") ?: httpUrl.queryParameter("embed") ?: 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? {
|
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? {
|
private fun decodeEmbeddedUrl(url: String): String? {
|
||||||
val httpUrl = url.toHttpUrlOrNull() ?: return null
|
if (!SiteConfig.isEventosUrl(url)) return null
|
||||||
if (!httpUrl.host.contains("futbollibretv.su")) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (!httpUrl.encodedPath.contains("/eventos")) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
|
val httpUrl = url.toHttpUrlOrNull() ?: return null
|
||||||
val encoded = httpUrl.queryParameter("r") ?: httpUrl.queryParameter("embed") ?: return null
|
val encoded = httpUrl.queryParameter("r") ?: httpUrl.queryParameter("embed") ?: return null
|
||||||
val paddedValue = encoded.trim().let { raw ->
|
val paddedValue = encoded.trim().let { raw ->
|
||||||
val missingPadding = (4 - raw.length % 4) % 4
|
val missingPadding = (4 - raw.length % 4) % 4
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
<string name="channel_name">Nombre del canal</string>
|
<string name="channel_name">Nombre del canal</string>
|
||||||
<string name="stream_options">Opciones de stream</string>
|
<string name="stream_options">Opciones de stream</string>
|
||||||
<string name="no_events">No se encontraron eventos</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="rewind">Retroceder</string>
|
||||||
<string name="fast_forward">Avanzar</string>
|
<string name="fast_forward">Avanzar</string>
|
||||||
<string name="play_pause">Reproducir/Pausar</string>
|
<string name="play_pause">Reproducir/Pausar</string>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
</trust-anchors>
|
</trust-anchors>
|
||||||
</base-config>
|
</base-config>
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
<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">futbollibretv.su</domain>
|
||||||
<domain includeSubdomains="true">latamvidz1.com</domain>
|
<domain includeSubdomains="true">latamvidz1.com</domain>
|
||||||
<domain includeSubdomains="true">la14hd.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