合并自建歌曲源支线(selfhost-source)+ 版本号按约定修正为 1.6.0

版本号:
- 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 例)
This commit is contained in:
zijunchiang
2026-10-14 02:06:28 +08:00
parent e060ae5ebe
commit 87aa979abd
68 changed files with 21305 additions and 105 deletions
+329
View File
@@ -0,0 +1,329 @@
# 自建歌曲源服务器 — 接入分析与选型方案
> **支线约定(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 静态服务**天然全部满足** ✓
#### ⑤ 响应格式
```jsonc
// 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)。三种可行做法:
> 1. 公网域名 + DDNS(如 `ktv.example.com` → 动态 IP);
> 2. **内网自建 DNS**(dnsmasq / AdGuard Home / 路由器 hosts)把 `ktv.lan` 解析到内网服务器 IP;
> 3. 在路由器上给设备下发该 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**
```bash
apt install nginx # 媒体/反代
# 放进 /opt/ktv-api/ktv-api(Go 编译产物)
systemctl enable --now ktv-api nginx
```
- 优点:最省资源(512 MB 内存的旧机器也能跑);缺点:手工维护
### 2.5 API 端点最小实现(Go 伪代码,约 60 行)
```go
// 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(媒体静态服务)**
```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 的闸门测)
```bash
# 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 风险与注意
1. **单源即单点**:App 现在只有一个源,自建后建议配 **2 个入口域名**做冗余。
2. **内容来源合法合规**:服务器上的媒体内容(版权/授权)需自行确保,本方案只解决技术链路。
3. **错误语义别混**:403 = 风控(会让 App 判定"源受限");歌不存在用 404。
4. **带宽保护**:店内网络有限,建议 nginx `limit_rate` / `limit_conn`,并把媒体放在独立网口/NAS 上。
5. **接口演进**: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)。