This commit is contained in:
Lsong 2024-09-06 00:44:42 +08:00
parent 8486ea4bf6
commit a48acc6862
11 changed files with 231 additions and 146 deletions

View File

@ -1,5 +1,5 @@
<h1 align="center"> <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> <br>DuckTV<br>
</h1> </h1>

View File

@ -20,10 +20,10 @@ android {
defaultConfig { defaultConfig {
applicationId = "me.lsong.mytv" applicationId = "me.lsong.mytv"
minSdk = 21 minSdk = 31
targetSdk = 34 targetSdk = 34
versionCode = 2 versionCode = 4
versionName = "1.0.0" versionName = "1.0.$versionCode"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -9,10 +9,10 @@
<application <application
android:name=".MyTVApplication" android:name=".MyTVApplication"
android:allowBackup="true" 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:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true" android:supportsRtl="true"

View File

@ -8,19 +8,56 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import me.lsong.mytv.epg.fetcher.EpgFetcher 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.io.StringReader
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
import java.util.zip.GZIPInputStream
/** /**
* 节目单获取 * 节目单获取
*/ */
class EpgRepository { 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 * 解析节目单xml
*/ */
private fun parseFromXml(xmlString: String): EpgList { 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))
@ -69,36 +106,4 @@ class EpgRepository {
Log.i("epg","解析节目单完成,共${epgMap.size}个频道") Log.i("epg","解析节目单完成,共${epgMap.size}个频道")
return EpgList(epgMap.values.toList()) 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()
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)
} catch (ex: Exception) {
Log.e("epg", "获取节目单失败", ex)
throw Exception(ex)
}
}
} }

View File

@ -1,17 +1,23 @@
package me.lsong.mytv.providers package me.lsong.mytv.providers
import android.util.Log import android.util.Log
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.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.EpgRepository 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 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 { class IPTVProvider(private val epgRepository: EpgRepository) : TVProvider {
private var groupList: TVGroupList = TVGroupList() private var groupList: TVGroupList = TVGroupList()
private var epgList: EpgList = EpgList() 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) } val iptvUrls = Settings.iptvSourceUrls.ifEmpty { listOf(Constants.IPTV_SOURCE_URL) }
iptvUrls.forEach { url -> iptvUrls.forEach { url ->
val m3u = retry { getM3uChannels(sourceUrl = url) } val m3u = retry { getM3uChannels(sourceUrl = url) }
allSources.addAll(m3u.sources) allSources.addAll(m3u.channels.map {
m3u.epgUrl?.let { epgUrls.add(it) } 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) if (epgUrls.isEmpty()) epgUrls.add(Constants.EPG_XML_URL)
return Pair(allSources, epgUrls.distinct()) return Pair(allSources, epgUrls.distinct())
@ -218,11 +191,7 @@ class IPTVProvider(private val epgRepository: EpgRepository) : TVProvider {
} }
private suspend fun getM3uChannels(sourceUrl: String): M3uData { private suspend fun getM3uChannels(sourceUrl: String): M3uData {
val parser = M3uParser() return parseM3u(request(sourceUrl))
val content = request(sourceUrl)
return parser.parse(content).also {
Log.i("getM3uChannels", "解析直播源完成:${it.sources.size}个资源, $sourceUrl")
}
} }
} }
@ -244,3 +213,122 @@ class MyTvProviderManager : TVProvider {
override fun groups(): TVGroupList = TVGroupList(providers.flatMap { it.groups() }) override fun groups(): TVGroupList = TVGroupList(providers.flatMap { it.groups() })
override fun channels(groupTitle: String): TVChannelList = TVChannelList(providers.flatMap { it.channels(groupTitle) }) 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()
}

View File

@ -15,15 +15,16 @@ object Constants {
*/ */
const val IPTV_SOURCE_URL = "http://lsong.one:8888/IPTV.m3u" const val IPTV_SOURCE_URL = "http://lsong.one:8888/IPTV.m3u"
// 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/YueChan/Live/main/IPTV.m3u
// https://raw.githubusercontent.com/fanmingming/live/main/tv/m3u/ipv6.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地址 * 节目单XML地址
*/ */
const val EPG_XML_URL = "http://epg.51zmt.top:8000/e.xml.gz" 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请求重试次数 * HTTP请求重试次数
@ -39,13 +40,4 @@ object Constants {
* 播放器加载超时 * 播放器加载超时
*/ */
const val VIDEO_PLAYER_LOAD_TIMEOUT = 1000L * 15 // 15秒 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 781 KiB

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 781 KiB