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

17 KiB
Raw Blame History

自建歌曲源服务器 — 接入分析与选型方案

支线约定(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)。三种可行做法:

  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

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 风险与注意

  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)。