update
This commit is contained in:
parent
8486ea4bf6
commit
a48acc6862
@ -1,5 +1,5 @@
|
||||
<h1 align="center">
|
||||
<img src="./icon.png" alt="DuckTV Icon" width="200">
|
||||
<img src="./app/src/main/res/drawable-xhdpi/ic_launcher.png" alt="DuckTV Icon" width="200">
|
||||
<br>DuckTV<br>
|
||||
</h1>
|
||||
|
||||
|
@ -20,10 +20,10 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "me.lsong.mytv"
|
||||
minSdk = 21
|
||||
minSdk = 31
|
||||
targetSdk = 34
|
||||
versionCode = 2
|
||||
versionName = "1.0.0"
|
||||
versionCode = 4
|
||||
versionName = "1.0.$versionCode"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
@ -9,10 +9,10 @@
|
||||
<application
|
||||
android:name=".MyTVApplication"
|
||||
android:allowBackup="true"
|
||||
android:banner="@drawable/ic_banner"
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
|
@ -8,97 +8,102 @@ 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 {
|
||||
|
||||
/**
|
||||
* 解析节目单xml
|
||||
*/
|
||||
private fun parseFromXml(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())
|
||||
}
|
||||
|
||||
private suspend fun fetchXml(url: String): String = withContext(Dispatchers.IO) {
|
||||
Log.d("epg", "获取远程节目单xml: $url")
|
||||
|
||||
val client = OkHttpClient()
|
||||
val request = Request.Builder().url(url).build()
|
||||
|
||||
suspend fun getEpgList(url: String): EpgList = withContext(Dispatchers.Default) {
|
||||
try {
|
||||
with(client.newCall(request).execute()) {
|
||||
if (!isSuccessful) {
|
||||
throw Exception("获取远程节目单xml失败: $code")
|
||||
}
|
||||
|
||||
val fetcher = EpgFetcher.instances.first { it.isSupport(url) }
|
||||
|
||||
return@with fetcher.fetch(this)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
throw Exception("获取远程节目单xml失败,请检查网络连接", ex)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getEpgList(xmlUrl: String): EpgList = withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val xmlString = fetchXml(xmlUrl)
|
||||
return@withContext parseFromXml(xmlString)
|
||||
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())
|
||||
}
|
@ -1,17 +1,23 @@
|
||||
package me.lsong.mytv.providers
|
||||
|
||||
import android.util.Log
|
||||
import android.util.Xml
|
||||
import androidx.compose.runtime.Immutable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.lsong.mytv.epg.EpgChannel
|
||||
import me.lsong.mytv.epg.EpgList
|
||||
import me.lsong.mytv.epg.EpgProgramme
|
||||
import me.lsong.mytv.epg.EpgRepository
|
||||
import me.lsong.mytv.utils.Constants
|
||||
import me.lsong.mytv.utils.Settings
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.StringReader
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
|
||||
// 数据类定义
|
||||
@ -92,48 +98,6 @@ data class TVChannelList(val value: List<TVChannel> = emptyList()) : List<TVChan
|
||||
}
|
||||
}
|
||||
|
||||
data class M3uData(
|
||||
var epgUrl: String?,
|
||||
val sources: List<TVSource>
|
||||
)
|
||||
|
||||
class M3uParser {
|
||||
fun parse(data: String): M3uData {
|
||||
var xTvgUrl: String? = null
|
||||
val channels = mutableListOf<TVSource>()
|
||||
data
|
||||
.trim()
|
||||
.split("\r\n", "\n")
|
||||
.filter { it.isNotBlank() }
|
||||
.windowed(2) { (line1, line2) ->
|
||||
when {
|
||||
line1.startsWith("#EXTM3U") -> {
|
||||
xTvgUrl = Regex("x-tvg-url=\"(.+?)\"").find(line1)?.groupValues?.get(1)?.trim()
|
||||
}
|
||||
line1.startsWith("#EXTINF") && !line2.startsWith("#") -> {
|
||||
val title = line1.split(",").lastOrNull()?.trim() ?: return@windowed
|
||||
val attributes = parseTvgAttributes(line1)
|
||||
channels.add(
|
||||
TVSource(
|
||||
tvgId = attributes["tvg-id"],
|
||||
tvgName = attributes["tvg-name"],
|
||||
tvgLogo = attributes["tvg-logo"],
|
||||
groupTitle = attributes["group-title"],
|
||||
title = title,
|
||||
url = line2.trim()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return M3uData(epgUrl = xTvgUrl, channels)
|
||||
}
|
||||
|
||||
private fun parseTvgAttributes(line: String): Map<String, String> =
|
||||
Regex("""(\S+?)="(.+?)"""").findAll(line)
|
||||
.associate { it.groupValues[1] to it.groupValues[2].trim() }
|
||||
}
|
||||
|
||||
class IPTVProvider(private val epgRepository: EpgRepository) : TVProvider {
|
||||
private var groupList: TVGroupList = TVGroupList()
|
||||
private var epgList: EpgList = EpgList()
|
||||
@ -157,8 +121,17 @@ class IPTVProvider(private val epgRepository: EpgRepository) : TVProvider {
|
||||
val iptvUrls = Settings.iptvSourceUrls.ifEmpty { listOf(Constants.IPTV_SOURCE_URL) }
|
||||
iptvUrls.forEach { url ->
|
||||
val m3u = retry { getM3uChannels(sourceUrl = url) }
|
||||
allSources.addAll(m3u.sources)
|
||||
m3u.epgUrl?.let { epgUrls.add(it) }
|
||||
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())
|
||||
@ -218,11 +191,7 @@ class IPTVProvider(private val epgRepository: EpgRepository) : TVProvider {
|
||||
}
|
||||
|
||||
private suspend fun getM3uChannels(sourceUrl: String): M3uData {
|
||||
val parser = M3uParser()
|
||||
val content = request(sourceUrl)
|
||||
return parser.parse(content).also {
|
||||
Log.i("getM3uChannels", "解析直播源完成:${it.sources.size}个资源, $sourceUrl")
|
||||
}
|
||||
return parseM3u(request(sourceUrl))
|
||||
}
|
||||
}
|
||||
|
||||
@ -244,3 +213,122 @@ class MyTvProviderManager : TVProvider {
|
||||
override fun groups(): TVGroupList = TVGroupList(providers.flatMap { it.groups() })
|
||||
override fun channels(groupTitle: String): TVChannelList = TVChannelList(providers.flatMap { it.channels(groupTitle) })
|
||||
}
|
||||
|
||||
data class M3uData(
|
||||
val headers: Map<String, String>,
|
||||
val channels: List<M3uSource>
|
||||
)
|
||||
|
||||
data class M3uSource(
|
||||
val attributes: Map<String, String>,
|
||||
val title: String,
|
||||
val url: String
|
||||
)
|
||||
|
||||
fun parseM3u(data: String): M3uData {
|
||||
val lines = data.trim().split("\r\n", "\n").filter { it.isNotBlank() }
|
||||
val headers = mutableMapOf<String, String>()
|
||||
val channels = mutableListOf<M3uSource>()
|
||||
var currentAttributes = mutableMapOf<String, String>()
|
||||
var currentTitle = ""
|
||||
for (processedLine in lines) {
|
||||
when {
|
||||
processedLine.startsWith("#EXTM3U") -> {
|
||||
headers.putAll(parseAttributes(processedLine.substring(7).trim()))
|
||||
}
|
||||
processedLine.startsWith("#EXTINF:") -> {
|
||||
val (duration, rest) = processedLine.substring(8).split(',', limit = 2)
|
||||
currentAttributes = parseAttributes(duration).toMutableMap()
|
||||
currentTitle = rest.trim()
|
||||
}
|
||||
!processedLine.startsWith("#") -> {
|
||||
channels.add(M3uSource(currentAttributes, currentTitle, processedLine.trim()))
|
||||
currentAttributes = mutableMapOf()
|
||||
currentTitle = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return M3uData(headers, channels)
|
||||
}
|
||||
|
||||
fun parseAttributes(input: String): Map<String, String> {
|
||||
val attributes = mutableMapOf<String, String>()
|
||||
var remaining = input.trim().replace("\",\"", ",")
|
||||
while (remaining.isNotEmpty()) {
|
||||
val equalIndex = remaining.indexOf('=')
|
||||
if (equalIndex == -1) break
|
||||
|
||||
val key = remaining.substring(0, equalIndex).trim()
|
||||
remaining = remaining.substring(equalIndex + 1).trim()
|
||||
val value: String
|
||||
if (remaining.startsWith("\"")) {
|
||||
val endQuoteIndex = remaining.indexOf("\"", 1)
|
||||
if (endQuoteIndex == -1) break
|
||||
value = remaining.substring(1, endQuoteIndex)
|
||||
remaining = remaining.substring(endQuoteIndex + 1).trim()
|
||||
} else {
|
||||
val spaceIndex = remaining.indexOf(' ')
|
||||
if (spaceIndex == -1) {
|
||||
value = remaining
|
||||
remaining = ""
|
||||
} else {
|
||||
value = remaining.substring(0, spaceIndex)
|
||||
remaining = remaining.substring(spaceIndex + 1).trim()
|
||||
}
|
||||
}
|
||||
attributes[key] = value
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
|
||||
fun parseEpgXML(xmlString: String): List<EpgChannel> {
|
||||
val epgMap = mutableMapOf<String, EpgChannel>()
|
||||
val parser: XmlPullParser = Xml.newPullParser()
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||
parser.setInput(StringReader(xmlString))
|
||||
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 epgMap.values.toList()
|
||||
}
|
||||
|
@ -15,15 +15,16 @@ object Constants {
|
||||
*/
|
||||
const val IPTV_SOURCE_URL = "http://lsong.one:8888/IPTV.m3u"
|
||||
// http://lsong.one:8888/IPTV.m3u
|
||||
// https://live.fanmingming.com/tv/m3u/index.m3u
|
||||
// https://raw.githubusercontent.com/YueChan/Live/main/IPTV.m3u
|
||||
// https://raw.githubusercontent.com/fanmingming/live/main/tv/m3u/ipv6.m3u
|
||||
// https://live.fanmingming.com/tv/m3u/index.m3u
|
||||
// https://raw.githubusercontent.com/yuanzl77/IPTV/main/live.m3u
|
||||
|
||||
/**
|
||||
* 节目单XML地址
|
||||
*/
|
||||
const val EPG_XML_URL = "http://epg.51zmt.top:8000/e.xml.gz"
|
||||
// const val EPG_XML_URL = "https://live.fanmingming.com/e.xml"
|
||||
// const val EPG_XML_URL = "http://epg.51zmt.top:8000/e.xml"
|
||||
|
||||
/**
|
||||
* HTTP请求重试次数
|
||||
@ -39,13 +40,4 @@ object Constants {
|
||||
* 播放器加载超时
|
||||
*/
|
||||
const val VIDEO_PLAYER_LOAD_TIMEOUT = 1000L * 15 // 15秒
|
||||
|
||||
/**
|
||||
* 播放器 userAgent
|
||||
*/
|
||||
// const val VIDEO_PLAYER_USER_AGENT = "ExoPlayer"
|
||||
// /**
|
||||
// * 界面 临时面板界面显示时间
|
||||
// */
|
||||
// const val UI_TEMP_PANEL_SCREEN_SHOW_DURATION = 1500L // 1.5秒
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 182 KiB |
BIN
app/src/main/res/drawable-xhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/drawable-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 237 KiB |
BIN
app/src/main/res/drawable-xhdpi/tv_banner.png
Normal file
BIN
app/src/main/res/drawable-xhdpi/tv_banner.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
BIN
icon-x1024.png
BIN
icon-x1024.png
Binary file not shown.
Before Width: | Height: | Size: 781 KiB |
Loading…
x
Reference in New Issue
Block a user