update
This commit is contained in:
parent
a48acc6862
commit
ca45426639
@ -1,109 +0,0 @@
|
|||||||
package me.lsong.mytv.epg
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import android.util.Xml
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import org.xmlpull.v1.XmlPullParser
|
|
||||||
import me.lsong.mytv.epg.fetcher.EpgFetcher
|
|
||||||
import java.io.BufferedReader
|
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.io.InputStreamReader
|
|
||||||
import java.io.StringReader
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.zip.GZIPInputStream
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 节目单获取
|
|
||||||
*/
|
|
||||||
class EpgRepository {
|
|
||||||
suspend fun getEpgList(url: String): EpgList = withContext(Dispatchers.Default) {
|
|
||||||
try {
|
|
||||||
return@withContext parseEpgXml(request(url))
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Log.e("epg", "获取节目单失败", ex)
|
|
||||||
throw Exception(ex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun request(url: String): String{
|
|
||||||
val client = OkHttpClient()
|
|
||||||
val request = Request.Builder().url(url).build()
|
|
||||||
client.newCall(request).execute().use { response ->
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
throw Exception("request failed: $response.code")
|
|
||||||
}
|
|
||||||
val contentType = response.header("content-type")
|
|
||||||
if (contentType?.startsWith("text/")!!) {
|
|
||||||
return response.body!!.string()
|
|
||||||
}
|
|
||||||
val gzData = response.body!!.bytes()
|
|
||||||
val stringBuilder = StringBuilder()
|
|
||||||
val gzipInputStream = GZIPInputStream(ByteArrayInputStream(gzData));
|
|
||||||
val reader = BufferedReader(InputStreamReader(gzipInputStream));
|
|
||||||
var line: String?
|
|
||||||
while (reader.readLine().also { line = it } != null) {
|
|
||||||
stringBuilder.append(line).append("\n")
|
|
||||||
}
|
|
||||||
response.close()
|
|
||||||
return stringBuilder.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析节目单xml
|
|
||||||
*/
|
|
||||||
private fun parseEpgXml(xmlString: String): EpgList {
|
|
||||||
val parser: XmlPullParser = Xml.newPullParser()
|
|
||||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
|
||||||
parser.setInput(StringReader(xmlString))
|
|
||||||
val epgMap = mutableMapOf<String, EpgChannel>()
|
|
||||||
var eventType = parser.eventType
|
|
||||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
|
||||||
when (eventType) {
|
|
||||||
XmlPullParser.START_TAG -> {
|
|
||||||
if (parser.name == "channel") {
|
|
||||||
val channelId = parser.getAttributeValue(null, "id")
|
|
||||||
parser.nextTag()
|
|
||||||
val channelDisplayName = parser.nextText()
|
|
||||||
val channel = EpgChannel(
|
|
||||||
id = channelId,
|
|
||||||
title = channelDisplayName,
|
|
||||||
)
|
|
||||||
// Log.d("epg", "${channel.id}, ${channel.title}")
|
|
||||||
epgMap[channelId] = channel
|
|
||||||
} else if (parser.name == "programme") {
|
|
||||||
val channelId = parser.getAttributeValue(null, "channel")
|
|
||||||
val startTime = parser.getAttributeValue(null, "start")
|
|
||||||
val stopTime = parser.getAttributeValue(null, "stop")
|
|
||||||
parser.nextTag()
|
|
||||||
val title = parser.nextText()
|
|
||||||
fun parseTime(time: String): Long {
|
|
||||||
if (time.length < 14) return 0
|
|
||||||
return SimpleDateFormat("yyyyMMddHHmmss Z", Locale.getDefault()).parse(time)?.time ?: 0
|
|
||||||
}
|
|
||||||
val programme = EpgProgramme(
|
|
||||||
channelId = channelId,
|
|
||||||
startAt = parseTime(startTime),
|
|
||||||
endAt = parseTime(stopTime),
|
|
||||||
title = title,
|
|
||||||
)
|
|
||||||
if (epgMap.containsKey(channelId)) {
|
|
||||||
// Log.d("epg", "${programme.channelId}, ${programme.title}")
|
|
||||||
epgMap[channelId] = epgMap[channelId]!!.copy(
|
|
||||||
programmes = epgMap[channelId]!!.programmes + listOf(programme)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eventType = parser.next()
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i("epg","解析节目单完成,共${epgMap.size}个频道")
|
|
||||||
return EpgList(epgMap.values.toList())
|
|
||||||
}
|
|
@ -4,20 +4,26 @@ import android.util.Log
|
|||||||
import android.util.Xml
|
import android.util.Xml
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import me.lsong.mytv.epg.EpgChannel
|
import me.lsong.mytv.epg.EpgChannel
|
||||||
import me.lsong.mytv.epg.EpgList
|
import me.lsong.mytv.epg.EpgList
|
||||||
import me.lsong.mytv.epg.EpgProgramme
|
import me.lsong.mytv.epg.EpgProgramme
|
||||||
import me.lsong.mytv.epg.EpgRepository
|
|
||||||
import me.lsong.mytv.utils.Constants
|
import me.lsong.mytv.utils.Constants
|
||||||
import me.lsong.mytv.utils.Settings
|
import me.lsong.mytv.utils.Settings
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.xmlpull.v1.XmlPullParser
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.InputStreamReader
|
||||||
import java.io.StringReader
|
import java.io.StringReader
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
|
|
||||||
|
|
||||||
// 数据类定义
|
// 数据类定义
|
||||||
@ -98,104 +104,6 @@ data class TVChannelList(val value: List<TVChannel> = emptyList()) : List<TVChan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class IPTVProvider(private val epgRepository: EpgRepository) : TVProvider {
|
|
||||||
private var groupList: TVGroupList = TVGroupList()
|
|
||||||
private var epgList: EpgList = EpgList()
|
|
||||||
|
|
||||||
override suspend fun load() {
|
|
||||||
val (sources, epgUrls) = fetchIPTVSources()
|
|
||||||
groupList = process(sources)
|
|
||||||
epgList = fetchEPGData(epgUrls)
|
|
||||||
}
|
|
||||||
override fun groups(): TVGroupList {
|
|
||||||
return groupList
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun channels(groupTitle: String): TVChannelList {
|
|
||||||
return groupList.find { it.title == groupTitle }?.channels ?: TVChannelList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun fetchIPTVSources(): Pair<List<TVSource>, List<String>> {
|
|
||||||
val allSources = mutableListOf<TVSource>()
|
|
||||||
val epgUrls = mutableListOf<String>()
|
|
||||||
val iptvUrls = Settings.iptvSourceUrls.ifEmpty { listOf(Constants.IPTV_SOURCE_URL) }
|
|
||||||
iptvUrls.forEach { url ->
|
|
||||||
val m3u = retry { getM3uChannels(sourceUrl = url) }
|
|
||||||
allSources.addAll(m3u.channels.map {
|
|
||||||
TVSource(
|
|
||||||
tvgId = it.attributes["tvg-id"],
|
|
||||||
tvgLogo = it.attributes["tvg-logo"],
|
|
||||||
tvgName = it.attributes["tvg-name"],
|
|
||||||
groupTitle = it.attributes["group-title"],
|
|
||||||
title = it.title,
|
|
||||||
url = it.url,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
epgUrls += m3u.headers["x-tvg-url"]?.split(",").orEmpty()
|
|
||||||
}
|
|
||||||
if (epgUrls.isEmpty()) epgUrls.add(Constants.EPG_XML_URL)
|
|
||||||
return Pair(allSources, epgUrls.distinct())
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun fetchEPGData(epgUrls: List<String>): EpgList {
|
|
||||||
val epgChannels = mutableListOf<EpgChannel>()
|
|
||||||
epgUrls.forEach { url ->
|
|
||||||
val epg = retry { epgRepository.getEpgList(url) }
|
|
||||||
epgChannels.addAll(epg.value)
|
|
||||||
}
|
|
||||||
return EpgList(epgChannels.distinctBy { it.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun process(sources: List<TVSource>): TVGroupList {
|
|
||||||
val channels = sources.groupBy { it.name }
|
|
||||||
.map { (name, sources) ->
|
|
||||||
TVChannel(
|
|
||||||
name = name,
|
|
||||||
title = sources.first().title,
|
|
||||||
sources = sources,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return TVGroupList(
|
|
||||||
channels.groupBy { it.groupTitle ?: "其他" }
|
|
||||||
.map { (title, channels) -> TVGroup(title = title, channels = TVChannelList(channels)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun <T> retry(fn: suspend () -> T): T {
|
|
||||||
repeat(Constants.HTTP_RETRY_COUNT) {
|
|
||||||
try {
|
|
||||||
return fn()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (it == Constants.HTTP_RETRY_COUNT) throw e
|
|
||||||
delay(Constants.HTTP_RETRY_INTERVAL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw IllegalStateException("Failed to fetch data after ${Constants.HTTP_RETRY_COUNT} attempts")
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun request(url: String) = withContext(Dispatchers.IO) {
|
|
||||||
Log.d("request", "request start: $url")
|
|
||||||
val client = OkHttpClient()
|
|
||||||
val request = Request.Builder().url(url).build()
|
|
||||||
try {
|
|
||||||
client.newCall(request).execute().use { response ->
|
|
||||||
if (!response.isSuccessful) throw Exception("failed: ${response.code}")
|
|
||||||
response.body?.string()?.trim() ?: throw Exception("Empty response body")
|
|
||||||
}
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
val e = Exception("request failed $url", ex)
|
|
||||||
Log.d("request", "${e.message}")
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getM3uChannels(sourceUrl: String): M3uData {
|
|
||||||
return parseM3u(request(sourceUrl))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Interface definition
|
// Interface definition
|
||||||
interface TVProvider {
|
interface TVProvider {
|
||||||
suspend fun load()
|
suspend fun load()
|
||||||
@ -205,7 +113,7 @@ interface TVProvider {
|
|||||||
|
|
||||||
class MyTvProviderManager : TVProvider {
|
class MyTvProviderManager : TVProvider {
|
||||||
private val providers: List<TVProvider> = listOf(
|
private val providers: List<TVProvider> = listOf(
|
||||||
IPTVProvider(EpgRepository())
|
IPTVProvider()
|
||||||
)
|
)
|
||||||
override suspend fun load() {
|
override suspend fun load() {
|
||||||
providers.forEach { it.load() }
|
providers.forEach { it.load() }
|
||||||
@ -283,11 +191,14 @@ fun parseAttributes(input: String): Map<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun parseEpgXML(xmlString: String): List<EpgChannel> {
|
/**
|
||||||
val epgMap = mutableMapOf<String, EpgChannel>()
|
* 解析节目单xml
|
||||||
|
*/
|
||||||
|
private fun parseEpgXml(xmlString: String): EpgList {
|
||||||
val parser: XmlPullParser = Xml.newPullParser()
|
val parser: XmlPullParser = Xml.newPullParser()
|
||||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||||
parser.setInput(StringReader(xmlString))
|
parser.setInput(StringReader(xmlString))
|
||||||
|
val epgMap = mutableMapOf<String, EpgChannel>()
|
||||||
var eventType = parser.eventType
|
var eventType = parser.eventType
|
||||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||||
when (eventType) {
|
when (eventType) {
|
||||||
@ -300,7 +211,7 @@ fun parseEpgXML(xmlString: String): List<EpgChannel> {
|
|||||||
id = channelId,
|
id = channelId,
|
||||||
title = channelDisplayName,
|
title = channelDisplayName,
|
||||||
)
|
)
|
||||||
Log.d("epg", "${channel.id}, ${channel.title}")
|
// Log.d("epg", "${channel.id}, ${channel.title}")
|
||||||
epgMap[channelId] = channel
|
epgMap[channelId] = channel
|
||||||
} else if (parser.name == "programme") {
|
} else if (parser.name == "programme") {
|
||||||
val channelId = parser.getAttributeValue(null, "channel")
|
val channelId = parser.getAttributeValue(null, "channel")
|
||||||
@ -329,6 +240,144 @@ fun parseEpgXML(xmlString: String): List<EpgChannel> {
|
|||||||
}
|
}
|
||||||
eventType = parser.next()
|
eventType = parser.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.i("epg","解析节目单完成,共${epgMap.size}个频道")
|
Log.i("epg","解析节目单完成,共${epgMap.size}个频道")
|
||||||
return epgMap.values.toList()
|
return EpgList(epgMap.values.toList())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun request(url: String) = withContext(Dispatchers.IO) {
|
||||||
|
Log.d("request", "request start: $url")
|
||||||
|
val client = OkHttpClient()
|
||||||
|
val request = Request.Builder().url(url).build()
|
||||||
|
try {
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) throw Exception("failed: ${response.code}")
|
||||||
|
val contentType = response.header("content-type")
|
||||||
|
if (contentType?.startsWith("text/") == true || url.endsWith(".m3u")) {
|
||||||
|
response.body?.string() ?: throw Exception("Empty response body")
|
||||||
|
} else {
|
||||||
|
val gzData = response.body?.bytes() ?: throw Exception("Empty response body")
|
||||||
|
BufferedReader(InputStreamReader(GZIPInputStream(ByteArrayInputStream(gzData)))).use { reader ->
|
||||||
|
reader.lineSequence().joinToString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
val e = Exception("request failed $url", ex)
|
||||||
|
Log.d("request", "${e.message}")
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IPTVProvider : TVProvider {
|
||||||
|
private var groupList: TVGroupList = TVGroupList()
|
||||||
|
private var epgList: EpgList = EpgList()
|
||||||
|
|
||||||
|
override suspend fun load() {
|
||||||
|
val (sources, epgUrls) = fetchIPTVSources()
|
||||||
|
groupList = processSources(sources)
|
||||||
|
epgList = fetchEPGData(epgUrls)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun groups(): TVGroupList = groupList
|
||||||
|
|
||||||
|
override fun channels(groupTitle: String): TVChannelList =
|
||||||
|
groupList.find { it.title == groupTitle }?.channels ?: TVChannelList()
|
||||||
|
|
||||||
|
private suspend fun fetchIPTVSources(): Pair<List<TVSource>, List<String>> = coroutineScope {
|
||||||
|
val iptvUrls = Settings.iptvSourceUrls.ifEmpty { listOf(Constants.IPTV_SOURCE_URL) }
|
||||||
|
val deferredResults = iptvUrls.map { url ->
|
||||||
|
async { fetchM3uData(url) }
|
||||||
|
}
|
||||||
|
val results = deferredResults.awaitAll()
|
||||||
|
val allSources = results.flatMap { it.first }
|
||||||
|
val allEpgUrls = results.flatMap { it.second }.distinct()
|
||||||
|
|
||||||
|
Pair(allSources, if (allEpgUrls.isEmpty()) listOf(Constants.EPG_XML_URL) else allEpgUrls)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchM3uData(url: String): Pair<List<TVSource>, List<String>> {
|
||||||
|
val m3u = retry { parseM3u(request(url)) }
|
||||||
|
val sources = m3u.channels.map { it.toTVSource() }
|
||||||
|
val epgUrls = m3u.headers["x-tvg-url"]?.split(",").orEmpty()
|
||||||
|
return Pair(sources, epgUrls)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchEPGData(epgUrls: List<String>): EpgList = coroutineScope {
|
||||||
|
val deferredEpgChannels = epgUrls.map { url ->
|
||||||
|
async { retry { getEpgList(url) } }
|
||||||
|
}
|
||||||
|
val epgChannels = deferredEpgChannels.awaitAll().flatten()
|
||||||
|
EpgList(epgChannels.distinctBy { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getEpgList(url: String): List<EpgChannel> = withContext(Dispatchers.Default) {
|
||||||
|
try {
|
||||||
|
parseEpgXml(request(url)).value
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Log.e("epg", "Failed to fetch EPG data", ex)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processSources(sources: List<TVSource>): TVGroupList {
|
||||||
|
val channels = sources.groupBy { it.name }
|
||||||
|
.map { (name, sources) -> TVChannel(name, sources.first().title, sources) }
|
||||||
|
|
||||||
|
return TVGroupList(
|
||||||
|
channels.groupBy { it.groupTitle ?: "Others" }
|
||||||
|
.map { (title, channels) -> TVGroup(title, TVChannelList(channels)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun request(url: String): String = withContext(Dispatchers.IO) {
|
||||||
|
Log.d("request", "Request start: $url")
|
||||||
|
val client = OkHttpClient()
|
||||||
|
val request = Request.Builder().url(url).build()
|
||||||
|
|
||||||
|
try {
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) throw Exception("Request failed: ${response.code}")
|
||||||
|
|
||||||
|
val contentType = response.header("content-type")
|
||||||
|
val body = response.body ?: throw Exception("Empty response body")
|
||||||
|
|
||||||
|
when {
|
||||||
|
contentType?.startsWith("text/") == true || url.endsWith(".m3u") -> body.string()
|
||||||
|
else -> decodeGzipContent(body.bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Log.d("request", "Request failed: $url", ex)
|
||||||
|
throw Exception("Request failed: $url", ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeGzipContent(gzData: ByteArray): String {
|
||||||
|
return BufferedReader(InputStreamReader(GZIPInputStream(ByteArrayInputStream(gzData)))).use { reader ->
|
||||||
|
reader.lineSequence().joinToString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> retry(attempts: Int = Constants.HTTP_RETRY_COUNT, block: suspend () -> T): T {
|
||||||
|
repeat(attempts - 1) { attempt ->
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("retry", "Attempt ${attempt + 1} failed", e)
|
||||||
|
delay(Constants.HTTP_RETRY_INTERVAL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return block() // Last attempt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension function to convert M3uChannel to TVSource
|
||||||
|
private fun M3uSource.toTVSource() = TVSource(
|
||||||
|
tvgId = attributes["tvg-id"],
|
||||||
|
tvgLogo = attributes["tvg-logo"],
|
||||||
|
tvgName = attributes["tvg-name"],
|
||||||
|
groupTitle = attributes["group-title"],
|
||||||
|
title = title,
|
||||||
|
url = url
|
||||||
|
)
|
Loading…
x
Reference in New Issue
Block a user