# 多源策略模式重构 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 为策略模式,每个视频源有独立 Handler,xb6v 用配置驱动,tvcat 重写详情/播放逻辑 **Architecture:** SourceHandler 接口 → BaseSourceHandler 抽象基类(复用 Jsoup 搜索/详情/播放逻辑)→ Xb6vHandler(配置复用)/ TvcatHandler(重写 extractVideos + resolvePlayUrl),SourceRegistry 单例注册管理,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) -> Unit, onError: suspend (String) -> Unit ) suspend fun extractVideos(detailUrl: String): List suspend fun resolvePlayUrl(playUrl: String): Pair } ``` - [ ] **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) -> 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() 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 = 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() 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 = 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 { val episodes = mutableListOf() 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 = 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 = 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() 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 = 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