1 Commits
v2.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
10 changed files with 382 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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