V1.5.4: 新增可用源 gz.ac16.vip;修复"点歌失败不入下载列表";下载列表删除按状态区分

一、新增歌曲源 gz.ac16.vip(置于源列表首位)
实测(DoH 取真实 IP 154.222.26.244,绕开本机网关 fake-IP 干扰):
- i.php: HTTP 200,下发 token,end_date=2027-10-05(比现用镜像源的 2026-10-05 更晚);
- music/do.php: 对 7303235/7200188/8614960 分别返回各自不同的真源地址
  (http://txog.ktvsky.com/.../<编号>.ts?token=... / http://download.origjoy.com/.../<编号>.ts?sign=...),
  文件名均等于点歌编号;
- 这些地址能真实下载:HTTP 206 video/MP2T,逐曲内容各不相同,
  TS 流同步字节按 188 字节对齐(41/43 包同步);
- 对照:其余 7 个入口对每首歌(含不存在的编号)统一返回同一段
  https://wb66.cn/f/d/7b7c9/10188555.ts(广告/测试资源),主源 w.w345.my/i.php 直接 403。

因此把 gz.ac16.vip 加入源列表:ktv_api.js 的 CONFIG.HOSTS 与
SongUrlResolver.DEFAULT_HOSTS 各一处(均置于首位)。
⚠️ 边界:该域名**自己**的 /<编号>.ts 永远是同一段广告,所以它继续留在
AdVideoFilter.AD_HOSTS(媒体层拦截);广告黑名单只作用于"返回地址的 host",
不作用于"取址入口的 host",两者不可混淆,否则会误杀唯一可用源。
KTV_BRIDGE_API 9 → 10(ktv_api.js 两处标记 + KtvJsBridge 两处校验),保证设备热更新到新脚本。

二、修复"点歌失败时歌曲不出现在下载列表"
根因:DownloadTask 只在 onDownloadStart 里创建,所以"取址失败(无法生成下载地址)"这类
还没真正开始下载的失败**不会留下任何记录**,下载列表里什么都没有。
修复:downloadSong() 在**发起下载之前**就登记任务(状态「排队中」),
失败时由既有回调就地改成「失败:原因」,条目保留在列表里,可看原因、可重试、可清理。

三、下载列表删除功能(按状态区分语义)
- 有本地文件 → 「删除文件」(confirmDeleteLocalFile,同时清掉下载记录);
- 失败/进行中(无文件)→ 「移出下载列表」(confirmRemoveDownloadEntry):
  只删记录 + stateDatabase.removeDownload,**绝不动任何歌曲文件**
  (历史上"顺手清缓存"删光公放曲库的教训见 AGENTS);
- 「更多」菜单在下载列表内额外提供「移出列表」;
- SongListAdapter 的「更多」图标由装饰改为**可点/可聚焦**(遥控器可达,不再只能长按)。

版本 1.5.3(35) → 1.5.4(36);签名证书不变。测试 9 例全通过。
This commit is contained in:
zijunchiang
2026-10-04 00:04:20 +08:00
parent 96bad3bd68
commit c1fd0fa319
7 changed files with 101 additions and 10 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.local.ktv"
minSdk 21
targetSdk 25
versionCode 35
versionName "1.5.3"
versionCode 36
versionName "1.5.4"
ndk {
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
+5 -2
View File
@@ -12,7 +12,7 @@
(function(global) {
'use strict';
// KTV_BRIDGE_API: 9 (Android 缓存校验标记,请勿删除)
// KTV_BRIDGE_API: 10 (Android 缓存校验标记,请勿删除)
// ─── 配置 ──────────────────────────────────────────
const CONFIG = {
@@ -23,6 +23,9 @@
VER: '4',
// 歌曲源(均为同一后台的多个入口; 运行时按可用性自动排序, 解析失败的源沉底)。
HOSTS: [
// 2026-10 实测: 其余入口对每首歌统一返回广告/测试资源时,本入口仍稳定下发真源 CDN 地址,
// 故置于首位;它自己域名下的媒体始终是广告,由 AdVideoFilter 继续拦截。
'http://gz.ac16.vip',
'http://w.w345.my',
'http://desktok.top',
'http://mm.kk456.top',
@@ -441,7 +444,7 @@
// ─── 导出 ──────────────────────────────────────────
global.KtvApi = KtvApi;
global.KtvApiConfig = CONFIG;
global.KtvBridgeApiVersion = 9;
global.KtvBridgeApiVersion = 10;
if (typeof module !== 'undefined' && module.exports) {
module.exports = {KtvApi: KtvApi, CONFIG: CONFIG};
@@ -335,7 +335,7 @@ class KtvJsBridge(context: Context) {
runCatching {
file.isFile && file.length() in 1_001..500_000 &&
file.readText(Charsets.UTF_8).let {
it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 9")
it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 10")
}
}.getOrDefault(false)
@@ -361,7 +361,7 @@ class KtvJsBridge(context: Context) {
val script = String(bytes, StandardCharsets.UTF_8)
require(
script.length > 1000 && script.contains("KtvApi") &&
script.contains("KTV_BRIDGE_API: 9"),
script.contains("KTV_BRIDGE_API: 10"),
) { "Invalid or incompatible JS file" }
tempFile.writeText(script, Charsets.UTF_8)
if (!tempFile.renameTo(cacheFile)) {
@@ -5441,6 +5441,20 @@ class MainActivity : AppCompatActivity() {
if (::stateDatabase.isInitialized) runCatching {
stateIo.execute { stateDatabase.updateDownload(song, "queued", 0) }
}
// ⚠️ 必须在**发起下载之前**就把任务挂进下载列表。
// 旧实现只在 onDownloadStart 里建任务,于是"取址失败"(无法生成下载地址)这类
// 还没真正开始下载的失败**不会留下任何记录** —— 用户点歌失败后下载列表空空如也,
// 既看不到原因也无法重试。现在先登记为「排队中」,任何失败都会就地改成「失败:原因」。
main.post(Runnable {
val task = downloads.firstOrNull { stableId(it.song) == stableId(song) }
?: DownloadTask(song).also { downloads.add(it) }
if (task.state == "等待" || task.state.isEmpty() || task.state.startsWith("失败")) {
task.state = "排队中"
}
task.cancelled = false
refreshSongAdapter()
if (currentTabIndex == 6) loadDownloadedList()
})
download(song, object : DownloadCallback {
override fun onDownloadStart(song: Song) {
if (extraCallback != null) extraCallback.onDownloadStart(song)
@@ -6275,6 +6289,13 @@ class MainActivity : AppCompatActivity() {
confirmDeleteLocalFile(song)
}, LinearLayout.LayoutParams(0, -2, 1f))
}
// 下载列表里额外提供「移出列表」:在下载管理中删除条目(失败任务一直留在列表里,
// 就是为了能看清原因并自行清理或重试)。
if (browseMode == "downloads" && downloads.any { stableId(it.song) == stableId(song) }) {
actions.addView(action(R.drawable.ott_ic_delete, "移出列表") {
confirmRemoveDownloadEntry(song)
}, LinearLayout.LayoutParams(0, -2, 1f))
}
panel.addView(actions, LinearLayout.LayoutParams(-1, -2))
panel.addView(TextView(this).apply {
text = song.singer.orEmpty()
@@ -6309,6 +6330,41 @@ class MainActivity : AppCompatActivity() {
.create().showForTv()
}
/** 下载列表「移出列表」确认框。 */
private fun confirmRemoveDownloadEntry(song: Song) {
AlertDialog.Builder(this)
.setTitle("移出下载列表")
.setMessage(
"确定把 ${song.title} 移出下载列表吗?\n" +
"只删除这条下载记录,不影响曲库;已下载的文件请用「删除文件」。",
)
.setNegativeButton("取消", null)
.setPositiveButton("确定") { _, _ -> removeDownloadEntry(song) }
.create().showForTv()
}
/**
* 把一首歌从下载列表中移出(同时清掉下载状态记录)。
*
* ⚠️ 只动"下载记录",**绝不删除任何歌曲文件** —— 文件删除是用户显式选择「删除文件」的事,
* 历史上"顺手清缓存"曾把整个公放曲库删光(1.4.10 事故)。
*/
private fun removeDownloadEntry(song: Song) {
val removing = downloads.filter { stableId(it.song) == stableId(song) }
if (removing.isEmpty()) {
toast("该歌曲不在下载列表中")
return
}
downloads.removeAll { stableId(it.song) == stableId(song) }
pendingQueueSongIds.remove(stableId(song))
if (::stateDatabase.isInitialized) runCatching {
stateIo.execute { stateDatabase.removeDownload(song) }
}
toast("已移出下载列表:${song.title}")
refreshSongAdapter()
if (browseMode == "downloads") loadDownloadedList()
}
private fun showSongInfo(song: Song) {
val message = ("歌名:" + song.title
+ "\n歌手:" + song.singer
@@ -9825,8 +9881,16 @@ class MainActivity : AppCompatActivity() {
onDownload = { song -> downloadSong(song) },
onMore = { song -> showSongActions(song) },
onTop = { song -> moveQueuedSongToNext(song) },
// 下载列表里「删除」按状态区分语义:
// 有本地文件 → 删除文件(同时清掉下载记录)
// 失败/进行中(无文件)→ 移出下载列表(绝不误删别的文件)
// 其它列表(已点/已唱等)保持原语义:从队列移除。
onDelete = { song ->
if (browseMode == "downloads") deleteDownloadedSong(song) else deleteQueuedSong(song)
when {
browseMode != "downloads" -> deleteQueuedSong(song)
hasValidLocalSongFile(song) -> confirmDeleteLocalFile(song)
else -> confirmRemoveDownloadEntry(song)
}
},
onPauseResume = { song -> toggleDownloadPause(song) },
),
@@ -135,7 +135,7 @@ class SongListAdapter(
if (!state.local || state.queued) {
addView(icon(if (state.queued) R.drawable.ic_order_song else R.drawable.ott_ic_download, null), LinearLayout.LayoutParams(dp(40), dp(40)))
}
addView(icon(R.drawable.ott_ic_more, null), LinearLayout.LayoutParams(dp(40), dp(40)))
addView(icon(R.drawable.ott_ic_more) { callbacks.onMore(song) }, LinearLayout.LayoutParams(dp(40), dp(40)))
configureSongCard(this, song, songIndex)
if (songIndex >= columns) nextFocusUpId = cardId(songIndex - columns)
if (column == 1) nextFocusLeftId = cardId(songIndex - 1)
@@ -167,7 +167,7 @@ class SongListAdapter(
if (!state.local || state.queued) {
addView(icon(if (state.queued) R.drawable.ic_order_song else R.drawable.ott_ic_download, null), LinearLayout.LayoutParams(dp(40), dp(40)))
}
addView(icon(R.drawable.ott_ic_more, null), LinearLayout.LayoutParams(dp(40), dp(40)))
addView(icon(R.drawable.ott_ic_more) { callbacks.onMore(song) }, LinearLayout.LayoutParams(dp(40), dp(40)))
configureSongCard(this, song, index)
}
}
@@ -47,6 +47,8 @@ object SongUrlResolver {
private const val BLOCKED_COOLDOWN_MS = 3 * 60_000L
private val DEFAULT_HOSTS = listOf(
// 与其他入口同一后台、同一协议;2026-10 实测唯一仍下发真源 CDN 地址的入口,放首位。
"http://gz.ac16.vip",
"http://w.w345.my",
"http://desktok.top",
"http://mm.kk456.top",