Files
maidong-ktv/selfhost-source/DESIGN.md
T
zijunchiang 87aa979abd 合并自建歌曲源支线(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 例)
2026-10-14 02:06:28 +08:00

330 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 自建歌曲源服务器 — 接入分析与选型方案
> **支线约定(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)。