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
|
||||
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
|
||||
@@ -22,8 +23,6 @@ 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"
|
||||
@@ -38,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) {
|
||||
@@ -49,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)
|
||||
@@ -64,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 ->
|
||||
@@ -88,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)
|
||||
@@ -134,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)) {
|
||||
@@ -580,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)
|
||||
}
|
||||
@@ -589,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