Files
android-tv/docs/superpowers/plans/2026-06-09-multi-source-strategy.md
2026-06-09 19:44:15 +08:00

806 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 多源策略模式重构 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 重构 VideoExtractor/NativeSearch 为策略模式,每个视频源有独立 Handlerxb6v 用配置驱动tvcat 重写详情/播放逻辑
**Architecture:** SourceHandler 接口 → BaseSourceHandler 抽象基类(复用 Jsoup 搜索/详情/播放逻辑)→ Xb6vHandler配置复用/ TvcatHandler重写 extractVideos + resolvePlayUrlSourceRegistry 单例注册管理SettingsActivity/PlayerActivity/SearchFragment 通过 Registry 获取当前 Handler
**Tech Stack:** Kotlin, Jsoup, org.json, ExoPlayer, Coroutines
---
### Task 1: 创建 SourceHandler 接口
**Files:**
- Create: `app/src/main/java/com/videoapp/tv/engine/SourceHandler.kt`
- [ ] **Step 1: 写入接口文件**
```kotlin
package com.videoapp.tv.engine
import com.videoapp.tv.data.SearchResult
interface SourceHandler {
val id: String
val displayName: String
val baseUrl: String
suspend fun search(
keyword: String,
onResult: suspend (List<SearchResult>) -> Unit,
onError: suspend (String) -> Unit
)
suspend fun extractVideos(detailUrl: String): List<PlaySource>
suspend fun resolvePlayUrl(playUrl: String): Pair<String?, String?>
}
```
- [ ] **Step 2: 验证编译**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 3: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/engine/SourceHandler.kt
git commit -m "feat: 添加 SourceHandler 接口"
```
---
### Task 2: 创建 BaseSourceHandler 抽象基类
**Files:**
- Create: `app/src/main/java/com/videoapp/tv/engine/BaseSourceHandler.kt`
- [ ] **Step 1: 写入基类,包含通用搜索、详情页解析、播放页解析**
BaseSourceHandler 需引用 `SiteConfig``SearchResult``PlaySource``Episode`
```kotlin
package com.videoapp.tv.engine
import com.videoapp.tv.data.SearchResult
import com.videoapp.tv.data.SiteConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Connection
import org.jsoup.Jsoup
abstract class BaseSourceHandler(
override val id: String,
override val displayName: String,
override val baseUrl: String,
protected val config: SiteConfig
) : SourceHandler {
override suspend fun search(
keyword: String,
onResult: suspend (List<SearchResult>) -> Unit,
onError: suspend (String) -> Unit
) {
try {
val results = withContext(Dispatchers.IO) {
val url = "${baseUrl.trimEnd('/')}/${config.searchPath.trimStart('/')}"
val connection: Connection = Jsoup.connect(url)
.data(config.keywordParam, keyword)
.timeout(15000)
config.extraParams.forEach { (key, value) ->
connection.data(key, value)
}
val method = config.searchMethod.uppercase()
val doc = if (method == "GET") {
connection.get()
} else {
connection.post()
}
val items = doc.select(config.resultSelector)
val resultsList = mutableListOf<SearchResult>()
for (item in items) {
try {
val titleEl = item.selectFirst(config.titleSelector) ?: continue
val linkEl = item.selectFirst(config.linkSelector)
val coverEl = item.selectFirst(config.coverSelector)
val categoryEl = item.selectFirst(config.categorySelector)
val dateEl = item.selectFirst(config.dateSelector)
val title = titleEl.text().trim()
val detailUrl = buildFullUrl(linkEl?.attr("href")?.trim() ?: "")
val coverUrl = buildFullUrl(coverEl?.attr("src")?.trim() ?: "")
val category = categoryEl?.text()?.trim() ?: ""
val date = dateEl?.text()?.trim() ?: ""
if (title.isNotEmpty() && detailUrl.isNotEmpty()) {
resultsList.add(
SearchResult(
title = title,
coverUrl = coverUrl,
detailUrl = detailUrl,
category = category,
date = date
)
)
}
} catch (_: Exception) {
}
}
resultsList
}
if (results.isEmpty()) {
onError("未找到结果")
} else {
onResult(results)
}
} catch (e: Exception) {
onError("搜索失败: ${e.message}")
}
}
override suspend fun extractVideos(detailUrl: String): List<PlaySource> =
withContext(Dispatchers.IO) {
val doc = Jsoup.connect(detailUrl).timeout(15000).get()
val sourceTabs = doc.select(config.sourceSelector)
val sourceNames = sourceTabs.map { it.text().trim() }.filter { it.isNotEmpty() }
val episodeGroups = doc.select(config.sourceEpisodeGroupSelector)
val sources = mutableListOf<PlaySource>()
if (sourceNames.isNotEmpty() && episodeGroups.size >= sourceNames.size) {
sourceNames.forEachIndexed { i, name ->
val group = episodeGroups[i]
val episodes = extractEpisodes(group, config)
if (episodes.isNotEmpty()) {
sources.add(PlaySource(name, episodes))
}
}
}
if (sources.isEmpty()) {
val episodes = extractEpisodes(doc, config)
if (episodes.isNotEmpty()) {
sources.add(PlaySource("默认来源", episodes))
}
}
sources
}
override suspend fun resolvePlayUrl(playUrl: String): Pair<String?, String?> =
withContext(Dispatchers.IO) {
try {
val doc = Jsoup.connect(playUrl).timeout(15000).get()
val iframeEl = doc.selectFirst(config.iframeSelector)
var iframeUrl = iframeEl?.attr("src")
if (iframeUrl != null && iframeUrl.startsWith("//")) {
iframeUrl = "https:$iframeUrl"
}
val videoSrc = doc.selectFirst(config.videoSelector)
val directUrl = videoSrc?.attr("src")
Pair(directUrl, iframeUrl)
} catch (_: Exception) {
Pair(null, null)
}
}
private fun extractEpisodes(
container: org.jsoup.nodes.Element,
config: SiteConfig
): List<Episode> {
val episodes = mutableListOf<Episode>()
val eps = container.select(config.episodeSelector)
for (ep in eps) {
val title = ep.text().trim()
val href = ep.attr("href").trim()
if (title.isNotEmpty() && href.isNotEmpty()) {
episodes.add(Episode(title, buildFullUrl(href)))
}
}
return episodes
}
protected fun buildFullUrl(href: String): String {
if (href.isEmpty()) return ""
if (href.startsWith("http")) return href
return "${baseUrl.trimEnd('/')}/${href.trimStart('/')}"
}
}
```
- [ ] **Step 2: 更新 import 和编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
期望通过sourceSelector 和 sourceEpisodeGroupSelector 允许为空串)
- [ ] **Step 3: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/engine/BaseSourceHandler.kt
git commit -m "feat: 添加 BaseSourceHandler 抽象基类"
```
---
### Task 3: 创建 Xb6vHandler
**Files:**
- Create: `app/src/main/java/com/videoapp/tv/engine/xb6v/Xb6vHandler.kt`
- [ ] **Step 1: 写入 Xb6vHandler完全复用基类**
```kotlin
package com.videoapp.tv.engine.xb6v
import com.videoapp.tv.data.SiteConfig
import com.videoapp.tv.engine.BaseSourceHandler
class Xb6vHandler(config: SiteConfig) : BaseSourceHandler(
id = "xb6v",
displayName = "xb6v (星辰影视)",
baseUrl = "https://www.xb6v.com",
config = config
)
```
- [ ] **Step 2: 编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 3: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/engine/xb6v/Xb6vHandler.kt
git commit -m "feat: 添加 Xb6vHandler"
```
---
### Task 4: 创建 TvcatHandler重写 extractVideos 和 resolvePlayUrl
**Files:**
- Create: `app/src/main/java/com/videoapp/tv/engine/tvcat/TvcatHandler.kt`
- [ ] **Step 1: 写入 TvcatHandler**
TvcatHandler 继承 BaseSourceHandler使用内置的 tvcat 专用 SiteConfig 配置,重写 extractVideos详情页只有一个源直接用 li.list-inline-item a 提取剧集)和 resolvePlayUrl调用 /_fetch_p/ API 获取 m3u8
```kotlin
package com.videoapp.tv.engine.tvcat
import com.videoapp.tv.data.SiteConfig
import com.videoapp.tv.engine.BaseSourceHandler
import com.videoapp.tv.engine.Episode
import com.videoapp.tv.engine.PlaySource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import org.jsoup.Jsoup
import java.net.URL
class TvcatHandler : BaseSourceHandler(
id = "tvcat",
displayName = "tvcat (电视猫)",
baseUrl = "https://tvcat.cc",
config = SiteConfig(
baseUrl = "https://tvcat.cc",
searchPath = "/search",
searchMethod = "GET",
keywordParam = "q",
resultSelector = "li.col-md-2.col-sm-3.col-4",
titleSelector = "a[title]",
coverSelector = "img",
linkSelector = "a",
categorySelector = ".text-muted",
dateSelector = "",
episodeSelector = "li.list-inline-item a",
sourceSelector = "",
sourceEpisodeGroupSelector = "",
iframeSelector = "iframe",
videoSelector = "video source, video[src]"
)
) {
override suspend fun extractVideos(detailUrl: String): List<PlaySource> =
withContext(Dispatchers.IO) {
val doc = Jsoup.connect(detailUrl).timeout(15000).get()
val episodes = doc.select("li.list-inline-item a").mapNotNull { ep ->
val title = ep.text().trim()
val href = ep.attr("href").trim()
if (title.isNotEmpty() && href.isNotEmpty()) {
Episode(title, buildFullUrl(href))
} else null
}
if (episodes.isNotEmpty()) {
listOf(PlaySource("默认来源", episodes))
} else emptyList()
}
override suspend fun resolvePlayUrl(playUrl: String): Pair<String?, String?> =
withContext(Dispatchers.IO) {
try {
val match = Regex("""(\d+)/ep(\d+)""").find(playUrl)
if (match != null) {
val videoId = match.groupValues[1]
val epNum = match.groupValues[2]
val apiUrl = "https://tvcat.cc/_fetch_p/$videoId/ep$epNum"
val conn = URL(apiUrl).openConnection()
conn.setRequestProperty("User-Agent", "Mozilla/5.0")
conn.setRequestProperty("Referer", playUrl)
conn.connectTimeout = 15000
conn.readTimeout = 15000
val json = conn.getInputStream().bufferedReader().use { it.readText() }
val obj = JSONObject(json)
val playcfgs = obj.getJSONArray("playcfgs")
if (playcfgs.length() > 0) {
val url = playcfgs.getJSONObject(0).getString("url")
Pair(url, null)
} else Pair(null, null)
} else Pair(null, null)
} catch (_: Exception) {
Pair(null, null)
}
}
}
```
- [ ] **Step 2: 编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 3: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/engine/tvcat/TvcatHandler.kt
git commit -m "feat: 添加 TvcatHandler支持 /_fetch_p/ API 获取播放链接"
```
---
### Task 5: 创建 SourceRegistry 注册中心
**Files:**
- Create: `app/src/main/java/com/videoapp/tv/engine/SourceRegistry.kt`
- [ ] **Step 1: 写入注册中心**
```kotlin
package com.videoapp.tv.engine
import android.content.Context
import com.videoapp.tv.data.ConfigRepository
import com.videoapp.tv.data.SiteConfig
import com.videoapp.tv.engine.tvcat.TvcatHandler
import com.videoapp.tv.engine.xb6v.Xb6vHandler
object SourceRegistry {
private val handlers = mutableMapOf<String, SourceHandler>()
fun register(handler: SourceHandler) {
handlers[handler.id] = handler
}
fun get(id: String): SourceHandler = handlers[id]
?: throw IllegalArgumentException("Unknown source: $id")
fun getOrDefault(id: String): SourceHandler? = handlers[id]
fun getAll(): List<SourceHandler> = handlers.values.toList()
fun init(context: Context) {
val configRepo = ConfigRepository(context)
val savedConfig = configRepo.getConfig()
val xb6vConfig = xb6vConfig()
register(Xb6vHandler(xb6vConfig))
register(TvcatHandler())
}
private fun xb6vConfig() = SiteConfig(
baseUrl = "https://www.xb6v.com",
searchPath = "/e/search/11index.php",
searchMethod = "POST",
keywordParam = "keyboard",
extraParams = mapOf(
"show" to "title",
"tempid" to "1",
"tbname" to "article",
"mid" to "1",
"dopost" to "search"
),
resultSelector = "li.post",
titleSelector = "h2 a",
coverSelector = ".thumbnail img",
linkSelector = ".thumbnail a",
categorySelector = ".info_category a",
dateSelector = ".info_date",
episodeSelector = "a.lBtn",
sourceSelector = ".playfrom a, .play_source a, .source-list a",
sourceEpisodeGroupSelector = ".playlist > ul, .play_list > ul, .episode-list",
iframeSelector = ".video iframe",
videoSelector = "video source, video[src]"
)
}
```
- [ ] **Step 2: 编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 3: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/engine/SourceRegistry.kt
git commit -m "feat: 添加 SourceRegistry 注册中心"
```
---
### Task 6: 修改 ConfigRepository添加 currentSourceId 存储
**Files:**
- Modify: `app/src/main/java/com/videoapp/tv/data/ConfigRepository.kt`
- [ ] **Step 1: 读取 ConfigRepository**
```powershell
# 需要先读取文件确认当前代码
```
- [ ] **Step 2: 添加 currentSourceId 读写方法**
在 ConfigRepository 中添加 `getCurrentSourceId()``setCurrentSourceId(id: String)`,使用 SharedPreferences 的 key `current_source_id`。同时废弃旧的 `getPresets()` / `applyPreset()` 方法(或保留兼容)。
```kotlin
fun getCurrentSourceId(): String {
return prefs.getString("current_source_id", "xb6v") ?: "xb6v"
}
fun setCurrentSourceId(id: String) {
prefs.edit().putString("current_source_id", id).apply()
}
```
- [ ] **Step 3: 编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 4: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/data/ConfigRepository.kt
git commit -m "feat: ConfigRepository 添加 currentSourceId 存储"
```
---
### Task 7: 修改 SettingsActivity使用 Registry 驱动 Spinner
**Files:**
- Modify: `app/src/main/java/com/videoapp/tv/SettingsActivity.kt:60-80`
- Modify: `app/src/main/res/layout/activity_settings.xml`
- [ ] **Step 1: 读取当前 SettingsActivity 和布局文件**
需要先读取确认当前 Spinner 相关的代码。
- [ ] **Step 2: 修改 Spinner 数据源**
将 Spinner 数据源从 `SitePreset.PRESETS` 改为 `SourceRegistry.getAll()`
```kotlin
private fun setupSourceSpinner() {
val handlers = SourceRegistry.getAll()
val names = handlers.map { it.displayName }
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, names)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinnerSource.adapter = adapter
val currentId = configRepo.getCurrentSourceId()
val currentIndex = handlers.indexOfFirst { it.id == currentId }
if (currentIndex >= 0) {
spinnerSource.setSelection(currentIndex)
}
spinnerSource.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
if (ignoringSpinner) return
val handler = handlers[position]
configRepo.setCurrentSourceId(handler.id)
}
override fun onNothingSelected(parent: AdapterView<*>) {}
}
}
```
- [ ] **Step 3: 移除旧的 SitePreset 引用**
删除 `import com.videoapp.tv.data.SitePreset`,删除 `applyPreset` 调用。
- [ ] **Step 4: 编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 5: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/SettingsActivity.kt app/src/main/res/layout/activity_settings.xml
git commit -m "feat: SettingsActivity 使用 SourceRegistry 驱动源切换"
```
---
### Task 8: 修改 PlayerActivity通过 Handler 获取数据
**Files:**
- Modify: `app/src/main/java/com/videoapp/tv/PlayerActivity.kt:141-161` (loadSources)
- Modify: `app/src/main/java/com/videoapp/tv/PlayerActivity.kt:247-269` (playEpisode)
- [ ] **Step 1: 读取 PlayerActivity 当前代码**
需要确认精确行号和当前代码结构。
- [ ] **Step 2: 修改 loadSources 和 playEpisode**
`videoExtractor.extractVideos()` 改为 `handler.extractVideos()`,将 `videoExtractor.extractFromPlayPage()` + `fetchPlayUrlFromApi()` 改为 `handler.resolvePlayUrl()`
```kotlin
// loadSources
private fun loadSources(detailUrl: String) {
showLoading(true)
val currentId = configRepo.getCurrentSourceId()
val handler = SourceRegistry.getOrDefault(currentId)
lifecycleScope.launch {
try {
sources = handler?.extractVideos(detailUrl) ?: emptyList()
if (sources.isNotEmpty()) {
buildSourceUI()
selectSource(0)
resetAutoHide()
} else {
tryPlayDirectly(detailUrl)
}
} catch (e: Exception) {
showError("加载失败: ${e.message}")
showLoading(false)
}
}
}
// playEpisode
private fun playEpisode(ep: Episode) {
currentEpisode = ep
showLoading(true)
val currentId = configRepo.getCurrentSourceId()
val handler = SourceRegistry.getOrDefault(currentId)
savePlayHistory(ep.title)
lifecycleScope.launch {
val (directUrl, iframeUrl) = handler?.resolvePlayUrl(ep.playUrl)
?: Pair(null, null)
if (directUrl != null) {
playWithExoPlayer(directUrl)
} else if (iframeUrl != null) {
playWithWebView(iframeUrl)
} else {
playWithWebView(ep.playUrl)
}
}
}
```
同时修改 `tryPlayDirectly` 方法签名,移除 `config` 参数:
```kotlin
private fun tryPlayDirectly(detailUrl: String) {
savePlayHistory(videoTitle)
lifecycleScope.launch {
val currentId = configRepo.getCurrentSourceId()
val handler = SourceRegistry.getOrDefault(currentId)
val (directUrl, iframeUrl) = handler?.resolvePlayUrl(detailUrl)
?: Pair(null, null)
if (directUrl != null) {
controlPanel.visibility = View.GONE
playWithExoPlayer(directUrl)
} else if (iframeUrl != null) {
controlPanel.visibility = View.GONE
playWithWebView(iframeUrl)
} else {
controlPanel.visibility = View.GONE
playWithWebView(detailUrl)
}
}
}
```
- [ ] **Step 3: 移除旧的 VideoExtractor 引用**
删除 `private val videoExtractor = VideoExtractor()` 字段。
- [ ] **Step 4: 编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 5: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/PlayerActivity.kt
git commit -m "feat: PlayerActivity 使用 SourceHandler 获取剧集和播放链接"
```
---
### Task 9: 修改 SearchFragment通过 Handler 执行搜索
**Files:**
- Modify: `app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt`
- [ ] **Step 1: 读取 SearchFragment 当前代码**
- [ ] **Step 2: 修改搜索调用**
`NativeSearch().search()` 改为 `SourceRegistry.get(currentId).search()`
```kotlin
private fun performSearch(query: String) {
val currentId = configRepo.getCurrentSourceId()
val handler = SourceRegistry.getOrDefault(currentId)
if (handler == null) {
showError("未知源")
return
}
settingsIcon?.visibility = View.VISIBLE
lifecycleScope.launch {
handler.search(
keyword = query,
onResult = { results ->
adapter.setItems(results)
},
onError = { error ->
showError(error)
}
)
}
}
```
- [ ] **Step 3: 编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 4: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt
git commit -m "feat: SearchFragment 使用 SourceHandler 执行搜索"
```
---
### Task 10: 在 Application 或 MainActivity 中初始化 Registry
**Files:**
- Modify: `app/src/main/java/com/videoapp/tv/MainActivity.kt`(或 Application 类,取决于现有架构)
- [ ] **Step 1: 找到合适的初始化入口**
查找是否存在 Application 类,否则在 MainActivity.onCreate 中初始化。
- [ ] **Step 2: 添加初始化代码**
```kotlin
// 在 MainActivity.onCreate 中
SourceRegistry.init(this)
```
- [ ] **Step 3: 编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 4: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/MainActivity.kt
git commit -m "feat: 应用启动时初始化 SourceRegistry"
```
---
### Task 11: 清理废弃代码
**Files:**
- 保留: `app/src/main/java/com/videoapp/tv/engine/VideoExtractor.kt`(保留,其他旧引用可能还在用)
- 保留: `app/src/main/java/com/videoapp/tv/engine/NativeSearch.kt`(保留兼容)
- 标记: `app/src/main/java/com/videoapp/tv/data/SitePreset.kt`(已不再使用,可选删除或保留)
- [ ] **Step 1: 检查是否还有代码引用 VideoExtractor / NativeSearch / SitePreset**
```powershell
# 搜索引用
```
如果只有新代码引用 Handler可以选择删除这些旧文件或保留作为参考。
- [ ] **Step 2: 删除或注释过时的 import**
确保不在报编译错误。
- [ ] **Step 3: 编译并构建 APK**
```powershell
.\gradlew.bat assembleDebug 2>&1
```
期望BUILD SUCCESSFUL
- [ ] **Step 4: 提交**
```powershell
git add .
git commit -m "refactor: 清理废弃代码,移除旧的 VideoExtractor/NativeSearch 引用"
```
---
### Task 12: 推送并部署 APK
- [ ] **Step 1: 推送到远程仓库**
```powershell
git push origin main 2>&1
```
- [ ] **Step 2: 上传 APK 到 Gitea Release**
```powershell
# 删除旧 asset
curl.exe -s -X DELETE "http://124.223.26.33:3000/api/v1/repos/xiaji/android-tv/releases/2/assets/3" -u "xiaji:xiaji1234"
# 上传新 APK
curl.exe -s -X POST "http://124.223.26.33:3000/api/v1/repos/xiaji/android-tv/releases/2/assets" -u "xiaji:xiaji1234" -F "attachment=@app\build\outputs\apk\debug\app-debug.apk" -F "name=app-debug.apk"
```
- [ ] **Step 3: 验证下载**
```powershell
curl.exe -s -I "http://124.223.26.33:3000/xiaji/android-tv/releases/download/v1.1/app-debug.apk"
```
期望HTTP/1.1 200 OK