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

24 KiB
Raw Blame History

多源策略模式重构 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: 写入接口文件

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: 验证编译
.\gradlew.bat compileDebugKotlin 2>&1
  • Step 3: 提交
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 需引用 SiteConfigSearchResultPlaySourceEpisode

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 和编译验证
.\gradlew.bat compileDebugKotlin 2>&1

期望通过sourceSelector 和 sourceEpisodeGroupSelector 允许为空串)

  • Step 3: 提交
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完全复用基类

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: 编译验证
.\gradlew.bat compileDebugKotlin 2>&1
  • Step 3: 提交
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

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: 编译验证
.\gradlew.bat compileDebugKotlin 2>&1
  • Step 3: 提交
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: 写入注册中心

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: 编译验证
.\gradlew.bat compileDebugKotlin 2>&1
  • Step 3: 提交
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

# 需要先读取文件确认当前代码
  • Step 2: 添加 currentSourceId 读写方法

在 ConfigRepository 中添加 getCurrentSourceId()setCurrentSourceId(id: String),使用 SharedPreferences 的 key current_source_id。同时废弃旧的 getPresets() / applyPreset() 方法(或保留兼容)。

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: 编译验证
.\gradlew.bat compileDebugKotlin 2>&1
  • Step 4: 提交
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()

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: 编译验证
.\gradlew.bat compileDebugKotlin 2>&1
  • Step 5: 提交
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()

// 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 参数:

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: 编译验证
.\gradlew.bat compileDebugKotlin 2>&1
  • Step 5: 提交
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()

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: 编译验证
.\gradlew.bat compileDebugKotlin 2>&1
  • Step 4: 提交
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: 添加初始化代码
// 在 MainActivity.onCreate 中
SourceRegistry.init(this)
  • Step 3: 编译验证
.\gradlew.bat compileDebugKotlin 2>&1
  • Step 4: 提交
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

# 搜索引用

如果只有新代码引用 Handler可以选择删除这些旧文件或保留作为参考。

  • Step 2: 删除或注释过时的 import

确保不在报编译错误。

  • Step 3: 编译并构建 APK
.\gradlew.bat assembleDebug 2>&1

期望BUILD SUCCESSFUL

  • Step 4: 提交
git add .
git commit -m "refactor: 清理废弃代码,移除旧的 VideoExtractor/NativeSearch 引用"

Task 12: 推送并部署 APK

  • Step 1: 推送到远程仓库
git push origin main 2>&1
  • Step 2: 上传 APK 到 Gitea Release
# 删除旧 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: 验证下载
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