版本号:
- 1.5.11 越界(规则 3 规定 patch ≤ 10)→ 修正为 1.6.0(进位到 minor、patch 归 0)
- versionCode 43 不变:43 从未对外发布,本次修正只是把命名拉回约定
合并支线(原在工作区根目录、App 仓库之外,DESIGN.md 自称"支线"):
- selfhost-source/server/ 自建歌曲源服务器(纯 Python 标准库、零 pip 依赖)
- selfhost-source/deploy/、DESIGN.md、LIBRARY.md、build_inventory.py
- selfhost-source/inventory/ 小清单(playlists.csv、song-stats.md 等)
- ⚠️ inventory 生成物(songs-*.csv / singers-top.csv,共 84MB)不入库,
已写入 .gitignore;需要时用 build_inventory.py 依据 muse.db 重新生成
同时入库的 App 侧支线改动(此前为工作树未提交状态):
- Whoami.kt(whoami 设备心跳)、DeviceIdentity.kt(设备身份伪装)
- PushDownload.kt / PushDownloadClient.kt(点歌推送)
- AdVideoFilter 带 query/fragment 的地址闸门;KTV_BRIDGE_API 11 → 13
- 修 ktv_api.js 的 global.KtvBridgeApiVersion(12 → 13,与注释及 Kotlin 两处校验一致)
- 对应单测:WhoamiTest / DeviceIdentityTest / PushDownloadTest / AdVideoFilterTest(共 39 例)
文档:
- DESIGN.md 的"在 App 仓库之外"约定改为"已并入仓库统一版本化"
- AGENTS.md 版本号行更新为 1.6.0/43,并新增支线并入说明
验证:assembleRelease + testReleaseUnitTest 全过(39 例)
17 KiB
自建歌曲源服务器 — 接入分析与选型方案
支线约定(2026-10-13 调整):本文档属于"自建歌曲源"支线,不修改本体 App 应用代码 (App 侧只通过
Whoami/DeviceIdentity/PushDownload*这些独立模块接入,互不干涉)。 产物位于selfhost-source/—— 原先是"工作区根目录、App 仓库之外", 现已并入 App 仓库统一管理(按用户要求"合拼支线改动"),随主线一起版本化;inventory/*.csv等生成物不入库(.gitignore已忽略,用build_inventory.py重新生成)。依据:对
maidong-ktv仓库现有实现逐行核对(SongApiClient/SongUrlResolver/KtvJsBridge/ktv_api.js/AdVideoFilter/SongFileValidator/SongOkDownloadManager), 以及 2026-10 对真实源站(e.ac19.cn、zk.ktv1.top:6677、124.223.20.123:58)与 HAR 抓包的实测。
1. 任务 1:App 取址流程分析与最佳插入点
1.1 App 侧取址的三条链路
用户点歌
└─ MainActivity.downloadSong()
└─ SongApiClient.getSongDownloadUrl(musicno)
├─(a) JS 主路径(新设备,WebView 正常)
│ ktv_api.js KtvApi
│ getToken(host): GET {host}/i.php?…&sign=md5(params+APP_KEY) → data.token
│ getUrl: GET {host}/music/do.php?…&ls=1&sign=md5(params+SDK_KEY) → data
│ isDemoUrl(url, musicno) 复核 → 不通过则 sleep 3s 重试一次 → 再失败换 host
├─(b) 原生兜底(老盒子:WebView 导不出 KtvApi,如极米 H1S)
│ SongUrlResolver.resolve()
│ ensureConfig(): POST {host}/api.php action=get_config&device_id=<mac>(缓存 30min)
│ for host in HOSTS: i.php → token → do.php → url → isValidSongUrl(url, musicno)
└─(c) DB 兜底:cloud_url(最后手段)
关键结论:三条链路的最终地址都要过同一套闸门 ——
AdVideoFilter.isTrustedSongUrl + isDemoUrl / isValidSongUrl。
所以"自建服务器能不能被 App 采纳",只取决于返回的媒体 URL 是否满足闸门,
与服务器实现语言、部署方式无关。
1.2 四种插入方式对比
| 方式 | 改哪里 | 覆盖设备 | 需要发版? | 评价 |
|---|---|---|---|---|
| A. 改 JS 源列表 | 仓库 app/src/main/assets/mobile/ktv_api.js 的 CONFIG.HOSTS |
新设备(JS 路径) | 不需要 —— 走 JS 热更新(Gitea raw)✓ | ⭐ 首选,最快见效 |
| B. 改原生源列表 | SongUrlResolver.DEFAULT_HOSTS(Kotlin) |
老盒子(原生兜底) | 需要发版 ✗ | 必须与 A 配套 |
| C. 伪装/接管现有源域名 | 不动 App | 全部 | 不需要 | ✗ 不推荐:要控制设备 DNS,脆弱且影响面不清 |
| D. 让自建源顶替某个现有域名 | 不动 App | 全部 | 不需要 | ✗ 不可控:域名不在自己手里 |
推荐:A + B 组合。 先做 A(改一行 JS,热更新当天生效,新设备立刻指向自建源); 下次发版时把 B 一起改成自建源,老盒子(H1S/GMUI2.0)也能用自建源。 回滚成本极低:JS 热更新改回来即可,无需发版。
A 的实施注意:改 ktv_api.js 必须提升 KTV_BRIDGE_API 标记(当前 12,改后 13),
并同步 KtvJsBridge.kt 的两处校验,否则设备会继续用缓存里的旧脚本。
1.3 服务器必须满足的硬约束(逐条来自 App 代码)
① 三个端点
| 端点 | 方法 | 是否必需 | 说明 |
|---|---|---|---|
/api.php |
POST 表单 action=get_config&device_id=<mac> |
建议实现 | App 用它取配置(trial_times 等);不实现也能跑(App 会记一条本地日志并继续),但实现后可控 |
/i.php |
GET(签名参数) | 必需 | 返回 token |
/music/do.php |
GET(签名参数) | 必需 | 返回媒体地址 |
| 媒体文件 | GET | 必需 | 真实 TS 文件 |
② 返回的媒体 URL:这是唯一的"生死线"
来自 ktv_api.js 的 isDemoUrl() 与 SongUrlResolver.isValidSongUrl()(两处规则一致):
| 规则 | 结论 |
|---|---|
含 wb66 / demo |
❌ 拒收 |
域名命中 *.ac16.vip、路径含 /ad_files/、my_ad_video |
❌ 拒收 |
host 是裸 IP(http://1.2.3.4[:port]/…) |
❌ 拒收 → 媒体 URL 必须是域名 ✓ |
pub-*.r2.dev/ss/ |
❌ 拒收 |
.ts 文件名与点歌编号不一致 |
❌ 拒收 |
→ 自建服务器返回的媒体地址必须是:
http://<你自己的域名>[:端口]/<任意路径>/<musicno>.ts
例如 http://ktv.example.com/media/7009126.ts ✓
⚠️ 注意区分两类 host:
- 取址入口(
CONFIG.HOSTS里的 host)不校验,可以是裸IP:端口(如124.223.20.123:58实测可用);- 返回的媒体 URL 必须是域名。 所以"入口用 IP、媒体用域名"是完全可行的组合。
③ 媒体内容校验(SongFileValidator)
- 文件 ≥ 6 MiB
- 时长 ≥ 60 秒
- TS 结构(188 字节包;下载器会读头尾各约 4.3 MiB 做校验)
→ 必须是真 TS 流;把 mp4 改名
.ts会被判无效。
④ 下载协议(SongOkDownloadManager 用 OkDownload)
- 支持 Range / 206(断点续传,
downloadFromBreakpoint) - 返回
Content-Length(进度显示依赖) - 允许
GET;即下即播(streamDownloadAndPlay)是顺序流式读取 → nginx 静态服务天然全部满足 ✓
⑤ 响应格式
// GET /i.php → App 只读 code / token(其余字段不影响功能)
{"code":200,"msg":"ok","token":"<任意字符串>","end_date":"2030-01-01 00:00:00","retry":5,"ordertype":0}
// GET /music/do.php?musicno=7009126
{"code":200,"msg":"SUCCESS","data":"http://ktv.example.com/media/7009126.ts","limit":2000}
// POST /api.php (action=get_config&device_id=<mac>)
{"status":"success","config":{"network_verification":false,"buy_enabled":false,
"heartbeat_interval":3600,"trial_enabled":false,"trial_times":0,"trial_duration":0,
"dialog_title":""}}
do.php的code != 200→ App 记为"没有地址"(本曲失败)- ⚠️ 不要用 403 表达"这首歌不存在":HTTP 403 或业务码 403 会被 App 当作源站风控
(写
SOURCE-BLOCKED日志,并可能触发"源已全部失效"的提示语义)。歌不存在请用code:404+msg。 api.php的trial_times返回 0 或大一些的数,可让 App 不因"额度"而限制尝试源的数量。
⑥ 请求参数(服务器可以完全不校验签名)
i.php : appid, mac=<mac>_<sn>, sn, time, ver=2.0, vn=4.1.3.03281430, sign=md5(params+APP_KEY)
do.php: appid, device=<mac>_<sn>, ish265, ls=1, musicno, resolution=720, sn, time, token, sign=md5(params+SDK_KEY)
api.php: 表单 action=get_config & device_id=<mac>
- 签名与
APP_KEY/SDK_KEY只在 App 侧;服务器收到后忽略即可(想校验也可以,算法与密钥见ktv_api.js常量)。 ls目前 App 只试1;resolution常见720(也可能1080);ish265为0/1→ 服务器建议全部容忍,按可用文件回落。
⑦ 传输与身份
- 明文 HTTP 即可(原版真源也是 http;老设备 HTTPS 反而有 TLS 坑)
- 端口任意(原版见过
:58、:66、:6677) - 设备身份:
mac=<12 位小写十六进制>、sn(部分老设备 sn 是硬编码2d7bb4a7d7f7) → 若要做设备区分,用device字段,但不要做严格白名单(同一 mac 可能被多台盒子共用)。
⑧ 可用性
App 当前只剩一个源(zk.ktv1.top:6677)→ 自建后建议至少两个入口/两域名互为备份,
并在 JS HOSTS 里按顺序排列(App 运行时会自动把成功的源置顶、失败的沉底)。
2. 任务 2:服务器类型与安装方式推荐
2.1 先算账(决定选型的两个数字)
| 项 | 估算 |
|---|---|
| 单曲(720p,约 3–5 分钟) | 20–60 MB |
| 曲库 1 万首 | 约 300–600 GB |
| 并发 | KTV 场景通常 1–3 台设备同时下歌 |
| 峰值上行带宽 | 每路 8–20 Mbps → 20–50 Mbps 足够 |
| 取址请求 | 每次几百字节(可忽略) |
→ 瓶颈是"媒体存储 + 上行带宽",不是 API 性能。
2.2 三种部署形态
| 方案 | 适用 | 优点 | 缺点 |
|---|---|---|---|
| ① 门店局域网自建(推荐) | 单店/局域网 KTV | 上行免费、延迟 <5ms、千兆内网带宽足、数据自控、无流量费 | 只能店内访问;需要内网能解析"域名" |
| ② 公网 VPS + 对象存储/CDN | 多门店、远程 | 随处可用、弹性扩容 | 流量费用高;国内需域名备案;否则用 DDNS |
| ③ 家宽 + DDNS + 端口转发 | 小规模自用 | 成本最低 | 家宽上行小(20–50 Mbps)、IP 变动、稳定性差 |
推荐:① 为主,② 作为异地备份/外网入口(用同一套 API 代码,媒体目录/对象存储不同)。
⚠️ 无论哪种,媒体 URL 必须是域名(App 会拒收裸 IP)。三种可行做法:
- 公网域名 + DDNS(如
ktv.example.com→ 动态 IP);- 内网自建 DNS(dnsmasq / AdGuard Home / 路由器 hosts)把
ktv.lan解析到内网服务器 IP;- 在路由器上给设备下发该 DNS(或设备端静态 DNS)。
2.3 技术选型(推荐组合)
| 层 | 推荐 | 备选 | 理由 |
|---|---|---|---|
| 媒体静态服务 | nginx | Caddy / MinIO | Range/206、Content-Length、sendfile、限速全都有;静态服务零开发 |
| API 服务 | Go 单二进制 | Python(FastAPI) / Node(Express) | 三个端点 + md5;无依赖、内存 <20 MB、交叉编译后丢上去就能跑 |
| 元数据 | 按编号落目录(/media/<musicno>.ts) |
SQLite(musicno→路径/标题/歌手) | 零依赖最快落地;要搜索/歌单再上 SQLite |
| 存储 | 本地 SSD/HDD(NAS 直挂) | S3/MinIO | 局域网够用;要 CDN 再上对象存储 |
| 协议 | HTTP | HTTPS | 原版就是 http;避免老设备 TLS 问题 |
| 端口 | 任选(如 80 / 8080 / 6677) |
— | App 会正确使用 :端口 |
2.4 安装方式(三选一,推荐 A)
A. Docker Compose(推荐)
selfhost-source/
├── docker-compose.yml # nginx + api
├── nginx/default.conf # /media 静态(Range)+ /api 反代
├── api/main.go # 三个端点
└── media/7009126.ts … # 媒体目录
- 优点:一份 compose 起全部、升级=换镜像、迁移=拷目录
- 适合:小型主机/NAS(群晖、绿联、迷你 PC 都能跑容器)
B. NAS 套件
- 群晖/威联通/绿联:Web Station(nginx)+ 一个开机自启脚本/容器跑 API
- 优点:图形化、带 RAID/快照;缺点:套件版本差异大
C. 裸机 + systemd
apt install nginx # 媒体/反代
# 放进 /opt/ktv-api/ktv-api(Go 编译产物)
systemctl enable --now ktv-api nginx
- 优点:最省资源(512 MB 内存的旧机器也能跑);缺点:手工维护
2.5 API 端点最小实现(Go 伪代码,约 60 行)
// GET /i.php → 发 token(服务器可自定义策略,App 不做校验)
func iPHP(w http.ResponseWriter, r *http.Request) {
// 服务器可自行决定是否校验 mac/sn/sign(App 一定会带上)
writeJSON(w, map[string]any{
"code": 200, "msg": "ok", "retry": 5, "ordertype": 0,
"end_date": "2030-01-01 00:00:00", "token": "local-token",
})
}
// GET /music/do.php?musicno=xxx → 返回媒体地址
func doPHP(w http.ResponseWriter, r *http.Request) {
musicno := r.URL.Query().Get("musicno")
path := "/media/" + musicno + ".ts" // ⚠️ 文件名必须等于编号
if _, err := os.Stat(mediaRoot + path); err != nil {
writeJSON(w, map[string]any{"code": 404, "msg": "not found"}) // 别用 403!
return
}
// ⚠️ 必须是域名(不能是裸 IP),App 会校验
writeJSON(w, map[string]any{
"code": 200, "msg": "SUCCESS", "limit": 2000,
"data": "http://" + publicHost + path,
})
}
// POST /api.php action=get_config&device_id=… → 让 App 认为"无试用限制"
func apiPHP(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
writeJSON(w, map[string]any{
"status": "success",
"config": map[string]any{
"network_verification": false, "buy_enabled": false,
"trial_enabled": false, "trial_times": 0, "trial_duration": 0,
"heartbeat_interval": 3600, "dialog_title": "",
},
})
}
nginx(媒体静态服务)
location /media/ {
alias /srv/ktv/media/;
add_header Accept-Ranges bytes; # 断点续传(OkDownload 需要)
add_header Cache-Control "public, max-age=86400";
sendfile on; tcp_nopush on;
# limit_rate 8m; # 需要时限制单连接速率
}
location / { proxy_pass http://api:8080; } # 反代 API
2.6 上线验收清单(逐条对着 App 的闸门测)
# 1) 取 token
curl -s 'http://ktv.example.com/i.php?appid=x&mac=800b510bf4fd_2d7bb4a7d7f7&sn=2d7bb4a7d7f7&time=1&ver=2.0&vn=4.1.3.03281430&sign=x'
# → 期待 {"code":200,...,"token":"…"}
# 2) 取址(务必检查文件名=编号,且是域名)
curl -s 'http://ktv.example.com/music/do.php?musicno=7009126&ls=1&resolution=720&device=…&token=…'
# → 期待 data = "http://ktv.example.com/media/7009126.ts"
# 3) 媒体头(Range/长度必须齐)
curl -sI 'http://ktv.example.com/media/7009126.ts'
# → 200、Accept-Ranges: bytes、Content-Length: 大数
curl -sI -H 'Range: bytes=0-1023' 'http://ktv.example.com/media/7009126.ts'
# → 206 Partial Content
# 4) 内容合规:≥6 MiB、时长≥60s、是 TS
ffprobe -v error -show_entries format=duration,size -of default=nw=1 media/7009126.ts
App 端现场验证:点歌后在设备 <存储根>/logs/ktv.log 里看
NATIVE-SONG 取址成功 编号=… 源=… urlHost=… 与 PLAY … src=…;
远程日志只留汇总(点歌失败 编号=… 请求源=… 详情=…)。
反例自测(验证闸门生效):
- 返回裸 IP 的媒体地址 → App 应拒收(本曲失败)
- 返回
7009126.mp4或错名.ts→ App 应拒收 - 用
code=403表示"歌不存在" → App 会按"源站风控"处理(日志出现SOURCE-BLOCKED) → 所以别这么用。
2.7 风险与注意
- 单源即单点:App 现在只有一个源,自建后建议配 2 个入口域名做冗余。
- 内容来源合法合规:服务器上的媒体内容(版权/授权)需自行确保,本方案只解决技术链路。
- 错误语义别混:403 = 风控(会让 App 判定"源受限");歌不存在用 404。
- 带宽保护:店内网络有限,建议 nginx
limit_rate/limit_conn,并把媒体放在独立网口/NAS 上。 - 接口演进:App 的闸门规则将来可能变化(例如放宽/收紧文件名匹配)→ 建议服务器
始终返回
/<musicno>.ts形式的域名地址,这是最稳定的形态。
3. 建议的推进顺序
| 阶段 | 内容 | 产出 | 是否要改本体 App |
|---|---|---|---|
| 1 | 按本文档起自建服务器(nginx + Go API + 媒体目录) | 三个端点 + 媒体可访问 | 否 |
| 2 | 用 2.6 的清单逐条自测 | 验收记录 | 否 |
| 3 | 只改 ktv_api.js 的 CONFIG.HOSTS(提升 KTV_BRIDGE_API 到 13) |
新设备立即走自建源 | 否(JS 热更新) |
| 4 | 把 SongUrlResolver.DEFAULT_HOSTS 也指向自建源 |
老盒子(H1S)可用 | 是(需发版) |
| 5 | 双入口冗余 + 监控(nginx access log / 简单健康检查) | 稳定性 | 否 |
阶段 3 与阶段 4 属于"接入动作",需要你明确指示后我再动手; 阶段 4 必须走 App 的发版流程(且发布仍需你单独下达指令)。
4. 实现落地(2026-10-10 完成,只写代码未运行)
服务器代码已按本文的分析与选型实现,位于 selfhost-source/server/(纯 Python 标准库,零 pip 依赖):
| 交付 | 位置 | 说明 |
|---|---|---|
| 服务端口(App 接口 + 媒体) | server/ktv/service.py |
/api.php、/i.php、/music/do.php、/media/<编号>.ts(Range/206) |
| 管理端口(界面 + API) | server/ktv/admin.py、server/ktv/ui.py |
概览/清单/队列/设置/日志;与管理端口分离(默认 6677 / 6678) |
| 曲库同步与清单 | server/ktv/musesync.py、server/ktv/store.py |
官方 Muse 协议(纯标准库 RSA/PKCS#1 v1.5,已实测登录成功)+ SQLite 清单 |
| 下载池(≤10 并发 + 去重登记) | server/ktv/downloader.py |
按排序取址(上游 i.php/do.php)→ 断点续传 → TS/大小校验 → 登记 done |
| 程序入口 | server/run.py、server/ktv/main.py |
两端口同进程、SIGTERM 优雅退出、定时曲库同步 |
| 部署 | server/README.md、server/deploy/ |
Debian 13.7 步骤 + systemd 单元 + 可选 nginx 媒体分流 |
| 自测 | server/tests/ |
29 项(DER/RSA、闸门、去重状态机、接口契约、Range) |
与 App 的三条硬约束保持一致:媒体地址必须域名 +
/<编号>.ts、文件 ≥6MiB 且真 TS、 不用 403 表达"没有/未就绪"(用 code:404)。