# 自建歌曲源服务器 — 接入分析与选型方案 > **支线约定(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=(缓存 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=` | **建议实现** | 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://<你自己的域名>[:端口]/<任意路径>/.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=) {"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=_, sn, time, ver=2.0, vn=4.1.3.03281430, sign=md5(params+APP_KEY) do.php: appid, device=_, ish265, ls=1, musicno, resolution=720, sn, time, token, sign=md5(params+SDK_KEY) api.php: 表单 action=get_config & device_id= ``` - 签名与 `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/.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 的闸门规则将来可能变化(例如放宽/收紧文件名匹配)→ 建议服务器 **始终返回 `/.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)。