Files
maidong-ktv/AGENTS.md
T

60 KiB
Raw Blame History

麦动 KTV — 项目开发约定

本文件供后续开发(含 AI 助手会话)遵循,优先级高于默认习惯。

一、代码同步与发布策略(2026-09-14 更新)

代码主仓库:Gitea https://gitea.kasugano.cn/zijunchiang/maidong-ktv

远端 地址 用途
origin https://gitea.kasugano.cn/zijunchiang/maidong-ktv.git ✅ 代码主仓库,唯一的代码同步目标
gitee https://gitee.com/zijunchiang/maidong-ktv.git 📦 不推代码,只用于发布 Release;仓库内只保留 README 文档 + Releases 附件

五条硬性规则

  1. 推送失败只重试 3 次:Gitea 不可用(网络不通/服务器故障/返回非 200)时,最多重试 3 次 (间隔约 60s)。3 次仍失败就停止重试,改动保留为本地 commit,留到下次一起同步 (不要无限重试、不要为了"推上去"而改推 Gitee)。恢复同步时直接 git push origin master 把积压的 commit 一并推上即可。

  2. Gitee 不再同步代码,只发布版本:Gitee 仓库保持「仅 README 文档 + Releases 附件」状态, 禁止推送源码(pre-push 钩子会拦截 gitee.com)。Gitee 上只做一件事:创建 Release 并上传 APK 附件。

  3. 版本号递增规则(2026-09-26 更新):versionName 形如 <major>.<minor>.<patch>, patch 取值 010、minor 取值 110,每次修改先用掉一个 patch:

    • 常规递增:1.5.0 → 1.5.1 → … → 1.5.10;
    • patch 到 10 后再改 → 进位到 minor、patch 归 0:1.4.10 → 1.5.0;
    • minor 到 10 后再进位 → major +1、minor 归 1、patch 归 0:1.10.1 → 2.1.0。 versionCode 始终同步 +1(单调递增,供系统与更新检查判断新旧)。 不要在同一个版本号下反复改动交付。
  4. 发布是手动操作:未收到明确的「发布新版本」指令时,默认只在本地打包测试—— 产物按内部测试约定命名 maidong-ktv-<版本>-<改动说明>-双ABI-release.apk, 不创建 Release、不上传附件、不推送 tag、不在 Gitee 建 Release。 只有收到发布指令后,才按「发布新版本」小节在 Gitea(以及需要的 Gitee)建 Release。

    ⚠️ 不要混淆"交付"与"发布":向 Gitea git push origin master 同步代码(规则 1)、 本地构建/签名出 *-release.apk(规则 4 前半段)都不算发布,不需要、也不得顺带 建 Release / 打 tag / 传附件。发布动作只有三个:建 Release、打 tag、上传附件; 三者的触发条件只有一个 —— 收到明确的人工发布指令。若不确定是否已发布, 用 GET /repos/{owner}/{repo}/releases 与 /tags 核对,不要凭"打过包"推断。

  5. 发布说明从简,详情留本地(2026-10 新增):Release 正文只列"更新了哪几点"—— 每条一行,写清"改了什么 / 用户能感知到什么";不写根因分析、不贴证据与 URL、 不写验证过程、不列日志片段。详细信息写在本地记录 /home/zijun/dsh_data/maidongktv/CHANGELOG.md (工作区根目录、仓库之外:既不随 Release 发布,也不提交进 Gitea)。 两者分工:

    • CHANGELOG.md = 每个版本"发生了什么"(根因/证据/验证/发布信息,按版本倒序);
    • AGENTS.md = 以后改代码"必须遵守什么"(约定与坑位),不重复 CHANGELOG 的过程叙述。 Release 正文推荐格式(示例):
    ## 麦动KTV V1.5.5
    - 新增歌曲源 a.ktv8.my
    - 歌曲源全部失效时提示「远程源已全部失效,等待新版本修复」
    - 修复:点歌失败后歌曲不出现在下载列表
    - 下载列表支持删除(有文件的删文件,失败任务移出列表)
    

    每次发布/打包后,把该版本写进 CHANGELOG.md(含 versionCode、md5、发布位置与校验结果), 这样 Release 保持简短、git log 保持可读,而"为什么这么改"永远查得到。

  • 凭据:
    • Gitea(代码主仓库):访问令牌 dsh-sync-20260926(2026-09-26 数据迁移后重建;可在 Gitea 「设置 → 应用 → 访问令牌」随时吊销),存放于 ~/.git-credentials-gitea(权限 600), 由按主机限定的仓库级 credential.https://gitea.kasugano.cn.helper 引用;
    • Gitee(仅发布 Release,按规则 2 不推代码):访问令牌存放于 ~/.git-credentials-gitee (权限 600),由全局 credential.https://gitee.com.helper 引用;只用于 API 建 Release / 传附件,不得用于推送源码。
    • 账号密码不写入仓库配置。
    • ⚠️ 两个 helper 都必须按主机限定作用域(credential.https://<host>.helper,不要用无主机限定的 credential.helper):git 认证成功后会调用每个 helper 的 store,无主机限定会把 Gitee 凭据 追加写进 Gitea 的凭据文件(一个文件两行),此后「取第一行」式的令牌提取会拿到错误令牌、API 报 401。
    • ⚠️ 提取令牌必须按主机过滤:用 sed -nE 's|https://[^:]+:([^@]+)@<host>.*|\1|p', 不要用不带主机锚点的 sed -E 's|https://[^:]+:([^@]+)@.*|\1|'(多行文件会输出多行、拼成错令牌)。

推送方式

cd /home/zijun/dsh_data/maidongktv/maidong-ktv
git push origin master              # origin = Gitea(唯一的代码同步目标)

Gitee 不参与代码同步;确需回推文档时用 git push --no-verify gitee master, 但按规则 2 正常情况下不应执行。

运行时端点(已全部迁移到 Gitea)

App 内部两处端点已指向 Gitea(随本版 APK 生效):

  • AppUpdateManager.kt(2026-10 起为多源,Gitee 优先、Gitea 兜底):
    • 检查更新:https://gitee.com/api/v5/repos/zijunchiang/maidong-ktv/releases/latest、 https://gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases/latest —— 两个源都查,取版本更高的那个(避免某一边滞后时漏更新);
    • 下载安装包:按「Gitee → Gitea」顺序尝试,一个失败自动换下一个(同版本两个地址互为镜像);
    • Gitee 的 Release API 无需令牌即可读取(已实测),附件直链为 302 跳转到 foruda.gitee.com CDN,instanceFollowRedirects=true 可正常跟随(实测 22MB / 约 12s / md5 一致);
    • ⚠️ 只认以 .apk 结尾的附件:Gitee 的 assets 里还会带自动生成的源码包 (v1.5.5.zip / v1.5.5.tar.gz),宽松判断会把源码包当安装包下载;
    • 下载校验三条:HTTP 2xx、>1MB、文件头 PK,再用 PackageManager 复核包名。
  • KtvJsBridge.kt:gitea.kasugano.cn/zijunchiang/maidong-ktv/raw/branch/master/app/src/main/assets/mobile/ktv_api.js(JS 热更新)

注意:Gitea 的 raw 规范路径是 /raw/branch/<分支>/<文件>;/raw/<分支>/<文件> 会 303 跳转。 Gitea 的 raw 与 release 下载对无 User-Agent 的请求同样放行(App 裸 socket 可直连)。

发布新版本(Gitea Release)

TOKEN=$(sed -nE 's|https://[^:]+:([^@]+)@gitea\.kasugano\.cn.*|\1|p' ~/.git-credentials-gitea)

# 1) 创建 Release(tag 需高于当前 versionName 才会提示更新)
curl -X POST -u "zijunchiang:$TOKEN" -H "Content-Type: application/json" \
  -d '{"tag_name":"v1.2.6","name":"麦动KTV V1.2.6","body":"更新说明","target_commitish":"master"}' \
  https://gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases

# 2) 上传 APK 附件(用返回的 release id;附件名必须 ASCII)
curl -X POST -u "zijunchiang:$TOKEN" \
  -F "attachment=@麦动KTV-V1.2.6-release.apk" \
  "https://gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases/<id>/assets?name=maidong-ktv-V1.2.6-release.apk"

⚠️ 附件文件名必须为 ASCII:Gitea 1.27 的规范下载路径 /releases/download/<tag>/<文件名> 对非 ASCII 文件名返回 404(/attachments/<uuid> 直链虽可用, 但 releases/latest API 返回的是规范路径)。因此 Gitea 上的附件用 maidong-ktv-V1.2.x-release.apk 这类 ASCII 名;本地留存的发布包仍可命名 麦动KTV-V1.2.x-release.apk。

上传 22MB APK 约需 70s,注意命令超时设置。

发布新版本(Gitee Release)

GT=$(sed -nE 's|https://[^:]+:([^@]+)@gitee\.com.*|\1|p' ~/.git-credentials-gitee)

# 1) 创建 Release(Gitee 会自动创建同名 tag)
curl -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" \
  -d '{"tag_name":"v1.4.0","name":"麦动KTV V1.4.0","body":"更新说明","target_commitish":"master"}' \
  https://gitee.com/api/v5/repos/zijunchiang/maidong-ktv/releases

# 2) 上传 APK 附件(表单字段名是 file,不是 attachment;用返回的 release id)
curl -X POST -H "Authorization: token $GT" \
  -F "file=@麦动KTV-V1.4.0-release.apk" \
  "https://gitee.com/api/v5/repos/zijunchiang/maidong-ktv/releases/<id>/attach_files"

Gitee 附件名可用中文(ASCII 限制只针对 Gitea 1.27),上传 22MB 约 4s。 发布后务必校验下载:curl -sL <browser_download_url> | md5sum 应与本地发布包一致 (Gitea 侧同样要校验 /releases/download/<tag>/<文件> 规范路径返回 200)。

二、构建与产物

构建(release,双 ABI)

cd /home/zijun/dsh_data/maidongktv/maidong-ktv
. /home/zijun/dsh_data/maidongktv/env.sh    # 导入 JAVA_HOME / ANDROID_HOME / PATH
./gradlew assembleRelease

产物:app/build/outputs/apk/release/app-release.apk(已由 app/build.gradle 的 signingConfig 签名)

本机工具链(2026-09-26 数据迁移后重建)

数据迁移后的本机是纯净环境且当前用户无 sudo,因此工具链全部装在用户态, 不要再用系统路径(/usr/lib/jvm/... 不存在)。统一入口:

. /home/zijun/dsh_data/maidongktv/env.sh    # 已加入 ~/.bashrc,新开 shell 自动生效
组件 路径 来源
JDK 17 /home/zijun/tools/jdk-17(Temurin 17.0.20.1) Adoptium tarball 解包
git /home/zijun/tools/bin/git(包装脚本 → /home/zijun/tools/gitroot/usr/bin/git) apt-get download git + dpkg -x
Android SDK /home/zijun/Android/Sdk(与 local.properties 的 sdk.dir 一致) cmdline-tools + sdkmanager
已装 SDK 包 platforms;android-35、build-tools;34.0.0、build-tools;36.0.0、platform-tools sdkmanager
签名密钥 /home/zijun/dsh_data/maidongktv/ktv_keystore.jks(仓库外,勿入库) 数据迁移
  • git 包装脚本是必需的:deb 内 git 的编译期 exec-path 写死为 /usr/lib/git-core (无 root 无法创建),包装脚本显式设置 GIT_EXEC_PATH/GIT_TEMPLATE_DIR 后才找得到 git-remote-https 等子命令。
  • 迁移会把仓库文件权限改成 744(git 记为 1288 个纯 mode change、0 行内容变化)。 遇到大量 mode change 100644 => 100755 时,按索引恢复权限即可,不要当成内容改动: git ls-files -s -z | ... chmod(或临时 git config core.fileMode false)。

签名与校验(单独签名/改名时)

BT=/home/zijun/Android/Sdk/build-tools/36.0.0
$BT/apksigner sign --ks /home/zijun/dsh_data/maidongktv/ktv_keystore.jks \
  --ks-key-alias ktv --ks-pass pass:123456 --key-pass pass:123456 \
  --out 目标名.apk app/build/outputs/apk/release/app-release.apk
$BT/apksigner verify 目标名.apk
  • 密钥:/home/zijun/dsh_data/maidongktv/ktv_keystore.jks(仓库外,勿入库)
  • APK 命名约定:麦动KTV-V<版本号>-release.apk(对外发布)/ maidong-ktv-<版本>-<改动说明>-双ABI-release.apk(内部测试)

版本号

  • app/build.gradle:versionName "1.6.0" / versionCode 43 (1.5.5/37 已发布;1.5.6 = 更新源加 Gitee、1.5.7 = 删除「歌曲源临时受限」弹窗、1.5.8 = 精简远程日志、1.5.9 = 按 HAR 对齐原版协议、1.5.10 = 源列表只留 zk.ktv1.top:6677、1.5.11(命名越界,已改) / 1.6.0 = whoami 心跳/设备身份/点歌推送/带 query 的地址闸门 + 并入 selfhost-source 支线,均尚未发布。18~21 曾被本机临时诊断包占用)
  • App 内显示统一用纯版本号(不加 (versionCode) 括号)
  • ⚠️ 本机 /tmp 是 tmpfs,单测必须绕开它:/tmp(5.6G tmpfs)长期被"已删除但仍被打开"的 文件占满,单测写临时文件会抛 IOException: 超出磁盘配额(EDQUOT),看起来像测试失败。 app/build.gradle 已统一把测试 JVM 的 java.io.tmpdir 指到 <repo>/build/test-tmp (/home 有 400G 空闲)—— 遇到"莫名其妙的测试失败"先看 df -h /tmp,不要去改测试逻辑。
  • ⚠️ 发布构建已开启 NewApi 闸门(lint { checkReleaseBuilds true; fatal += ['NewApi'] }): 任何「调用了高于 minSdk 21 且未做版本守卫的 API」都会让 assembleRelease 直接失败。 被阻断时按报错位置补版本判断或改用兼容写法,不要把 checkReleaseBuilds 改回 false 来绕过 —— 这道闸门专为「低版本设备一打开就闪退」而设(见第三节同名条目)

三、关键实现约束(避免踩坑)

  • ⚠️ 低版本设备闪退(API 守卫):minSdk 21,但实际客户里有 Android 5.1.1 的机器 (极米 H1S / MStar 6A928 / GMUI2.0)。以下三类写法会让 app 在 API 22 上一打开就闪退, 必须避免:
    1. 把高版本 API 的类写在 Activity/Fragment/object 的字段初始化器里 —— 字段初始化在构造期执行,早于 onCreate 里的 SDK_INT 判断,因此守卫形同虚设。 v1.4.0 的闪退根因就是 private val micDeviceCallback = object : android.media.AudioDeviceCallback() {...} (AudioDeviceCallback 是 API 23)→ 构造 MainActivity 时 NoClassDefFoundError。 正确写法:字段声明为可空、在 SDK_INT >= 23 分支里按需创建(见 ensureMicDeviceCallback())。
    2. Java 8 的集合默认方法:Map.getOrDefault / Map.putIfAbsent / ConcurrentHashMap.newKeySet / computeIfAbsent / Comparator.comparing / List.sort 等都是 API 24+, API 21~23 上抛 NoSuchMethodError。替代:getOrPut、 Collections.newSetFromMap(ConcurrentHashMap())、ConcurrentHashMap.putIfAbsent(API 1)、 Kotlin compareBy。
    3. 其它漏网的:URLConnection.getContentLengthLong()(API 24,用 Compat.kt 的 contentLengthCompat())、ByteArrayOutputStream.toString(Charset)(API 33)。
    • 自查命令(必须为 0 条):./gradlew :app:lintRelease 后 在 app/build/reports/lint-results-release.txt 里搜 NewApi。
    • 根因扫描捷径:grep -n "private val .* = object : android\." app/src/main/java/com/local/ktv/*.kt —— 字段初始化器里出现 android.* 的高版本类就是要闪退。
  • ⚠️ 存储根目录必须运行时解析(不要再写死 /storage/emulated/0): AppPaths 曾把根目录写死成 /storage/emulated/0/MaidongKTV,在极米 H1S(GMUI2.0 / Android 5.1.1) 上该路径不可写 → ensureDirectories() 的 check(it.exists() || it.mkdirs()) 抛异常 → App 一打开就闪退(这台机器历来所有版本都打不开的真正原因,2026-09-26 定位)。 现在的约定:
    1. AppPaths.init(context) 在 KtvApplication.attachBaseContext 最前面调用,按 「Environment.getExternalStorageDirectory() → 历史硬编码路径 → getExternalFilesDir() → filesDir」顺序挑第一个真正可建且可写的目录(幂等);
    2. 所有用到根路径的地方必须走 AppPaths.* 的 getter,绝不能用 const val 把路径编译期内联 (MuseDatabase.VIDEO_ROOT、MuseDatabase.defaultDbFile() 曾经就是 const val 内联的, 改存储根后必须一起改成运行时取值);
    3. 目录创建不许抛异常:AppPaths.ensureDirectories() 返回 Boolean,失败只记日志, MainActivity 拿到 false 也只是告警继续,绝不能让存储问题变成闪退;
    4. 用户可见的目录提示文案也要用 AppPaths.*Dir.absolutePath 拼,不要写死 /sdcard/...;
    5. 启动时会往远程日志打一行 STORAGE root=... by=... envState=... probe=[...], 换盒子/换 ROM 时先看这行就能确认存储根落在哪。
    • 自查:grep -rn "/storage/emulated\|/sdcard/MaidongKTV" app/src/main/java --include='*.kt' 除 AppPaths 的候选清单外不应再有硬编码。
  • ⚠️ 老系统(API < 24)的 TLS 根证书:*.cherryonline.cn(曲库同步 mconn / 点歌登录 mws) 与 gitea.kasugano.cn 都用 Let's Encrypt 新链(leaf ← YR2 ← Root YR ← ISRG Root X1)。 res/xml/network_security_config.xml 里的三个内置根证书只在 Android 7.0(API 24)+ 生效, Android 5.1.1 上被完全忽略、系统信任库又没有 ISRG 新根 → SSLHandshakeException: Trust anchor for certification path not found → 曲库登录失败 →「不能下载曲库」(这就是极米 H1S 上第二个卡点的根因,2026-09-26 定位)。 约定:
    1. TlsCompat.install(context)(在 KtvApplication.attachBaseContext)在 API<24 上把 「系统默认信任 ∪ 内置根证书」的复合 X509TrustManager 装到全局 HttpsURLConnection.setDefaultSSLSocketFactory,一次覆盖 App 内所有 HttpsURLConnection (曲库同步、JS 热更新下载、更新检查、头像下载)。主机名校验保持系统默认,绝不放行任意证书;
    2. WebView 的 TLS 信任无法用 SSLContext 覆盖,而 JS(ktv_api.js)会直连 https://mws.cherryonline.cn。因此在 KtvJsBridge 的 onReceivedSslError 里绝不盲目 proceed():先用 TlsCompat.isHostTrustedByOurStore(host) 对同一主机独立做一次 TLS 校验, 通过才放行,否则 cancel();
    3. 以后替换/新增内置根证书时,TlsCompat.BUNDLED_CERTS 与 network_security_config.xml 两处要同步改(一个管 API<24、一个管 API>=24);
    4. 曲库同步每一步失败都会 MiliLog.warn("MUSE-DB ..." / "DB-SYNC ...") 上报。 老设备上「不能下载曲库」先看远程日志这几行 —— 以前只写 logcat,界面只剩 「未找到可用曲库」兜底文案,排查极其困难。
  • ⚠️ 高频回调不能直接刷 UI(曲库下载 = 430MB):MuseDbSync.downloadFile 每读一个 256KB 分块回调一次进度,430MB 就是 约 1700 次。若每次回调都 main.post { 改文本 + 改进度 + bringToFront() },主线程 Looper 队列会被压满 —— 实测表现为界面卡死、进度停滞, 而且整机 CPU 被布局任务吃掉后下载本身也变慢(极米 H1S 上就是这个现象)。 约定:
    1. 进度回调必须按最终显示文案去重(MuseDbSync 侧按百分比去重、 showDatabaseDownloadProgress 侧按文本去重),刷新次数上限 ~100 次;
    2. showDatabaseLoading() 的更新路径只做真正变化的事:文本不同才 setText、 进度不同才 progress=、不要每次 bringToFront()(整棵树 requestLayout)、 不要每次重设 setOnClickListener;
    3. 解压(约 1GB)与 md5 校验(430MB)阶段也要报进度(回调带明细文案, 如「正在解压曲库 512MB」),否则界面会长时间停在某个百分比、看着像卡死;
    4. 下载速率会按每 10% 上报一次 MUSE-DB progress 30% 129MB/430MB 1.85MB/s, 判断"是网络慢还是磁盘慢"直接看这几行;
    5. 新增任何"周期性/高频"回调(进度、心跳、轮询)时都要按同样标准评估主线程开销。
  • ⚠️ 裸 Socket 通道(KtvJsBridge.rawRequest)的三个坑:点歌取址走的正是这条通道 (为了贴近原版风控要求的"干净请求"),它不受 HttpsURLConnection 默认工厂与 network_security_config 的任何影响:
    1. HTTPS 必须用 TlsCompat.sslSocketFactory(),不能用 SSLSocketFactory.getDefault()—— 后者是系统信任库,老设备(及 Android 7.0)没有 ISRG 新根,握手直接失败;
    2. 连接必须显式给超时:Socket(host, port) 的连接阶段不受 soTimeout 约束, 遇路由黑洞会按系统 TCP 超时(约 2 分钟)干等;多源叠加会耗光 JS 侧 JS_REQUEST_TIMEOUT_MS(180s) 预算,表现为**「无法生成下载地址」且日志里什么都没有**。 正确写法:Socket().connect(InetSocketAddress(host, port), connectTimeout);
    3. 失败必须上报:裸通道失败打 RAW-ERR、HttpURLConnection 回退失败打 NET-ERR 到远程日志。
  • JS 侧日志默认只进本地(2026-10 精简):ktv_api.js 的 _log() 走 console.log (桥以 debug:true 实例化),KtvJsBridge.wrapHtml() 注入 console 桥把它转发到 android.log → MiliLog.local("JS …")(只写本地文件,不上报远程)。 逐步过程(Try source / Token OK/FAIL / do.php code=)在本地 logs/ktv.log 里看; 远程日志只看一行汇总 点歌失败 编号=… 请求源=… 详情=…。
  • ⚠️ 自建歌曲源支线(selfhost-source/,2026-10-13 并入本仓库):自建歌曲源服务器(纯 Python 标准库,零 pip 依赖) 已从"App 仓库之外的工作区目录"并入本仓库统一版本化;inventory/*.csv 等生成物由 selfhost-source/build_inventory.py 依据 muse.db 重新生成,不入库。 App 侧对应模块:Whoami.kt(whoami 心跳)、DeviceIdentity.kt(设备身份)、PushDownload*.kt(点歌推送)。 规则照旧:支线不改动 App 主体逻辑,只通过上述独立模块接入。
  • 歌曲源:app/src/main/assets/mobile/ktv_api.js 的 CONFIG.HOSTS(多源自动排序,失败源沉底)
    • ⚠️ gz.ac16.vip 是"源站入口"而不是"广告主机"的典型例子(2026-10 实测): 该域名对任何 /<编号>.ts 都返回同一段广告视频,因此它留在 AdVideoFilter.AD_HOSTS 里(媒体层拦截,必须保留);但它的 i.php + music/do.php 与其他入口同一后台、同一协议, 会下发指向真源 CDN(txog.ktvsky.com / download.origjoy.com)的逐曲地址, 实测可下载且逐曲内容不同。因此已把它置于源列表首位 (ktv_api.js 的 CONFIG.HOSTS 与 SongUrlResolver.DEFAULT_HOSTS 各一处)。 判定原则:广告黑名单只作用于返回地址(媒体 URL)的 host, 不作用于"取址入口"的 host —— 两者不能混为一谈,否则会误杀唯一可用的源。
    • 该改动同时把 KTV_BRIDGE_API 由 9 提升到 10(ktv_api.js 两处标记 + KtvJsBridge 两处校验)。
    • a.ktv8.my(2026-10 加入,置于 gz.ac16.vip 之后):实测与 w345.my 同一台服务器 (154.219.102.181),GET / 是主机面板默认页,i.php 返回与其它入口逐字节一致的 170B 响应(code=200/end_date=2026-10-05/retry=5)→ 同一后台的兄弟入口; 它自身媒体路径(/<编号>.ts、/ad_files/…)均 404 → 不托管广告媒体。 ⚠️ 其 do.php 取址行为尚未定性(测试时本机出口 IP 配额已被消耗殆尽), 因此置于次位、由运行时自动升降序。
    • 该条改动把 KTV_BRIDGE_API 由 10 提升到 11(同样四处)。
  • ⚠️ 源列表只保留 zk.ktv1.top:6677(2026-10-09,按用户要求): 实测该入口与 e.ac19.cn 同一个后台 —— i.php 响应逐字段一致 ({"code":200,"end_date":"2026-10-05 14:58:21",...,"retry":5})、do.php 同款 code=200/msg=SUCCESS + 同一条真源 CDN 路径(txog.ktvsky.com/.../<编号>.ts), 且能连续两首返回文件名=编号的真源;其余入口要么只发广告/测试资源、要么整体 403。
    • 两处同步:ktv_api.js 的 CONFIG.HOSTS(含 HOST 默认值)与 SongUrlResolver.DEFAULT_HOSTS, 都只留 http://zk.ktv1.top:6677 一个;KTV_BRIDGE_API 由 11 → 12(同样四处)。
    • ⚠️ 该源没有 api.php(404):ensureConfig() 现在失败也写负缓存(configAt), 否则每首歌取址前都会白请求一次。
    • ⚠️ 它返回的是裸 IP:端口形式(zk.ktv1.top 在开发机解析到网关 fake-IP 198.18.18.178), 裸 socket 会正确使用 uri.port(6677)—— 以前只有域名源,改这块要注意别丢端口。
    • AdVideoFilter.AD_HOSTS(媒体层广告域名黑名单)不变:它管的是"返回地址"的 host, 与"取址入口"无关(见上文判定原则)。
  • ⚠️ 原版协议对齐(2026-10-08,依据 HAR 抓包实测):抓包(Reqable,e.ac19.cn)显示原版客户端是三步:
    1. POST /api.php:Content-Type: application/x-www-form-urlencoded, body action=get_config&device_id=<mac>,UA Dalvik/2.1.0 (Linux; U; Android <版本>; <型号> Build/<ID>) → 返回 {"status":"success","config":{…}},字段含 network_verification、dialog_title("请输入商家提供的正确卡密")、 trial_enabled、trial_times、trial_duration、heartbeat_interval、server_time;
    2. GET /i.php(参数与我们完全一致);
    3. GET /music/do.php(参数与我们完全一致)。
    • 我们原先完全没调 api.php ✗ —— 现由 SongUrlResolver.ensureConfig() 补上 (走 KtvJsBridge.rawCleanPost(),缓存 30 分钟;桥不可用则跳过)。
    • trial_times 用于限制一次取址尝试的源数量:实测额度 2 次 / 5 分钟, 原来 9 个源全扫会把额度瞬间打光,紧接着就一直被风控拒绝。
    • ⚠️ 风控拒绝的真实形态是「HTTP 200 + 业务码 403」: {"code":403,"msg":"请求被拒绝:检测到异常行为"} —— 不是 HTTP 403。 以前只按 HTTP 状态判断,这类响应被当成"没有地址",日志里看不出真因; 现在 code == 403 也置 sawForbidden(i.php 与 do.php 两处)。
    • 日志分工照旧:远程一条 SOURCE-CONFIG trial_enabled=… trial_times=… …; 原始 JSON 与"要求卡密"提示只写本地。
  • ⚠️ whoami 设备心跳(2026-10-11 抓包补齐,第三个请求):官方客户端启动后先 POST hbeat.ktvsky.com/whoami?<24 个字段>(空 body、Dalvik/2.1.0 … UA)登记设备身份, 再 i.php → do.php;响应 {"code":200,"next_interval":3600} 决定下次上报间隔。 约定:
    1. 字段构造/响应解析抽到 Whoami.kt(纯 JVM 逻辑,有单测 WhoamiTest), MainActivity.sendDeviceHeartbeat() 只负责填 Build.*、发请求、按 next_interval 重排 (_boot=1 只在启动那一次,之后为 0);
    2. _pkn 用设置项 KtvStore.whoamiPkn(默认 com.origjoy.local.ktv = Whoami.DEFAULT_PKN,单一来源);_mac/androidid/_cid 三者同源见「设备 MAC」;
    3. 必须在后台线程 + 8 秒超时,失败只写本地日志(MiliLog.localWarn("WHOAMI …")), 绝不影响取址/播放,也绝不阻塞启动;
    4. 服务端对应实现 selfhost-source/server/ktv/whoami.py(配置 whoami_*, GET /api/whoami / POST /api/whoami/send),字段口径与 App 保持一致;
    5. 参数拼接与取址协议一样逐字节对齐抓包(顺序、编码都别改); 回归证据在服务端 tests/test_basic.py::TestCanonicalSign(现网 URL 与 HAR 逐字节相同)。
  • ⚠️ 取址有两条实现,改协议必须同时改:JS 主路径(ktv_api.js)+ 原生兜底 (SongUrlResolver.kt,供 WebView 不可用的老盒子使用 —— 实测极米 H1S/GMUI2.0 上 WebView 导不出 window.KtvApi,桥永远 not ready,只靠 JS 时表现为 「无法生成下载地址」且远程日志里一条 JS ... 都没有)。
    • SongUrlResolver 镜像了 ktv_api.js 的 APP_ID/APP_KEY/SDK_KEY/HOSTS 与 桥注入的 VER=2.0/VN=4.1.3.03281430(注意不是 JS 文件里的原始值)、 以及 isDemoUrl 校验(wb66/demo/纯 IP/r2.dev/ts 编号与 musicno 不一致)。 源站配置若变更,JS 能热更新而它必须发版 —— 所以它只做兜底: SongApiClient.getSongDownloadUrl 在「桥未就绪 / JS 未取到地址 / 主线程调用」 三个回退点先试原生、再回退 DB 的 cloud_url。
    • 它必须走 KtvJsBridge.rawCleanGet()(与 JS 完全相同的裸 Socket 干净请求), 否则会被源站风控拒绝。
    • 自查远程日志:成功是 NATIVE-SONG 取址成功 编号=... 源=...,失败是 NATIVE-SONG 全部源失败 编号=... 详情=token-fail|no-url|demo-or-mismatch; 桥侧另有 JS-BRIDGE ...(ready / KtvApi 未导出 / WebView 初始化失败 / 15s 未就绪)。
  • JS 热更新:改动 ktv_api.js 必须同步提升 KTV_BRIDGE_API: N 标记(JS 与 KtvJsBridge.kt 两处校验),否则设备仍用缓存旧脚本
  • ⚠️ 广告/错号地址只有一道闸门:AdVideoFilter.isTrustedSongUrl(url, musicno) (2026-09-26 事故:新设备点歌 7303235 拿到 gz.ac16.vip 的广告视频并被当成歌曲下载播放)。 约定:
    1. 任何来源的播放地址都必须过这道闸门 —— JS 取址(ktv_api.js 的 isDemoUrl)、 原生兜底(SongUrlResolver.isValidSongUrl)、DB 回退(SongApiClient.fetchFromDb), 以及最终兜底 SongOkDownloadManager.buildDownloadUrl 的成功分支。 只加在某一层必然漏:这次就是 JS 层的规则不认广告域名、且 Kotlin 的成功分支完全没做校验;
    2. 判定三条:非 http(s);命中广告特征(路径 /ad_files/、my_ad_video、域名 *.ac16.vip); URL 里的 .ts 文件名必须等于点歌编号(真源恒为 /<musicno>.ts,已实测 txog.ktvsky.com / download.origjoy.com 两个 CDN 域)。旧规则只比对"纯数字文件名", my_ad_video.ts 这类非数字名字直接漏过; ⚠️ 2026-10-11 补充(带 query 的地址):媒体地址恒为 …/<编号>.ts?token=…&t=…, 因此判定必须先剥离 query/fragment:.ts 比对只看 path、广告特征只看 host + path (用 AdVideoFilter.hostOf()/pathOf(),java.net.URI 失败会手工兜底)。 旧实现在 #fragment 上会整条文件名规则失效(漏放广告),拿整串搜 wb66/demo 又会误杀 query 里的签名串("地址不可信"的隐蔽来源)。裸 IP / pub-*.r2.dev/ss/ 也已收进这道闸门 —— 四条路径不再各判一套。JS 侧同步改(splitUrl()), KTV_BRIDGE_API 已升到 13;回归用例见 AdVideoFilterTest(带 query/fragment 的 真源放行、广告仍拒)与服务端 tests/test_basic.py::TestQueryStringGate;
    3. 内容校验挡不住广告:广告视频能通过 SongFileValidator(≥6MB / ≥60s / TS 包完整), 所以"取址即校验"是唯一有效防线。一旦判定地址不可信,必须同时删除已落盘的歌曲文件 (purgeCachedSongFile),否则 isDownloaded() 会一直复用那个广告文件,广告反复播;
    4. 历史遗留由 SongOkDownloadManager.purgeInjectedAdDuplicatesOnce() 在升级后清一次 (marker:filesDir/ad_dup_purge_v1),只删「多首不同歌曲内容完全相同」的文件: 广告对每首歌都下发同一个视频,所以它的特征是"多首歌共用同一段视频"; 绝不能图省事直接清空整个歌曲目录(1.4.10 这么干过,见下条);
    5. 回归测试 app/src/test/java/com/local/ktv/AdVideoFilterTest.java 用日志里的真实 URL 形态 锁住行为(含两个真源 CDN 域),改这块逻辑必须先让它通过。
    • ⚠️ 文件名比对只在编号可信时启用:buildDownloadUrl 里只有"从 song.filename 推出"的 点歌编号才传给 isTrustedSongUrl;song.id 可能是数据库主键,拿它比对会把正常地址误杀 (表现为真源被判"文件名不符"→ 永远下不到歌)。
  • ⚠️ 本地歌曲缓存 = 公放曲库,绝不能整目录清空(2026-09-26 新设备回归): MuseDatabase.songDirectories() 只有 cloudSongsDir 一个目录, SongOkDownloadManager.getLocalFile() 也落在那里 —— 所以"清理歌曲缓存"就等于删掉公放曲库。 1.4.10 在启动时做过一次整目录清理,结果:库里没歌 → playRandomLocalSong() 选不出候选 → currentSong 保持 null → 按「切歌」时 advancePlayback 落进 「取消随机续播、进入空闲态」分支 → 按钮按了完全没反应(用户报「公放时切歌失效」)。 约定:任何"清理缓存"的改动都必须先确认它会删掉哪些目录;要清理就只删能证明有问题的 文件(见上条),永远不要动用户已下载的正常歌曲。
  • ⚠️ 主线程禁止网络 I/O:SongApiClient.getSongDownloadUrl 的主线程分支只允许走 本地 DB 兜底(fetchFromDb)。1.4.7 曾在该分支先调 resolveNatively() —— 原生兜底最多 7 个源 × 8 秒超时,一旦有主线程调用者就会卡死 UI 到 ANR。 取址(JS 桥 / 原生兜底)一律只允许在后台线程发起。
  • ⚠️ 裸 Socket 的 HTTPS 必须带主机名:用 TlsCompat.sslSocketFactory() 时必须 createSocket(plainSocket, host, port, true)(先连接、再按主机名包装), 不能用无参 createSocket() —— 后者拿到的 SSLSocket 不带主机名, 会被 SNI 与证书主机名校验影响(共享 CDN / 反代多证书时直接握手失败)。 这样既保住显式连接超时,又补上 SNI。
  • 曲库:只走官方 Muse 同步(MuseDbSync),不再依赖代码仓库数据库文件
  • 曲库同步时效:默认 30 天(DatabaseBootstrapper.SYNC_TTL_DAYS)。未到期启动不联网、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在 db_sync_at.txt,由 DatabaseBootstrapper.markSynced() 写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载)
  • 麦克风:MicActivator 常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建
  • 设备 MAC:统一由 DeviceMac 解析当前在用网卡的硬件地址(12 位小写十六进制)。优先 wlan*/eth*/en*,忽略虚拟/蜂窝/VPN 接口;Android 6 起 WifiInfo.getMacAddress() 恒为 02:00:00:00:00:00,因此直接读 NetworkInterface.getHardwareAddress();读出 02:00:00:00:00:00 或全 0 视为无效。读取失败回退 DeviceMac.FALLBACK_MAC(历史抓包值),保证身份永不为空。 结果按进程缓存,网络切换时由 remoteNetworkMonitor 调用 DeviceMac.refresh() 重解析。 消费方:KtvJsBridge.DEVICE_MAC(JS 端 mac)、whoami 心跳的 _mac/androidid/_cid (抓包时三者同值,保持一致)
  • ⚠️ 下载列表的两条约定(2026-10):
    1. 任务必须在"发起下载之前"登记(downloadSong() 开头就把 DownloadTask 放进 downloads, 状态「排队中」)。旧实现只在 onDownloadStart 里建任务,于是取址失败 (「无法生成下载地址」)这类还没真正开始下载的失败不留任何记录 —— 用户点歌失败后下载列表空空如也,既看不到原因也不能重试。 失败时由既有回调就地改成「失败:原因」,条目保留在列表里。
    2. 下载列表的「删除」按状态区分语义(renderSongList 的 onDelete 接线): 有本地文件 → 删除文件(confirmDeleteLocalFile,同时清下载记录); 失败/进行中(无文件)→ 移出下载列表(confirmRemoveDownloadEntry)。 「移出列表」只删记录 + stateDatabase.removeDownload,绝不动任何歌曲文件 —— 历史上"顺手清缓存"曾把整个公放曲库删光(1.4.10 事故)。 另外 SongListAdapter 的「更多」图标已从装饰改为可点/可聚焦(遥控器可达), 下载列表行尾的删除按钮一直都在。
  • 播放信息实时刷新:refreshNowPlayingLabels() 是播放界面左上角「当前播放 / 下一首」、首页播放条、 旧版播放区与顶部状态栏的唯一刷新入口,只在文本变化时写 View;已挂到 lyricTicker(500ms)上, 因此队列变化(本地点歌 / 扫码点歌 / 自动切歌)最迟 500ms 内必然同步。新增播放信息展示时请复用它, 不要另起刷新路径
  • 远程日志:接入 GlogCenter 集中式日志(HTTP 接口,单条 JSON)。域名属敏感信息,不得写入任何文档 (端点常量见代码 MiliLog.ENDPOINT,需要时查代码,勿在 README/AGENTS 中记录域名)。字段: system/date/text/servername/serverip/clientip/loglevel/traceid/user;date 必须为 yyyy-MM-dd HH:mm:ss.SSS,否则按时间检索异常。其中 serverip = 主机 IP(公网出口 IP, 由 MiliLog.PUBLIC_IP_URL 查询,结果缓存 5 分钟、失败 1 分钟后重试,查询在独立后台线程, 绝不在主线程发起),clientip = 客户端 IP(设备当前网卡获取到的局域网 IP,由 MiliLog.updateDeviceContext(ip = ...) 注入,网络变化时自动刷新并顺带复核公网 IP)。 客户端为 MiliLog(有界队列 + 后台线程 + 失败重试),调用点 MiliLog.info/notice/warn/error 保持不变。启动上报(reportDeviceBootInfo())只采集这 10 项:包名、版本号、安装时间、品牌、型号、 设备名、Android 版本、CPU 架构、内核版本、屏幕分辨率
    • 写日志的五条约定(2026-09-26 依据现场日志复盘补充):
      1. 持久状态只记一次,不要每次事件都记。反例:桥不可用曾按「每次点歌一条 WARN」输出, 一台机器一天几百条、把日志刷满且毫无新增信息。现在由 KtvJsBridge.markUnavailable() 在状态首次变化时记一条。
      2. 关键行必须带全上下文:STORAGE/BOOT 这类启动行要放在 reportDeviceBootInfo() 的 后台线程里、refreshLogDeviceContext() 与 MiliLog.awaitServerIp() 之后, 否则 serverip/clientip/user/traceid 全是 undefined(旧日志按设备检索不到)。
      3. 不要在日志行里做重活:AppPaths.storageFacts()/diagnose() 会真实建目录、写探针、 读 /proc/mounts,绝不能放在主线程(启动时曾如此)。
      4. 日志要能自我定位:失败行写清"哪一步/哪个源/耗时/原因",例如 NATIVE-SONG 全部源失败 编号=... 详情=token-fail|no-url|demo-or-mismatch 用时=...ms; 桥侧由 JS-BRIDGE ... 说明 WebView 到底卡在哪一步(初始化失败/KtvApi 未导出/20s 超时)。
      5. ⚠️ 远程日志只留"每次事件一条"的汇总,细节走本地(2026-10 精简): MiliLog.local(msg) / localWarn / localError 只写本地文件、不上报远程。 判定标准——逐源 / 逐次重试 / 单次 HTTP 错误 / JS 控制台输出一律用 local*; 远程只保留"这件事的结论":
        • 取址:成功一条 NATIVE-SONG 取址成功 编号=… 源=… urlHost=… 用时=…ms, 失败一条 NATIVE-SONG 全部源失败 编号=… 详情=… 用时=…ms, 全部只发广告一条 SOURCE-BLOCKED 全部 N 个源只返回广告/测试资源… → 每次取址远程恰好一条(改前是每个源一条,9 个源就是 9+ 条);
        • JS …(ktv_api.js 的 console 转发)只进本地,不再进远程; JS 路径失败仍由远程的 点歌失败 编号=… 请求源=… 详情=… 一行说明原因。 ⚠️ 新加日志时先问自己:"这条是'结论'还是'过程'?" —— 过程写本地。
      6. 状态样本 + 事件缺一不可:事件(点歌/失败)说明"发生了什么",但排查"随时间变化"的 问题(空间变少、曲库丢失、长期离线)需要状态样本 —— 现由 6 小时一条 HEALTH ... (版本/在线/可用空间/曲库大小/根目录)提供。
    • ⚠️ 远程日志绝不能静默失效(2026-10 现场反馈"手动更新曲库后远程日志失效了"):
      1. MiliLog.init() 可以重复调用并会自动重启发送线程(旧实现是 if (started.getAndSet(true)) return,一旦线程退出就永久不再发送,且无从察觉);
      2. sendLoop 整体包了 catch (Throwable) 兜底 —— 任何异常都不允许让发送线程退出;
      3. 本地留档(纯文本):每行同时写入 <存储根>/logs/ktv.log (格式 yyyy-MM-dd HH:mm:ss.SSS LEVEL text,单行上限 4000 字,超 1MB 滚动为 .prev)。 远程日志不可用时,让现场直接把该文件发出来即可取证;启动时会打一行 APP-INIT ... logFile=<路径> 指明位置;
      4. MiliLog.init 已提到 KtvApplication.onCreate(早于 MainActivity), 这样即使更早的初始化抛异常,也已经有日志可查。
    • ⚠️ 取址失败只有一句用户可见提示(2026-10 用户要求,后经调整): Eac19WindowGuard.onAllFailed ← noteAllSourcesFailed(), 由 SongUrlResolver.resolve() 在本轮彻底失败时调用(403 / 只发广告 / 网络异常都算), 弹窗文案固定为 「远程源已全部失效,等待新版本修复」,按 15 分钟节流。 ⚠️ 原先的「歌曲源临时受限…请重启光猫换 IP」弹窗(onBlocked)已删除 —— 风控 403 现在只由 noteRiskBlocked() 写日志(SOURCE-BLOCKED 源站风控 403…),不再有第二个框。 以前这里只有一句"无法生成下载地址"的 toast,用户看着像界面没反应。
    • 一段典型现场日志应为:BOOT → STORAGE → (点歌)点歌编号=... 请求源=... 状态=成功 urlHost=... → PLAY ... src=local → 每 6h HEALTH ...;异常时才有 RAW-ERR/NET-ERR/NATIVE-SONG ...失败。
  • 网络状态与断网恢复:KtvNetworkMonitor(object,走 ConnectivityManager 回调、不轮询) 维护全局 online;MainActivity.onNetworkStateChanged() 负责:
    • 断网 → 播放页右上角 fullScreenNetworkBadge 显示「网络未连接」(在全屏控制层 header 内、 标题右侧,不遮挡右上角二维码);
    • 恢复 → refreshNetworkState():重解析网卡 MAC + 日志上下文、重建歌曲源会话、 复位 Eac19WindowGuard、重启本地点歌服务并刷新二维码、自动重试断网期间失败的下载(最多 8 首)。 换网/换 IP 时 remoteNetworkMonitor 走同一个 refreshNetworkState()(静默,不弹提示)。
    • ⚠️ 启动 ≠ 网络恢复:KtvNetworkMonitor.start() 的回调带 initial 参数, initial=true 表示"注册回调时系统回报的初始状态"。绝不能把它当成"断网后恢复"—— 启动流程(onCreate)自己已经做过 startRemoteServer()/二维码/设备上下文, 再走一遍恢复逻辑会:白清刚建立的歌曲源会话、语义错误地 noteUrlOk()(掩盖真正的 窗口到期提示)、把状态栏错写成"网络已恢复"(启动瞬间就显示,用户可见)。 初始状态只更新断网角标,恢复动作只在真正的 offline→online 跃迁时执行 (2026-09-26 由现场日志 NET-UP 出现在 BOOT 之前而发现)。
  • ⚠️ 歌曲源「授权窗口失效」的两种形态(都要走 Eac19WindowGuard): 额度记在出口 IP 上、与设备无关(实测换 mac/sn 无效)。
    1. 旧形态:do.php 返回 403「检测到异常行为」;
    2. 新形态(2026-10 实测):do.php 返回 200,但对每一首歌(含不存在的编号如 1111111) 都返回同一个广告/测试资源 —— 实测 https://wb66.cn/f/d/7b7c9/10188555.ts。 这种"看似成功"的形态不触发任何旧提示,用户只看到「无法生成下载地址」, 表现就是**「歌曲源全部失效」。i.php 仍返回 code=200 / end_date=未来 / ordertype=0, mwsLogin 也返回 authorized:true(但 license_box_count=0/license_duration=0), 所以别把它们当成"授权正常"**。
    • 代码侧已处理:SongUrlResolver 统计"全部源只返回不可信地址"→ 打 SOURCE-BLOCKED 全部 N 个源只返回广告/测试资源(该出口 IP 授权窗口失效)示例=<url> 并调用 Eac19WindowGuard.noteRiskBlocked()(只写日志;其弹窗已删除), 同时进入 3 分钟冷却(BLOCKED_COOLDOWN_MS)避免继续冲击源站;拿到真源立即解除。
    • 现场处置:更换公网出口 IP(重启光猫/路由器重拨)即恢复;已下载的歌曲不受影响照常播。
    • 403 也是受限信号:2026-10 实测主源 w.w345.my/i.php 直接返回 HTTP 403、 镜像源返回 200 但只给固定广告文件 —— 两种形态会同时出现。因此 KtvJsBridge.request 的 403 判定已从"仅 /music/do.php"扩展到 /i.php 也认, SongUrlResolver 也记录本轮是否见过 403(全部失败且见过 403 → 同样提示换 IP)。
    • ⚠️ 更正(2026-10-08):以前以为"api.php 已 404",其实是请求方式错了 —— 它必须用 POST 表单(action=get_config&device_id=<mac>),用 i.php 那种签名 GET 才会 404。 原版客户端取址前会先调它拿配置(见下面"原版协议对齐"条目)。
    • 若换公网 IP 后仍只发广告,基本可判定为服务/授权层面问题(i.php 的 ordertype=0、mwsLogin 的 license_box_count=0/license_duration=0 都指向"无有效授权", 且 api.php 明示 network_verification=true + dialog_title="请输入商家提供的正确卡密"), 需与歌曲源服务方确认续费/换卡密,不是客户端能修的。
    • ⚠️ 排查这类问题时不要用生产身份/出口 IP 做密集探测:源站按出口 IP 限流, 短时间内大量 i.php/do.php(多 host × 多参数组合)会加重受限, 把"能用"变成"只发广告"。少量、间隔式验证即可。 ⚠️ 「断网重连后再也下载不了、日志却显示歌曲源获取成功」的根因: SongApiClient.clearTokenCache() 原本是空实现,JS 侧 window.api.tokens 与 _apiInitPromise 会被一直复用;而歌曲源按「出口 IP + device」识别、试用窗口也记在出口 IP 上, 断网/重连(尤其换出口 IP)后旧 token 已失效 —— 取址这一步看起来成功,真正下载却 403/失败。 现在 clearTokenCache() 会真正下发 KtvJsBridge.resetApiSession() (置空 window.api 与 _apiInitPromise,下次取址重新构造 API 实例、回到默认源顺序并重新登录)。 下载层的网络类失败由 SongOkDownloadManager 结合 KtvNetworkMonitor.online 与异常类型/HTTP 0 判定并记入 networkFailed,网络恢复后由 retryNetworkFailed() 自动重试(手动取消的不入集合)。 ⚠️ 顺带修掉一个隐患:离线时 localIp() 为空,原 remoteNetworkMonitor 会每 5s 误判「IP 变化」 而反复重启本地点歌服务(并连带触发刷新);现已加空值短路,离线只等网络回调。
  • 播放界面右上角二维码:跟随全屏控制层一起出现/隐藏 —— 在 setFullScreenChromeVisible() 里同步 fullScreenQrOverlay 的可见性,updateFullScreenQrOverlay() 新建时按 fullScreenChromeVisible 初始化。不要再按「手机是否在点歌」自动隐藏 (原 qrAutoHideMonitor 已删除,扫码弹窗里的对应提示文案也一并删除)
  • 远程页面滑杆(触控):<input type="range"> 绝不能被 <label> 包裹 —— label 会把 内部任意位置的点击转发给 range,表现为"点轨道/点文字就跳值"。行容器用 <div class="audio-row"> / <div class="eq-band">;CSS 选择器也随之用类选择器而不是标签选择器。 另外滑杆元素本身高度设 32px(触控目标够大),视觉细轨道由 ::-webkit-slider-runnable-track / ::-moz-range-track 绘制,滑块 22px + 5px 边框。 attachThumbOnlyDrag() 仍作为第二道防线(命中滑块才放行原生拖动,容差按 32px 滑块算)
  • 远程搜索性能:曲库 DB 有 67 万首 / 约 1GB(muse.db),搜索是 I/O 密集操作。 实测(同一台机器直连该 DB,rec_score 复合索引齐全):
    • 中文 name_trim LIKE '%kw%':热态 2~280ms(走 idx_rec_score 顺序扫描 + 早停,无临时排序)
    • 拼音 name_cap/name_full GLOB 'kw*':热态 0.2~150ms(前缀范围扫描 + 临时 B-tree 排序; A* 这类高频前缀会扫很多行),冷态(首次读 1GB 文件)可达 1~15s
    • 歌手检索(EXISTS + singers LIKE):热态 34~70ms 结论与对策:瓶颈在冷态 I/O 与重复查询,因此
    1. 设备端加 remoteSearchCache(LRU 24 条 = 最近 4 个查询 × 6 页,TTL 60s,曲库刷新时清空), 同一「类型+关键词+页码」直接复用 JSON,翻页与重复搜索不再查库;
    2. 手机端:搜索请求用 AbortController 取消旧请求、同关键词不重复请求、 首页返回后预热第 2 页(把设备缓存捂热)、搜索期间暂停 1s 的状态轮询避免抢 I/O。 ⚠️ 不要试图用「一次多取几页」来省请求:实测 LIMIT 201 的代价远高于 LIMIT 21 (name_full GLOB 301 行 6.2s vs 21 行 0.7s,冷态),会把体验做得更差
  • 焦点填充与「纯色遮罩层」:TvFocusStyler.install() 会把背景整体换成不透明红→橙渐变, 作为遥控器焦点/按下的反馈(prepareTvFocusableTree(window.decorView) 与对话框的 installTree(decorView) 会遍历整棵树施加)。按钮/Tab/卡片/list 行需要这个反馈;但 大面积展示型表面绝不能加 —— 否则触摸一次(state_pressed)就用纯色盖住整屏内容。 已排除的表面:播放画面宿主(home_player_container / player_container)、视频层 (persistentVideo)、播放触摸层(playerTouchOverlay)、全屏播放容器(R.id.container)、 启动加载遮罩(databaseLoadingOverlay)、手机点歌二维码浮层、全屏音量面板。 新增此类铺满/覆盖播放画面的 View 时,必须调用 TvFocusStyler.suppressFill(view)
  • 音量默认值:KtvStore.DEFAULT_MUSIC_VOLUME(当前 85%)是唯一来源, KtvStore 默认值与 optInt 兜底、MainActivity.musicVolume 初值、调音弹窗「默认」按钮 都引用它;远程页面 assets/mobile/{index.html,app.js} 的默认值需手工跟改
  • 音量控制(只控 App 输出,不动系统音量):musicVolume(0-100) 是 App 内部输出音量, 统一在 applyPlaybackMode() 内按线性系数施加到播放器(KtvVideoView.setPlaybackVolume / selectAudioChannel)与副音轨播放器(vocalPlayer / embeddedVocalEngine)。 严禁调用 AudioManager.setStreamVolume / adjustStreamVolume 修改系统音量。 入口只有两处,且都必须收敛到 applyPlaybackMode():「调音」弹窗(showTuningDialog(), 播放界面与主界面右上角共用同一个弹窗)的音量横向控制杆、远程控制-音乐音量 (remoteAudio() 的 musicVolume)。声音设置弹窗里不再有音量滑杆与均衡器入口 (避免同一功能两套入口),它只保留「切到点播歌曲时音量/原伴唱」这类策略选择。曲线是纯衰减(100% 即 unity gain),不做系统级增益; 设备最终响度仍取决于系统音量,App 不再替用户改动它
  • 音频均衡器:内核 KtvEqualizer(参考开源项目 OpenEQ,Turbofan3360/OpenEQ,GPL-3.0 的 EQUtils.kt)——直接用系统 android.media.audiofx.Equalizer,按设备真实频段数与增益范围自适应 (频段是动态生成的,不要写死 5 段)。默认把 EQ 挂到实际播放会话: player.audioSessionId() / vocalPlayer.audioSessionId / embeddedVocalEngine.audioSessionId() (IJK 的原生 IjkMediaPlayer_getAudioSessionId 返回真实 AudioTrack 会话)。会话会随歌曲变化, 因此由 500ms 的 lyricTicker → syncEqualizerSessions() 周期对齐,消失的会话延迟 3s 释放。 globalMix(会话 0)是设备不支持按会话挂载时的显式开关,会影响本机所有声音,默认关闭。 均衡器已并入**「调音」弹窗**(showTuningDialog(),自上而下:音量横向杆 → 均衡器开关 → 预设 → EQ 频段横向杆,每个频段一行:频率 | 滑杆 | 增益),主界面右上角与播放界面「调音」 是同一个弹窗;另有远程点歌页面「均衡器」卡片(PATCH /api/v1/player/equalizer,状态随 GET /api/v1/state 的 equalizer 字段下发)。预设:App 内为「自定义/流行/摇滚/舞曲」 (appPresetNames(),手动改频段即转「自定义」并自动保存),远程页面额外保留「标准」 供「恢复标准」使用。设置持久化在 KtvStore.eq*; 刻度固定 30 档(eqSliderSteps),步进由设备增益范围均分,保证 0 dB 一定可选中。 音量与频段共用自绘控件 EqSlider(横向),已 suppressFill 并自绘焦点/调节描边。 ⚠️ 它的交互是刻意定制的,不要换回 SeekBar:
    • 遥控:焦点在控件上时方向键不改数值(先让焦点在控件间移动);按确认键进入调节态 (描边变亮变粗)后方向键才调值,再按确认键退出调节态;调节态下 BACK 只退出调节态、 不关弹窗;失焦自动退出。这是"需按确认键才可调节控件点、同理按确认键退出控制"的实现。
    • 触控:必须按住滑块(控件点)才拖动,点轨道空白不跳值(hitsThumb 命中判断, 容差 thumbTouchSlop)。远程点歌页面同规则,见 attachThumbOnlyDrag()(app.js,对 input[type=range] 做指针命中判断,未命中则 preventDefault)
  • 公放随机清单:RandomPlayQueue(纯内存单例)——随机续播不再「每次现随机挑一首」, 而是一次性生成打乱顺序的临时清单,一轮内不重复;播完一首 markFinished +1; next() 每次都重新比对曲库,自动吸纳新增歌曲、剔除已删除歌曲;一轮播完自动开新一轮。 不显示(界面不展示)、不关联已点清单(orderQueue 完全独立,随机歌曲不入队)、 App 退出即清理(onDestroy 里 isFinishing 时 clear(),不落盘)
  • ⚠️ 切歌/续播的状态机(MainActivity.advancePlayback):orderQueue 是"已点清单", 正在播的那首仍在队列里,切歌时才移除。四条出口,改动前先对齐语义:
    1. 显式切歌且队列还有歌 → 播下一首;
    2. 自然播完 + autoNext + 队列已空 → 进公放(随机本地歌曲);
    3. 显式切歌且队列已空(= 在点歌清单最后一首上按切歌)→ 进公放。 这是 2026-09-26 用户明确要求的行为:公放清单与点歌清单互不影响 (随机歌曲不入 orderQueue;用户一点歌,挂起的随机请求就会被 orderQueue.isNotEmpty() 守卫取消);
    4. 手动模式(autoNext=false)自然播完 → 停在空闲态等待点歌(保持旧逻辑)。 ⚠️ 凡是要清空 currentSong/播放状态的出口,都必须同时停播放器 (player.stopPlayback() + currentMediaPlayer = null,参考「清空已点」处理)。 原因:onCompletion 第一行是 if (generation != playbackGeneration || currentSong?.equals(song) != true) return, 所以只要出现"播放器还在跑、currentSong 已被置空",那首歌播完时回调被直接吞掉: 不再推进、状态却是"无歌可播",之后按切歌/播放都没有反应 —— 2026-09-26「点歌最后一首按切歌 → 播完卡死」就是这个僵尸态。 唯一例外是"马上要 play() 下一首"的路径(可以不停,避免切歌瞬间静音)。
  • ⚠️ 「切歌 → 公放」在老盒子上卡死(2026-09-26,极米 H1S):新设备正常、老设备卡死, 排查出三个叠加原因,都已修掉,改动这块时必须同时守住:
    1. 不能共用 io 线程池:io 只有 4 个线程,且"点歌即下即播"(streamDownloadAndPlay) 这类几十秒的长任务也在里面。老设备网速慢,并发点几首歌就把池占满,切歌任务一直排队。 → 切歌/随机续播改用 randomPlayExecutor(专用单线程守护线程)。
    2. 不能现扫目录找候选:候选来自 localSongInventory() + library.scanLocal(), 代价是"每个文件在头尾各读约 4.3MB 做 TS 校验" + "遍历 /storage/ 各挂载点 (老 ROM 的失效挂载点会让 File.listFiles() 永久阻塞)"。 → 改为启动时后台预热候选缓存(primeRandomCandidates():独立守护线程 + scanWithTimeout 超时保护),下载完成即时补进缓存,切歌直接读缓存(零 I/O)。
    3. 主线程不能做深度文件校验:Song.hasLocalFile() 内部是 SongFileValidator.inspect (头尾各读 4.3MB)。它曾被用在 play()、播放错误回调、switchToLocalPlayback, 以及逐行渲染的 songRowState() 里 —— 慢存储上每次都是秒级,直接 ANR。 → 播放/渲染路径统一改用廉价检查 MainActivity.hasPlayableLocalFile() (只看存在与大小),深度校验只留在后台(localSongInventory() / isDownloaded())。
  • ⚠️ Kotlin 块注释会嵌套,注释里不能出现 /*:写 `/storage/*` 时那个 /* 会开启一个 嵌套注释,导致 KDoc 永远不闭合,后面的代码全被吞进注释里(报错是 "Unclosed comment" + 一堆 "Missing '}'",很难一眼看出)。注释里要写路径通配就写 /storage/ 各挂载点 这种不含 /* 的说法。
  • 弹窗分辨率自适应:TV/投影/盒子的分辨率与 density 差异很大(1280x720、1920x1080, density 1.0~2.5),固定 dp 尺寸在矮屏上会被屏幕裁切。统一用 fitDialogToScreen(dialog, scroll, content, preferredWidthDp, …):宽度取 min(preferredWidthDp, 屏幕宽*0.94),高度把内容所在的 ScrollView 限制为 屏幕高*0.92 - 弹窗标题/按钮框架(chromeDp) —— 不要再用 WRAP_CONTENT 作为 弹窗高度。内容高度由「屏幕预算 - 固定区高度」反推(调音弹窗的 EQ 频段区就是这样自适应), 预算不足时弹窗内滚动而不是被裁掉。已应用:调音弹窗、扫码点歌弹窗、声音设置弹窗。 新增/改动弹窗时请复用该 helper,别再写死 setLayout(w, WRAP_CONTENT)。 ⚠️ 文字行必须 wrap_content:label() 自带 6dp 上下内边距,固定矮行(曾用 18/28/42dp) 会把文字裁掉 —— 这是"文字显示不全"的根因。做法是「文字行 wrap_content + 只有无文字区域 才用计算高度」:调音弹窗里唯一可伸缩的是 EQ 频段区,采用两遍 fit(先 panel.measure() 实测内容高度,超出预算就把频段区减掉,再调 fitDialogToScreen),这样收缩的永远是频段区 而不是文字;频段区下限 dp(96),再放不下才滚动。大字体(系统字体缩放)同理自动兜住
  • 仓库可见性:Gitea 仓库保持 public(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403)