diff --git a/docs/superpowers/plans/2026-06-09-multi-source-strategy.md b/docs/superpowers/plans/2026-06-09-multi-source-strategy.md new file mode 100644 index 0000000..4f098ab --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-multi-source-strategy.md @@ -0,0 +1,805 @@ +# 多源策略模式重构 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