refactor: implement lenient M3U/TS stream parsing

This commit is contained in:
tunbmoon
2026-04-09 14:38:48 +08:00
parent fa7743c04c
commit 171d6b34d4
@@ -13,6 +13,7 @@ data class M3UChannel(
object M3UParser {
private const val M3U_HEADER_MARK = "#EXTM3U"
private const val M3U_INFO_MARK = "#EXTINF:"
private const val KODI_MARK = "#KODIPROP:"
private const val M3U_TVG_ID_MARK = "tvg-id"
@@ -22,40 +23,33 @@ object M3UParser {
private const val KODI_LICENSE_TYPE = "inputstream.adaptive.license_type"
private const val KODI_LICENSE_KEY = "inputstream.adaptive.license_key"
private val infoRegex = """(-?\d+)(.*),(.+)""".toRegex()
private val metadataRegex = """([\w-_.]+)=\s*(?:"([^"]*)"|(\S+))""".toRegex()
private val kodiPropRegex = """([^=]+)=(.+)""".toRegex()
fun parse(content: String): Pair<List<M3UChannel>, String?> {
val channels = mutableListOf<M3UChannel>()
var epgUrl: String? = null
val epgUrl = content.lineSequence()
.firstOrNull { it.startsWith(M3U_HEADER_MARK) }
?.let { extractEpgUrl(it) }
val lines = content.lineSequence()
.filter { it.isNotEmpty() }
.map { it.trimEnd() }
.dropWhile { it.startsWith(M3U_HEADER_MARK) }
.iterator()
var currentLine: String
var metadata = emptyMap<String, String>()
var title = ""
var infoMatch: MatchResult? = null
val kodiMatches = mutableListOf<MatchResult>()
while (lines.hasNext()) {
currentLine = lines.next()
if (currentLine.startsWith("#EXTM3U")) {
epgUrl = extractEpgUrl(currentLine)
continue
}
while (currentLine.startsWith("#")) {
if (currentLine.startsWith(M3U_INFO_MARK)) {
val infoContent = currentLine.drop(M3U_INFO_MARK.length).trim()
val commaIndex = infoContent.lastIndexOf(',')
if (commaIndex != -1) {
val metadataText = infoContent.take(commaIndex)
title = infoContent.substring(commaIndex + 1).trim()
metadata = parseMetadata(metadataText)
}
infoMatch = infoRegex.matchEntire(currentLine.drop(M3U_INFO_MARK.length).trim())
}
if (currentLine.startsWith(KODI_MARK)) {
kodiPropRegex
@@ -67,16 +61,19 @@ object M3UParser {
} else break
}
if (metadata.isEmpty() && !currentLine.startsWith("#")) continue
if (infoMatch == null && !currentLine.startsWith("#")) continue
if (!currentLine.startsWith("#") &&
(currentLine.startsWith("http") || currentLine.startsWith("rtmp") ||
currentLine.startsWith("rtsp") || currentLine.startsWith("udp"))) {
val tvgId = metadata[M3U_TVG_ID_MARK].orEmpty()
val tvgName = metadata[M3U_TVG_NAME_MARK].orEmpty()
val relationId = tvgId.ifEmpty { tvgName }
val finalTitle = title.ifEmpty { "Channel ${channels.size + 1}" }
if (!currentLine.startsWith("#")) {
val title = infoMatch?.groups?.get(3)?.value.orEmpty().trim()
val metadata = buildMap {
val text = infoMatch?.groups?.get(2)?.value.orEmpty().trim()
val matches = metadataRegex.findAll(text)
for (match in matches) {
val key = match.groups[1]!!.value
val value = match.groups[2]?.value?.ifBlank { null } ?: continue
put(key.trim(), value.trim())
}
}
val kodiMetadata = buildMap {
for (match in kodiMatches) {
@@ -86,6 +83,11 @@ object M3UParser {
}
}
val tvgId = metadata[M3U_TVG_ID_MARK].orEmpty()
val tvgName = metadata[M3U_TVG_NAME_MARK].orEmpty()
val relationId = tvgId.ifEmpty { tvgName }
val finalTitle = title.ifEmpty { "Channel ${channels.size + 1}" }
channels.add(
M3UChannel(
id = "${currentLine.hashCode()}_${channels.size}",
@@ -99,8 +101,7 @@ object M3UParser {
)
)
metadata = emptyMap()
title = ""
infoMatch = null
kodiMatches.clear()
}
}
@@ -108,15 +109,6 @@ object M3UParser {
return channels to epgUrl
}
private fun parseMetadata(text: String): Map<String, String> = buildMap {
val matches = metadataRegex.findAll(text)
for (match in matches) {
val key = match.groups[1]!!.value
val value = match.groups[2]?.value?.ifBlank { null } ?: continue
put(key.trim(), value.trim())
}
}
private fun extractEpgUrl(line: String): String? {
val xTvgUrlMatch = Regex("""x-tvg-url\s*=\s*"([^"]+)"""").find(line)
val urlTvgMatch = Regex("""url-tvg\s*=\s*"([^"]+)"""").find(line)