Files
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

1516 lines
126 KiB
Markdown
Raw Permalink 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.
# 麦动KTV 自建歌曲源服务器
> **本目录只放服务器代码,不在开发机运行**;部署到 Debian 13.7 服务器后按本文档操作。
> 纯 Python 标准库实现(**零 pip 依赖**),协议与接口完全按 App 的实际取址流程反推。
>
> 文档导航:**§1–§10 是现状**(定位与边界 / 两个表 / 自动维护任务 / 心跳与身份 / 接口清单 /
> 配置项表 / 地址闸门 / 部署与运维 / 验收自查 / 目录与边界);**§11 是历史留档**
> (2026-10 的功能核验、缺陷根因与部署实测,部分能力已变更,会就地标注)。
---
## 1. 定位与边界(先读这一段)
### 1.1 本支线只做服务端,不发布 Android App
- 本目录 = 「麦动KTV 自建歌曲源服务器」:纯 Python 标准库、一个进程两个端口
(服务端口 6677 对 App/机顶盒,管理端口 6678 对管理员)。
- 配套的 **App 侧改动**(whoami 心跳、设备身份、地址闸门修复)**保留在 `maidong-ktv/` 仓库**里:
`app/src/main/java/com/local/ktv/Whoami.kt`、`DeviceIdentity.kt`、`AdVideoFilter.kt`,
以及 `app/src/main/assets/mobile/ktv_api.js`。
**本次不构建、不打包、不发布**(`dist/`、`*.apk` 里的历史产物也不代表本次发布)。
- 结论:**服务端的任何改动都不要求 App 跟版**。
### 1.2 服务端 ↔ App 的接口(App 无需跟着服务端升级)
| 端口 | 端点 | 方法 | 说明 |
|---|---|---|---|
| 6677(服务) | `/api.php` | POST 表单 | 取配置(本服务器不设试用限制:`trial_times:0`) |
| 6677(服务) | `/i.php` | GET | 取 token(本服务器固定返回一个非空 token) |
| 6677(服务) | `/music/do.php` | GET | 取本机媒体地址 `http://<域名>:6677/media/<编号>.ts`;未就绪返回 `code:404` |
| 6677(服务) | `/media/<编号>.ts` | GET/HEAD | 媒体文件,支持 `Range`/206 |
| 6677(服务) | `/push_download`、`/push_download/status` | POST/GET | App 点歌推送(限流按**真实客户端 IP**,见 §5.9) |
| 6677(服务) | `/healthz` | GET | 探活(**纯内存快照**:`ok/version/uptime_sec/library/keeps/downloader`,微秒级返回,给 IIS/负载均衡用) |
| 6677(服务) | `/api/client_ip` | GET | 排错:服务端认定的客户端 IP / 是否来自可信代理 / 命中哪个头(反代核对用,见 §8.6) |
| 6677(服务) | `/` | GET | 服务端口首页(一句摘要,管理界面在 6678) |
| 6678(管理) | `/`、`/ui` | GET | 管理界面(单页 HTML) |
| 6678(管理) | `/api/*` | GET/POST | 管理 JSON 接口(见 §5) |
| 出站 | `whoami_url`(默认 `http://hbeat.ktvsky.com/whoami`) | POST | **服务端主动**上报设备心跳,App 不参与 |
- 取址协议(`i.php` 签名串、`music/do.php` 签名串、返回结构)**没有变**,2026-10-11 用 HAR
逐字节复算通过(细节见 §11.6);whoami 是服务端**出站**请求。
- 所以 App 只要把地址指向本服务器(改 `HOSTS`,或内网 DNS/路由器 hosts 把某个域名指到本机)即可
继续使用,**不需要跟着服务端升级**。
### 1.3 App 侧接入(仅记录现状,本支线不动 App、不发版)
- **JS 主路径**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS` 改为
`['http://<你的域名>:6677']`,同时把 `KTV_BRIDGE_API` 从 12 提到 13(`KtvJsBridge.kt` 两处校验同步)
→ 走 Gitea 热更新,新设备立即生效;
- **老盒子(原生兜底)**:`SongUrlResolver.DEFAULT_HOSTS` 同样改成该地址(**需要发版**);
- 只想先验证、不改 App:用路由器 hosts/DNS 把某个域名指到本服务器,再临时改 JS。
> ⚠️ 上面三条是"**将来**若要动 App"的说明;本次支线**不构建、不发布 App**。
## 2. 两个表:曲库清单 vs 歌曲表(务必分清)
一句话:**曲库清单是"有什么"(数据库同步派生、只读参考);歌曲表是"要什么"(用户维护的名单)。
服务器只按歌曲表下载与删除媒体文件。**
### 2.1 曲库清单(`songs`)——数据库同步派生的全量表,只读
- **来源**:官方 Muse 同步(概览页「同步曲库」/ `POST /api/library/sync` / `muse_auto_sync_hours`)。
首次全量导入,之后每次先算 diff 再导入(新增 / 上游删除 / 内容变更)。
- **用途**:查询、按语言/状态筛选、导出 CSV、勾选后「加入歌曲表」。
- **只读参考,不接受手工增删**:没有增加/删除接口;上游删除的歌**不物理删除**,只打 `removed_at`
标记(保留下载登记与已下载文件),可以在同步任务里看到"这次少了哪些"。
- **接口**:`GET /api/songs`(= `GET /api/library/songs`)、`GET /api/languages`、
`GET /api/export.csv`(= `GET /api/library/export.csv`)。
- **界面**:「曲库清单」页签(页内明确写着"只读参考(不能在这里增删)")。
### 2.2 歌曲表(`keeps`)——用户维护的名单,服务器唯一的下载依据
- **行内容**:只存「编号 + 状态」等最小信息
(`tid/state/bytes/path/attempts/last_error/user/note/added_at/updated_at/finished_at`)。
- **状态 `state` 只有 4 个值**:
| 取值 | 含义 |
|---|---|
| `want` | 待下载(加入歌曲表后的初始状态) |
| `downloading` | 下载中(下载池已抢占) |
| `done` | 已下载(本地有 `<编号>.ts`) |
| `failed` | 失败(原因在 `last_error`,会被重试) |
> 列表里还会看到 `matched` / `unmatched`:那是**筛选口径**(能否从曲库补全),**不是状态值**。
> `state=unmatched` 只列出"曲库暂时没有这个编号"的行。
- **自行填充(只给「编号」也能用)**:歌名/语言/专辑/歌手/文件名/热度**不落库**,查询与导出时
实时 `LEFT JOIN songs` 取 —— 所以导入一份**只有「编号」一列**的 CSV 也能用;曲库暂时没有的编号
以 `matched=false` 留在表里,**曲库以后同步进来就自动补全**(不需要任何额外动作)。
- **操作**:增(勾选/本页/按筛选/手填编号/CSV 导入)、删(移除,默认连本地文件一起删)、
改(导入 merge 改状态)、查(关键词/语言/状态/排序/分页)、CSV 导入导出。
- **接口**:`GET /api/keeps`、`POST /api/keeps/add`、`POST /api/keeps/remove`、
`GET /api/keeps/export.csv`、`POST /api/keeps/import?mode=merge|replace`、
`GET /api/keeps/stats`、`GET /api/keeps/unmatched`。
### 2.3 表 ↔ 文件一致性规则(写死的,别手工绕过)
| 动作 | 谁做 | 结果 |
|---|---|---|
| **加入歌曲表** | 用户(界面/接口) | 维护任务下一轮补队下载:`store.keep_next_batch()` 只挑 `state NOT IN ('done','downloading')` **且能从曲库匹配到**的歌,再 `store.enqueue()`。手工 `POST /api/download/enqueue` 也可以入队,但**必须显式给 `tids`**(自动入队已删除,见 §5.7) |
| **从歌曲表移除** | 用户(界面/接口) | 默认删本地 `<media_dir>/<编号>.ts`(唯一入口 `_safe_delete_media`,见 §8.4)并清掉 `downloads` 登记;`delete_files=false` 可只移除名单不删文件 |
| **媒体目录里发现未知 `<编号>.ts`** | 维护任务的媒体扫描 | **自动登记进歌曲表**(`state='done'`、记录字节数/路径、`user='scan'`、`note='媒体扫描自动登记'`);曲库没有该编号也照样登记(`matched=false`)。**绝不删除** |
| **App 点歌推送**(`POST /push_download`) | App(点歌拿到下载地址后,默认关闭,见 §5.9) | 先校验地址闸门 + 按编号去重(三态),无重复才登记:歌曲表缺行时插入 `state='want'`、`user='app'`、`note='App 点歌推送'`(**已在表内的行状态不动**,与 `keep_add()` 同语义;曲库里没有该编号也允许,`matched=false`),并把 `downloads` 置 `pending` + `push_url=<推送地址>` + `source='app-push'`(`attempts` 归零,保证下一轮就能被抢占)。**只增不改不删** |
| **`done` 但文件缺失/过小** | 维护任务的媒体扫描 | 回退为 `want`(`requeued`),并把 `downloads` 登记放回 `pending`(否则下载池会因 `done` 跳过它) |
| **`matched=false` / 文件还没下的行** | —— | 原样保留(允许"先加入、以后再下"是明确需求) |
- **关键不变量**:`keeps` 行**永不因扫描/同步/迁移被删除**;全项目只有两处 `DELETE FROM keeps`:
`keep_remove()`(用户显式移除)与 `replace_keeps()`(导入=整表替换)。
- 删本地文件也**只有一个入口** `Store._safe_delete_media()`(三道校验,见 §8.4 第 3 条)。
### 2.4 CSV 导入导出
- **导出**:`GET /api/keeps/export.csv`(UTF-8 带 BOM;列与曲库清单导出一致,便于互导;
「下载状态」列是中文可读值)。
- **导入**:`POST /api/keeps/import?mode=merge|replace`(也支持 JSON 信封 `{"text": "...", "mode": "..."}`);
界面在「歌曲表」页签有「导入 CSV」+ 「导入=合并/整表替换」下拉。
- **编码自动识别**:`utf-8-sig → utf-8 → gb18030 → utf-16`(Excel 中文版另存的 GBK 可直接导入)。
⚠️ 界面必须把**原始字节**(`arrayBuffer()`)POST 上来,先按 UTF-8 解成字符串会把坏字节永久丢掉。
- **分隔符自动识别**:`,` `;` TAB `|`(取第一个非空行里出现次数最多者)。
- **表头按列名定位**(编号/tid/ID/歌曲编号、状态/下载状态/state、备注/note、用户/user),
列顺序随意、允许多余列/缺列;**没有表头也行**(第 1 列 = 编号)。
- `mode=merge`:按编号 upsert(存在的更新状态/字节,不存在的加入;备注/用户空值保留原值)。
- `mode=replace`:**原子整表替换**(清空 + 清 downloads + 写新行在**同一个事务**里,任何异常整体回滚),
且**空 CSV / 无有效编号直接拒绝执行**(`replace_cancelled=true`,歌曲表一行不动)。
- 「字节」列不参与导入(字节数/路径由下载与媒体扫描维护)。
- 被 Excel 按显示精度改写成 `4.10008E+06` 的编号会被**拒绝**并给中文提示(把该列设为"文本")。
## 3. 「歌曲表自动维护」任务(`ktv/maintain.py`)
**它是什么**:把"补队下载 / 扫描登记 / 失败重试 / 身份时效 / 心跳"合并成的**唯一**后台任务。
2026 之前是三个独立守护线程(`BatchManager` / `media_scan_scheduler` / `FailedRetry`)+ whoami 独立定时,
各跑各的定时器,"谁在跑、跑到哪一步、为什么没动静"说不清(线上出现过"下载 worker 不动作"却查不出是谁)。
现在只有**一条时间线**。
### 3.1 状态机(`state` / `state_text`)
| `state` | 界面中文(`state_text`) | 含义 |
|---|---|---|
| `stopped` | 未运行 | 维护任务未启动(默认状态) |
| `idle` | 运行中 · 空闲(等待下一轮) | 已启动,正在等 `next_run_at` |
| `checking_identity` | 运行中 · 检查设备身份 | 每轮第 1 步:身份 24h 时效检查 |
| `scanning` | 运行中 · 扫描媒体目录 | 每轮第 2 步:登记新文件 + 缺失回队 |
| `retrying` | 运行中 · 重试失败任务 | 每轮第 3 步:`failed` → 待下载 |
| `downloading` | 运行中 · 正在下载 | 每轮第 5 步:已起心跳,等队列排空 |
| `error` | 出错(见错误信息) | 本轮出现**未预期异常**(可预期的扫描/重试失败只记 `scan_error`/`retry_error`) |
- 中文由后端 `STATE_TEXT` 给出,界面直接渲染 `state_text`(不再两处各写一套)。
- `stop()` 之后正在收尾的那一轮**不会再改写状态**(避免"点了停止却还在跑")。
### 3.2 每轮固定顺序(`MaintainTask.run_cycle()`)
| 步 | 阶段 | 做什么 | 失败会怎样 |
|---|---|---|---|
| a | `checking_identity` | 身份时效检查;超过 `device_identity_ttl_hours` 就自动换新身份(§4.3) | 换身份失败只记日志,本轮继续 |
| b | `scanning` | 扫描媒体目录:新文件**登记**进歌曲表;`done` 但文件缺失/过小 → **回队**。列举放在**带超时的子线程**里(`media_scan_timeout_sec`,默认 30s) | 超时/异常只记 `scan_error`,**不改任何状态**,本轮继续 |
| c | `retrying` | `failed`/`missing` 重置为待下载(受 `max_attempts` 上限约束) | 只记 `retry_error`,本轮继续 |
| d | ——(不单独设状态) | **按歌曲表补队**:队列不足 `batch_target_pending`(200)时,按 `download_order`/`download_order_desc` 从歌曲表取候选(`keep_next_batch`)再 `enqueue` | 查询失败只记日志 |
| e | `downloading` | **只有"确实有歌要下"(`pending>0` 或 `active>0`)才**:确保下载池在跑(空间暂停过就先恢复)→ **启动 whoami 心跳** → 等队列排空(`pending==0 且 active==0`,最长 `maintain_drain_timeout_sec`,默认 3600s;超时记 `drain_timeout=true` 进入下一轮;`stop()` 也让它立刻退出) | 心跳启动失败不影响下载;drain 异常记日志后进入下一轮 |
| f | ——(收尾) | **无条件停心跳**(成功/失败/超时/异常都停)→ 记 `last_result` + 写一条 INFO 事件 → `next_run_at = 本轮结束时间 + maintain_interval_sec` | —— |
- 整轮有**防重入锁**:同一时刻只允许一轮(`run_now` 撞上正在跑的一轮会返回 `上一轮维护还在进行中`)。
- **没有要下载的歌 → 心跳一次都不启动**(这是设计,不是 bug)。
- `last_result` 字段:`identity_regenerated / identity_age_sec / identity_expires_in_sec / identity_mac /
scanned / adopted / updated / skipped / requeued / retried / enqueued / done / failed /
pending_left / active_left / heartbeat / drain_timeout / scan_error / retry_error / error / elapsed_ms`。
### 3.3 按钮语义(概览页「歌曲表自动维护」卡片)
| 按钮 | 接口 | 语义 |
|---|---|---|
| **启动** | `POST /api/maintain/start` | 立即开始第一轮,之后每 `maintain_interval_sec` 一轮;已在跑则返回 `{"ok":false,"error":"歌曲表自动维护已在运行"}` |
| **停止** | `POST /api/maintain/stop` | 置停止位 + **立刻停心跳**;正在 drain 的那一轮尽快退出(不等排空)。body 可带 `{"reason":"..."}` |
| **重启** | `POST /api/maintain/restart` | 停止后**立即开始新一轮**(`next_run_at=now`,**不等间隔**) |
| **立即维护一次** | `POST /api/maintain/run_now` | 起后台线程跑一轮并**立刻返回**(`{"ok":true,"started":true}`),**不改变排程**(跑完 `next_run_at` 不变);任务已停止时也会完整跑一轮 |
排程字段:`next_run_at`(下次开跑时间)、`last_run_at`、`cycle_count`、`interval_sec`、`running`。
### 3.4 排程与开机自启
- `maintain_interval_sec` 默认 **600 秒**(10 分钟);下限 30 秒(`maintain.MIN_INTERVAL`):
保存配置时 <30 会被改成 30,运行时再用 `max(30, 配置值)` 兜底(防忙轮询)。
- `maintain_enabled` 默认 **`false`**:服务启动后**不会自动维护**,要人在概览页点「启动」。
- **开机自动进入维护的条件**:`maintain_enabled=true` **或** 旧键 `batch_enabled=true`
(老部署靠它开机自动跑批量,继续认);两者都不为真时只写一条"未自动启动,可在管理界面点启动"事件。
- 装配顺序:维护任务在 `main.py` 里**只装配**;`start()` 必须在**两个 HTTP 端口监听之后**
(顺序是刻意的,见 §8.4 第 1 条)。
### 3.5 与旧守护线程的关系
- 默认(`maintain_legacy_daemons=false`)=**一条时间线**:whoami 心跳、失败重试、媒体目录扫描
都不再各自定时,全部由维护任务在每轮里驱动。
`FailedRetry` 对象仍装配(供 `POST /api/download/retry_failed_now` 手工触发、并给
`GET /api/download/status` 的 `auto_retry` 提供状态),但**不单独起线程**。
- 只有显式 `maintain_legacy_daemons=true` 时才退回旧行为:`whoami.start()` + `retry.start()` +
`media_scan_scheduler` 三个独立守护线程(并在 `batch_enabled=true` 时启动 `BatchManager`)。
这是给老部署的兜底,**新部署不要开**(会变成两条时间线,日志互相干扰)。
- **维护任务未启动时**:不会自动下载、不会自动扫描、不会自动重试、也不会有 whoami 心跳
(`POST /api/whoami/send`、`POST /api/keeps/scan`、`POST /api/download/retry_failed_now` 这些手工接口仍可用)。
### 3.6 状态接口
`GET /api/maintain/status`(`GET /api/batch/status` 是它的别名,字段相同):
| 字段组 | 字段 |
|---|---|
| 状态 | `ok` `enabled` `state` `state_text` `running` `fatal` `reason` `error` |
| 排程 | `interval_sec` `last_run_at` `next_run_at` `cycle_count` `checks`(`cycle_count` 的旧名) |
| 上一轮 | `last_result`(见 §3.2 字段表) |
| 心跳 | `heartbeat_running` `heartbeat_enabled` `last_heartbeat` |
| 身份 | `identity_age_sec` `identity_expires_in_sec` `identity_ttl_hours` `identity_expired` `identity_mac` |
| 队列/歌曲表 | `done` `pending` `failed` `library` `enqueued` `baseline_done` `keeps_total` `keeps_want` `keeps_done` `keeps_failed` `keeps_bytes` `keeps_remaining` |
## 4. 设备心跳(whoami)与设备身份
### 4.1 心跳什么时候发(只在维护任务确实要下载时)
- **启动点**:§3.2 步骤 e —— 只有 `pending>0 或 active>0` 时才 `WhoamiReporter.start()`。
- **停止点**:§3.2 步骤 f 的 `finally` —— **无条件** `stop()`(排空、超时、异常、用户点「停止」都停)。
- **本轮没有要下载的歌**(队列空)→ **一次都不发**(不会"空转保活")。
- **开关**:`maintain_heartbeat=false` 或 `whoami_enabled=false` → 任何情况下都不启动。
`whoami_enabled` 是总开关:置 false 后后台线程不再发(每 60 秒再看一眼开关,**改配置无需重启**)。
- **一次长下载期间仍按节奏上报**:`WhoamiReporter.loop()` 自己按响应里的 `next_interval` 续报
(响应没给就用 `whoami_interval_sec`,并夹在 60 秒 ~ 24 小时);`_boot=1` 只用于启动那一次。
- **手工**:`POST /api/whoami/send` 立刻同步打一次,**不受 `whoami_enabled` 限制**
(否则关掉开关就没法验证);`GET /api/whoami` 看 `running/sent/failed/last_at/last_ok/last_http/
last_response/last_error/last_elapsed_ms/last_next_interval`。
- **请求形状**:`POST {whoami_url}?<24 个 query 字段>`,**body 为空**,
`Content-Type: text/plain; charset=utf-8`,UA 为
`Dalvik/2.1.0 (Linux; U; Android <版本>; <型号> Build/<构建>)`;失败只记日志
(`WARN whoami 心跳失败(周期/手工)http=… error=…`),**绝不影响下载/取址主流程**;全程在后台守护线程里。
- **24 个字段的顺序**(`whoami.PARAM_ORDER`,顺序即抓包顺序):
`_src, _src_at, _m, _mac, _vc, _vn, _pkn, _player, _boot, _ts, _api, ktv_id, androidid, _manuf,
_cid, d_name, d_device, d_board, d_hardware, d_fingerprint, d_incremental, d_release, d_utc, d_user`。
### 4.2 身份:5 套安卓盒子模板 + 随机 MAC
内置 5 套常见盒子模板(`identity.PRESETS`):
| preset | 机型 | Android | 说明 |
|---|---|---|---|
| `aml_s905x` | A95X(Amlogic S905X) | 9 | OUI 取 Amlogic 方案常见前缀 |
| `aml_s905x3` | X96MaxPlus(S905X3) | 11 | —— |
| `aml_s905x4` | A95XF4(S905X4) | 12 | —— |
| `rk3328` | H96Max(Rockchip RK3328) | 9 | RK 方案盒子的 MAC 多由 ODM 分配,OUI 取做 RK 盒子的 ODM |
| `rtd1295` | RTD1295(Realtek) | 9 | —— |
- 生成规则:随机挑一套模板 + 随机 MAC(前 3 字节取自**真实 TV 盒子/芯片厂 OUI**,且必须**单播**)
+ 随机 `sn`(12 位小写 hex)+ 随机 `ktv_id`(8 位数字)。
- **生成一次、持久化**:**原子写 + 权限 0600** 到 `<data_dir>/device-identity.json`
(`device_identity_file` 可改路径),之后固定不变;文件损坏/不自洽会自动重新生成。
文件里有 mac/sn,按凭据对待。
- **自洽性**(`identity.validate()` 逐项校验):`d_fingerprint` 用真机固件格式
`品牌/产品名/设备名:版本/构建ID/增量:user/release-keys`;`_api` 由 `d_release` 反查
(9→28、11→30、12→31…),不会出现"_api=31 却自称 Android 9";`androidid == mac`、`_cid == "A_" + mac`。
- `device_identity_auto=false` → 不上报盒子身份,退回真实本机机型(**仅排错用**);
此时若已有身份文件仍会复用,但不会新建。
- **显式配置优先**:`upstream_mac`/`upstream_sn` 非空(且不等于旧抓包常量)=用户写死,
自动身份不再覆盖它(`GET /api/device` 的 `effective.mac_source` 会显示 `explicit`)。
### 4.3 24 小时时效(`device_identity_ttl_hours`)
- 默认 **24 小时**。维护任务每轮开始(§3.2 步骤 a)比较身份文件的 `created_at` 距今是否超过 ttl。
- **超期 → 自动换新**:调用 `identity.regenerate_identity()`,它**一处**完成
① 生成新身份 → ② 原子落盘 → ③ `apply_to_cfg`(换 `upstream_mac`/`upstream_sn` + `cfg["device_identity"]`)
→ ④ 清掉取址器缓存的 token(token 绑在旧 device 上)。
同时写事件 `WARN 设备身份已过期(X.Xh),自动更换:新 mac=… sn=…`,本轮 `last_result.identity_regenerated=true`。
- 填 **`0` = 关闭时效检查**(身份生成一次永久固定)。
- 只有**维护任务**会做自动换新;**服务启动时不会**因超期换身份(启动只 load/生成)。
想立刻换:`POST /api/device/regenerate`。
- **换身份必须走同一个入口**:单独改 `upstream_mac` 而不清 token / 不同步 `cfg["device_identity"]`,
会导致 i.php / do.php / whoami 三处不一致(上游会当成"换设备/异常")。
### 4.4 三处同源(i.php / do.php / whoami)
| 位置 | 用的字段 | 来源 |
|---|---|---|
| `GET /i.php` | `mac=<mac>_<sn>`(`resolver.token_params`) | `upstream_mac` / `upstream_sn`(`identity.apply_to_cfg` 在运行期填入,**不落盘**) |
| `GET /music/do.php` | `device=<mac>_<sn>`(`resolver.song_params`) | 同上 |
| whoami 心跳 | `_mac` / `androidid`(=`mac`) / `_cid`(=`A_<mac>`) / `_m` / `_manuf` / `d_*` | `cfg["device_identity"]`(同一份身份) |
- 自动身份写的是**运行期值**(`Config.set_runtime`,不落盘):一旦落盘就会变成"用户显式配置",
之后 `regenerate()` 反而覆盖不掉它(自动生成的值把自己锁死)。
- 用户显式配置 `upstream_mac`/`upstream_sn` 时,三处一起用显式值(`effective.mac_source=explicit`)。
### 4.5 接口与界面
- `GET /api/device`:`ok / auto / file / exists / origin / preset / label / presets / identity /
problems / ttl_hours / age_sec / expires_in_sec / expired / effective{upstream_mac, upstream_sn,
mac_source, sn_source}`。
- `origin`:`loaded`(复用已有文件)/ `generated`(本次生成)/ `explicit`(配置里写死了 mac/sn)/
`disabled`(未开启自动身份且无文件)/ `invalid`(文件不自洽且未重建)/ `none`。
- `age_sec`:身份已生成多少秒;`expires_in_sec`:距过期还有多少秒(0 = 已过期或未知);
`expired`:是否已超 ttl。
- `POST /api/device/regenerate`:body 可带 `{"preset":"aml_s905x3"}`(留空 = 随机模板);
立刻刷新内存配置(下一轮 i.php/do.php 就是新身份)、清 token、写事件日志。
- 设置页改 `device_identity_preset` 保存 → **立刻**按新模板重新生成一份身份(mac/sn 随之变化)。
- 概览页卡片「设备身份与心跳」:MAC/SN(含来源)、机型/厂商/Android/`_api`、模板、
心跳状态(运行中/已停止 + 上次时间与结果)、成功/失败计数、下次间隔、上报地址;
按钮「重新生成身份」「立即上报心跳」。
## 5. 接口清单(新旧对照表)
> - 管理端口的 `/api/*` 在 `admin_token` 非空时需要鉴权:请求头 `X-Auth-Token: <token>`、
> 或 `?token=<token>`、或 `Authorization: Bearer <token>`(`/api/public` 前缀豁免)。
> - 管理端口 `/` 与 `/ui` 都是同一个管理界面(HTML,不在 `/api/` 下,不受 `admin_token` 拦截;
> 界面里的接口调用会自动带上 token)。
> - 方法不匹配返回 404;`HEAD` 按 `GET` 路由(媒体探测/`curl -I` 用得到)。
### 5.1 服务端口 6677(App 协议,对外)
| 端点 | 方法 | 参数 | 返回 |
|---|---|---|---|
| `/api.php` | POST 表单 | `action=get_config&device_id=<mac>` | `{"status":"success","config":{…trial_times:0…}}` |
| `/i.php` | GET | `appid&mac=<mac>_<sn>&sn&time&ver=2.0&vn=4.1.3.03281430&sign` | `{"code":200,"msg":"ok","token":"…"}` |
| `/music/do.php` | GET | `appid&device&ish265&ls=1&musicno&resolution&sn&time&token&sign` | 已就绪 `{"code":200,"msg":"SUCCESS","data":"<媒体URL>","limit":2000}`;否则 `{"code":404,…}` |
| `/media/<编号>.ts` | GET/HEAD | 支持 `Range` | `200/206` + `Accept-Ranges: bytes`;不存在 = **404** |
| `/push_download` | POST | JSON 或表单:`tid&url&name&token`(令牌也可放请求头 `X-Push-Token`)。鉴权两条通道**任一即可**:`push_token` **或**合法 App 令牌(请求头 `X-App-Token`,见 §5.10) | 接受 `{"ok":true,"duplicate":false,"queued":true,"tid":…}`;重复 `{"ok":true,"duplicate":true,"reason":"already_done\|already_queued\|recently_pushed","queued":false}`;鉴权失败 401、闸门/参数不通过 400、超限流 429(见 §5.9) |
| `/push_download/status` | GET | —— | `{"ok":true,"accepted":…,"duplicate":…,"rejected":…,"rate_limited":…,"last_at":…}` |
| `/token` | GET **或** POST | 按**真实客户端 IP** 每分钟限 `token_rate_limit_per_min`(默认 60)| `{"ok":true,"token":"<32~48 位随机 urlsafe>","expires_in":600,"expires_at":<unix>}`;响应 `Cache-Control: no-store`;超限 429(带 `Retry-After`)—— App 令牌,见 §5.10 |
| `/token/status` | GET | —— | `{"ok":true,"issued":…,"accepted":…,"invalid":…,"rate_limited":…,"ttl_sec":…,"paths":[…],"pending_log":{…}}` —— 令牌计数(运维核对,见 §5.10) |
| `/healthz` | GET | —— | `{"ok":true,"version":"1.0.0","uptime_sec":…,"library":…,"keeps":…,"downloader":{…},"snapshot_age_sec":…}`(兼容老字段 `songs/done/pending/concurrency`);**纯内存快照**,微秒级返回,不查库、不碰媒体目录 —— 给 IIS/负载均衡探活用 |
| `/api/client_ip` | GET | —— | `{"ok":true,"client_ip":…,"peer_ip":…,"via_proxy":…,"source":"tcp\|x-forwarded-for\|x-real-ip","ignored_reason":…,"x_forwarded_for":…,"host":…,"host_source":"x-forwarded-host\|host","media_host_match":…,"api_host_match":…,"media_hosts":[…],"api_hosts":[…],"trusted_proxies":[…],"trust_proxy_headers":…}` —— 反代/宿主隔离排错用(见 §5.11、§8.6) |
| `/` | GET | —— | 服务端口首页 HTML(摘要 + 指向管理端口) |
> 媒体地址来自 `public_media_base`:`<public_media_base>/<编号>.ts`(**必须是域名**,§7.1)。
> 反向代理部署时(§8.6),若请求确实经由**可信代理**,`do.php` 会用可信的
> `X-Forwarded-Proto`/`X-Forwarded-Host` 拼地址(HTTPS 场景不返回 http 内网地址)。
### 5.2 管理端口:维护任务(**现状入口**)
| 接口 | 方法 | 说明 |
|---|---|---|
| `/api/maintain/status` | GET | 维护状态(§3.6 全字段) |
| `/api/maintain/start` | POST | 启动(立即第一轮,之后每 interval) |
| `/api/maintain/stop` | POST | 停止(同时停心跳;body 可带 `{"reason":"…"}`) |
| `/api/maintain/restart` | POST | 停止后立即开新一轮(不等间隔) |
| `/api/maintain/run_now` | POST | 立即跑一轮(立刻返回,**不改排程**) |
| `/api/batch/start` | POST | **旧接口,薄包装** → 等价于 `/api/maintain/start` |
| `/api/batch/stop` | POST | **旧接口,薄包装** → 等价于 `/api/maintain/stop` |
| `/api/batch/status` | GET | **旧接口,薄包装** → 等价于 `/api/maintain/status`(字段相同) |
### 5.3 管理端口:歌曲表 keeps
| 接口 | 方法 | 说明 |
|---|---|---|
| `/api/keeps` | GET | 分页查询(`keyword/lang/state/page/page_size/order/desc`;`state` 还支持 `matched`/`unmatched` 口径) |
| `/api/keeps/add` | POST | 加入歌曲表:给 `tids` 按编号加;否则按 `keyword`/`lang`/`limit` 批量加(`limit=0` 不限) |
| `/api/keeps/remove` | POST | 移除(默认 `delete_files=true` 连本地文件一起删;返回 `removed/deleted_files/freed_bytes/errors`) |
| `/api/keeps/export.csv` | GET | 导出整张歌曲表(UTF-8 BOM) |
| `/api/keeps/import` | POST | 导入 CSV;`?mode=merge\|replace`,body 可直接是 CSV 字节或 JSON `{text,mode}` |
| `/api/keeps/stats` | GET | 歌曲表统计 + **最近一次媒体扫描结果**(`scan` 字段) |
| `/api/keeps/scan` | POST | **立刻扫描媒体目录一次**(登记/更新/回队;**绝不删文件**;超时返回 `ok=false` 且不改状态) |
| `/api/keeps/scan/status` | GET | 最近一次媒体扫描结果(时间/新增/更新/跳过/回队/耗时/错误 + 开关与间隔) |
| `/api/keeps/unmatched` | GET | 曲库匹配不到的编号(`limit=0` 全部;用于核对编号是否写错) |
### 5.4 管理端口:曲库清单 / 曲库同步
| 接口 | 方法 | 说明 |
|---|---|---|
| `/api/songs`、`/api/library/songs` | GET | 曲库清单查询(只读参考,`state` 可按下载状态筛) |
| `/api/languages` | GET | 语言列表 + 每语言数量 |
| `/api/export.csv`、`/api/library/export.csv` | GET | 曲库清单导出 CSV |
| `/api/library/sync` | POST | 启动曲库同步(**立刻返回**,后台跑) |
| `/api/library/cancel` | POST | 取消进行中的同步(当前阶段结束时停) |
| `/api/library/tasks` | GET | 同步任务清单(含每次清单比对:新增/上游删除/变更) |
| `/api/library/task` | GET | 单条同步任务详情(`?id=`) |
| `/api/library/status` | GET | 同步实时状态 + 曲库 meta(版本/总数) |
### 5.5 管理端口:下载队列 / 上游源
| 接口 | 方法 | 说明 |
|---|---|---|
| `/api/download/enqueue` | POST | **显式入队**:body 必须带 `tids`(可选 `skip_done`、`name`);不传 tids 直接拒绝(`mode=auto` 已删除,见 §5.7) |
| `/api/download/start` | POST | 启动下载池 |
| `/api/download/pause` | POST | 暂停(人工暂停;不会被"空间恢复"解除) |
| `/api/download/resume` | POST | 继续(不会解除"空间原因"的暂停) |
| `/api/download/stop` | POST | 停止下载池 |
| `/api/download/status` | GET | 下载池状态 + 队列统计 + **`auto_retry`**(自动重试调度器状态) |
| `/api/download/tasks` | GET | 下载任务清单(`state/page/page_size`) |
| `/api/download/failed` | GET | 失败任务清单(`limit`) |
| `/api/download/retry_failed` | POST | 重置失败任务为待下载(直接用 `retry_failed_max_attempts`) |
| `/api/download/retry_failed_now` | POST | 立刻执行一次"自动重试"(默认遵守跳过条件;`{"force":true}` 强制) |
| `/api/upstream/hosts` | GET | 各源健康度/冷却剩余 + 自动选择顺序(`items`/`order`/`last_ok_host`) |
| `/api/upstream/probe` | POST | 手动探测全部源(api.php + i.php;**走同一取址节奏,不绕过限速**) |
| `/api/upstream/clear_health` | POST | 清除健康度(全部源回到配置顺序) |
### 5.6 管理端口:设备身份 / 心跳 / 概览 / 设置 / 日志
| 接口 | 方法 | 说明 |
|---|---|---|
| `/api/device` | GET | 当前设备身份 + 时效字段(§4.5) |
| `/api/device/regenerate` | POST | 重新生成身份(body 可带 `{"preset":"…"}`;立刻生效并清 token) |
| `/api/whoami` | GET | 最近一次心跳(时间/耗时/响应/成功/计数/下次间隔/`running`) |
| `/api/whoami/send` | POST | 立刻打一次心跳(**不受 `whoami_enabled` 限制**) |
| `/api/stats` | GET | 概览(曲库/歌曲表/下载/媒体文件/磁盘/配置/下载池/维护状态/同步状态;带 TTL 缓存) |
| `/api/storage` | GET | 存储水位 + 限速 + 守卫状态 |
| `/api/settings` | GET/POST | 读/写配置(POST body 是键值 patch;返回 `warnings` 校验提示) |
| `/api/events` | GET | 事件日志(`limit`,最多保留最近 5000 条) |
### 5.7 已删除、**不要再用**的接口与参数
| 已删除 | 原用途 | 现状 |
|---|---|---|
| `POST /api/maintenance/reconcile` | 对账(done 但文件缺失→标记/回队;磁盘上未登记→登记;可选删孤儿) | **接口已删除**(界面「对账」按钮也已删除)。原来的两件事现在由 §3.2 步骤 b 的**媒体扫描**承担:缺失/过小 → 回队;未知 `.ts` → 登记。**已无"删除孤儿"能力** |
| `POST /api/keeps/reconcile` | 同上(歌曲表视角的对账) | **接口已删除**;行为并入媒体扫描 |
| `POST /api/download/enqueue` 的 `mode=auto` | 按热度自动挑歌入队 | **自动入队已删除**;不传 `tids` 一律返回 `{"ok":false,"error":"没有可入队的编号"}`。自动补队的**唯一**路径是维护任务按**歌曲表**补队 |
| (旧界面按钮)「自动入队 N 首」「对账」 | —— | 已从界面删除 |
> 旧配置键 `keep_prune_orphans` / `keep_delete_missing_files` / `keep_reconcile_interval_sec`
> 也已从 `DEFAULTS` 删除(写在配置文件里会被忽略),见 §6.12。
### 5.8 App 协议细节(与真实源一致)
- `i.php` 的签名串:`appid→mac→sn→time→ver→vn`,`sign = md5(原样拼串 + APP_KEY)`;
- `music/do.php` 的签名串:`appid→device→ish265→ls→musicno→resolution→sn→time→token`,
`sign = md5(原样拼串 + SDK_KEY)`(原始 token 里的 `=` 必须原样带上);
- 以上两条已抽成 `resolver.token_params()/token_sign()` 与 `resolver.song_params()/song_sign()`,
并有两条**跑现网代码路径**的回归测试断言 URL 与 HAR 逐字节相同(`tests/test_basic.py::TestCanonicalSign`)。
- whoami 的出站请求形状见 §4.1。
### 5.9 服务端口:App 点歌推送(`/push_download`,2026-10 新增)
**用途**:App 点歌时已经向上游取到了一条可用媒体地址(`…/<编号>.ts?token=…&t=…`)。把它**顺带推一份**
给本服务器,服务器就能省掉一次上游取址(取址有 5~10 秒的全局串行节奏、还会被上游风控),
直接按同一条地址下载入库。实现:`ktv/pushdl.py`(判定/计数)+ `Store.push_enqueue()`(入队)+
`Downloader._download()`(固定地址下载与回退)。
**请求**(`POST /push_download`,服务端口 6677):
```bash
curl -sS -X POST http://<服务器>:6677/push_download \
-H 'X-Push-Token: local' \
--data-urlencode 'url=http://cdn.example.com/480p/7200088.ts?token=abc&t=1' \
-d 'tid=7200088' -d 'name=一帘好梦'
```
> ⚠️ `url` 是**表单字段**,必须整体做百分号编码(`--data-urlencode` 或 App 侧的 `URLEncoder`):
> 真源地址自带 `?token=…&t=…`,不编码的话 `&` 会把它拆成额外字段,服务器只会存下**被截断的地址**
> (下载时会当失败并回退取址,但白跑一次)。App 侧由 `PushDownload.buildForm()` 统一编码,无需关心。
| 项 | 说明 |
|---|---|
| body | `application/x-www-form-urlencoded`(上面这种)或 `application/json`(`{"tid":…,"url":…,"name":…,"token":…}`)都支持 |
| 字段 | `tid`(**必须是正整数**)、`url`(http/https 的媒体地址)、`name`(可选,仅写进日志/核对,**不入库**)、`token`(鉴权串) |
| 鉴权 | 请求头 `X-Push-Token: <push_token>` **或** body 的 `token`,二者取其一即可;不匹配 → **401** `{"ok":false,"error":"鉴权失败"}`。`push_token` 默认 `"local"`(与 `service_token` 同口径,**留空也回落成 `local`**,不会变成"谁都能推") |
| 限流 | 同一 **真实客户端 IP** 每分钟最多 `push_rate_limit_per_min`(默认 60)条;超出 → **429** + 中文错误 + `Retry-After` 响应头(`0` = 关闭限流)。"真实客户端 IP" = 直连时用 TCP 对端;经**反向代理**时**只有**直连对端命中 `trusted_proxies`(且 `trust_proxy_headers=true`)才采信 `X-Forwarded-For` 的**最左侧非代理 IP**(其次 `X-Real-IP`),否则一律用 TCP 对端(防伪造)。来源 IP 会写进事件日志与 `/push_download/status`;核对用 `GET /api/client_ip`(细节与 IIS 配置见 §8.6) |
| 地址闸门 | `url` 必须通过**服务端既有闸门** `resolver.is_trusted_url(url, tid)`(§7.2:必须是域名而非裸 IP、剥离 query/fragment 后路径以 `/<tid>.ts` 结尾、不在 wb66/demo/ac16.vip/ad_files/my_ad_video/pub-*.r2.dev/ss 黑名单);不通过 → **400** + 原因,**绝不入库** |
| 去重(核心) | 按 `tid` 判,命中任一种即**视为重复、不重复下载**:<br>a) `keeps.state='done'` **且** `<media_dir>/<编号>.ts` 确实在 → `reason=already_done`;<br>b) `keeps.state in ('want','downloading')` 或 `downloads.state in ('pending','downloading')` → `reason=already_queued`;<br>c) 同一 tid 在过去 `push_dedupe_sec`(默认 300)秒内已被推送过 → `reason=recently_pushed`。 |
| 接受 | 登记歌曲表(缺行时 `state='want'`、`user='app'`、`note='App 点歌推送'`;**曲库里没有该编号也允许**,`matched=false`)+ 入队下载(`downloads.push_url=<推送 URL>`、`source='app-push'`)→ `{"ok":true,"duplicate":false,"queued":true,"tid":…}` |
> **去重三态的先后顺序**是 a→b→c。因此"接受过的编号在队列被清掉之前再推"通常报 `already_queued`;
> `recently_pushed` 覆盖的是"队列项已经不在了(例如用户先从歌曲表移除)但服务器还记得刚推过"的情况。
> **固定 URL 下载与回退**(`Downloader._download()`):队列项带 `push_url` 时**跳过 `get_song_url()` 取址**,
> 直接用该地址下载(省一次上游请求,也避开取址限流)。该地址**过期/403/被判非 TS/文件过小**时,
> **自动回退到正常取址路径重试一次**,并把"回退"写进事件(便于发现 App 推送的地址是否普遍失效),
> 同时清掉这条死地址(`push_url=''`,避免以后的重试又先撞一次)。固定地址与取址路径**共用同一套
> 地址闸门与 TS/大小校验**(入队判一次、下载前再判一次,任何一道都不得绕过)。
> ⚠️ **限速口径**:固定地址**没有取址请求**,因此不计入"压缩后的取址节奏"(5~10 秒全局串行);
> 但它仍然受**下载池 `concurrency`(≤10)与媒体限速 `media_rate_limit_mb_s`** 约束 ——
> 没有取址请求不等于可以放大并发。
> **事件日志口径**(本项目在意日志量):**接受**每 tid 一条 `INFO`(真实点歌事件,可追溯);
> **重复 / 拒绝 / 限流**按**自然分钟**聚合,分钟翻页(下一次推送或 status 查询)时汇总成一条
> (`App 点歌推送:重复跳过 N 首(already_done=a/already_queued=b/recently_pushed=c)`、
> `App 点歌推送:拒绝 N 条(其中触发限流 M 条),示例:编号=… (地址未通过闸门:…)`)
> —— 这三类才可能被刷屏;拒绝那条**带一条示例明细**,便于定位坏地址是哪来的。
> 未汇总的当前分钟计数在 `/push_download/status` 的 `pending_log` 里可见(不会丢)。
> **计数接口**:`GET /push_download/status` → `accepted / duplicate / rejected / rate_limited / last_at`
> (+ 生效的 `rate_limit_per_min` / `dedupe_sec` / `pending_log`)。计数**只在内存里、重启归零**:
> 它是运维核对用的,没必要为每条推送写一次库。
**App 侧配置**(`KtvStore`,设置页「设置 → 数据设置 → 推送下载」):
| 键 | 默认值 | 说明 |
|---|---|---|
| `pushServerUrl` | **空** | 自建服务器地址(如 `http://192.168.1.10:6677`)。**空 = 关闭该功能**:点歌时连请求都不发(不影响现有用户) |
| `pushToken` | `"local"` | 必须与服务端 `push_token` 一致;留空回落成 `local` |
App 侧行为:在**取址汇合点** `SongApiClient.getSongDownloadUrl()` 拿到地址后异步 POST(后台守护线程、
8 秒超时、fire-and-forget;失败只写**本地**日志 `MiliLog.localWarn("PUSH-DL …")`,用户无感),
并对同一编号做 **5 分钟内存去重**以减少请求(服务器还会再判一次)。
### 5.10 服务端口:App 令牌(`/token`,2026-10 新增)
实现位置:`ktv/apptoken.py`(`TokenService`,挂在 `web.Router.guard` 上,**路由匹配之前**跑);
装配在 `ktv/service.py`,配置键在 `ktv/config.py`。
**用途**:给**未来的自有 App** 一个短时效随机凭证 —— 先取令牌,再带着它调接口,地址就不容易被抓取/转发。
```bash
# ① 取令牌(默认 10 分钟有效;响应带 Cache-Control: no-store,任何中间层都不许缓存)
curl -s http://127.0.0.1:6677/token
# → {"ok":true,"token":"T9mm641G2xpouF82-2DnXfVoJiBcq92JOA0vQRlcZ3c","expires_in":600,"expires_at":1791901656}
# ② 带令牌取址(请求头 X-App-Token;query/body 里的 token 也认,但下面这条更明确)
curl -s 'http://127.0.0.1:6677/music/do.php?musicno=7300123&ls=1&resolution=720' \
-H "X-App-Token: T9mm641G2xpouF82-2DnXfVoJiBcq92JOA0vQRlcZ3c"
# ③ 点歌推送同样可以只带 App 令牌(或继续用 push_token,二者任一)
curl -s -X POST http://127.0.0.1:6677/push_download -H "X-App-Token: <token>" \
--data-urlencode 'url=https://cdn.example.com/480p/7300123.ts?token=abc&t=1' -d 'tid=7300123'
# ④ 运维核对(计数 + 未汇总的当前分钟计数;成功签发/成功校验不逐笔记)
curl -s http://127.0.0.1:6677/token/status
```
**校验与兼容策略(红线)**:
- **默认不强制**(`app_token_enforce=false`):`/music/do.php`、`/i.php` 不强制令牌 ——
已发布的官方 App 直接调这两个接口且**不带**我们的令牌,照常可用(参数与签名算法一个字都没改);
- 但**带了令牌就校验**:带了错/过期令牌一律 **401 + `{"ok":false,"error":"令牌无效或已过期,请重新获取"}`**
(避免"以为生效了");
- **老协议自己的 `token` 参数不算 App 令牌**:`/i.php` 返回的 token(默认 `local`)会出现在
`do.php?...&token=local` 里,`/push_download` 的 body `token` 则是 `push_token` 口径 ——
后端对这两处做了区分(`_is_legacy_param`),否则已发布 App 会被全线 401。**自有 App 请统一用
`X-App-Token` 请求头**(没有任何歧义);
- `/push_download`:**始终**要么有合法 App 令牌、要么有既有 `push_token`,否则 401;
- `app_token_enforce=true` 时:`app_token_paths` 命中的**所有**前缀路径必须带令牌
(含 `/push_download/status`;`/i.php` 只有显式写进 `app_token_paths` 才强制);
- 令牌**绑定签发时的真实客户端 IP**(`app_token_bind_ip=true`):换 IP 用同一令牌 → 401。
反代场景靠 `trusted_proxies` + `trust_proxy_headers` 取真实 IP(见 §5.11 / §8.6);
- 令牌存内存(`dict` + 过期清理),**不为每个令牌写库**;可选 `app_token_persist=true`
把整份令牌表落 `meta` 表(**一行**,重启后仍有效);
- **限流**:同一来源 IP 每分钟最多签发 `token_rate_limit_per_min`(默认 60),超出 429 + `Retry-After`;
- **日志口径**:成功签发/成功校验不逐笔记;"签发 N 个"与"校验失败 N 次"按**自然分钟聚合成一条**
(与 §5.9 的推送聚合同一个套路),当前分钟计数在 `/token/status` 的 `pending_log` 里可见。
### 5.11 双站点反代(API 站 / 媒体站)与宿主隔离
交付物在 `deploy/iis/`:`api-site/web.config`(`mdmuse.kasugano.cn`,HTTPS 443)、
`media-site/web.config`(`md.kasugano.cn`,HTTPS **4433**)、上级 `web.config` 只是索引,
完整步骤与验证命令见 `deploy/iis/README.md`。
后端配合的两个配置项(`ktv/apptoken.py` 的 `host_guard`,同样跑在 `web.Router.guard` 里):
| 键 | 默认 | 作用 |
|---|---|---|
| `media_hosts` | `[]` | **媒体站宿主**(如 `["md.kasugano.cn"]`)。非空才启用宿主隔离 |
| `api_hosts` | `[]` | **API 站宿主**(如 `["mdmuse.kasugano.cn"]`) |
规则(`media_hosts` 为空 = **完全不校验**,单站点部署行为与改造前一致):
- `/media/*` **之外**的路径若来自**媒体宿主** → 404(媒体站只能下文件,接口一律不给);
- `/media/*` 若来自**非媒体宿主**且 `api_hosts` 非空 → 404(API 站不对外提供批量下载);
- 唯一豁免 `GET /api/client_ip`(排错入口;它会返回 `host` / `host_source` /
`media_host_match` / `api_host_match`,宿主配错时一眼能看出判定成了什么);
- 宿主判定用**经过 `ktv/proxy.py` 可信校验**的 `X-Forwarded-Host`(不可信时退回 `Host` 头)——
否则任何直连客户端自己塞一个 `X-Forwarded-Host: md.kasugano.cn:4433` 就能绕过隔离;
- **端口容错**:`X-Forwarded-Host` 常常带端口(媒体站是 `:4433`),配置写 `md.kasugano.cn`
也能命中 `md.kasugano.cn:4433`(两侧都按"去端口"再比一次,大小写不敏感)。
## 6. 配置项表
> 全部键定义在 `ktv/config.py` 的 `DEFAULTS`;配置文件默认 `/etc/ktv-source/server-config.json`。
> 管理界面「设置」只暴露其中一部分,其余直接编辑 JSON 后重启,或用 `POST /api/settings` 传 patch。
> `Config.update()` 会做钳制并返回 `warnings`(**不阻断保存**)。
> `config_version` 用于一次性迁移旧默认值(当前 2;迁移规则见 §11.7 的说明)。
### 6.1 监听与存储
| 键 | 默认值 | 作用 | 什么时候要改 |
|---|---|---|---|
| `service_host` | `0.0.0.0` | 服务端口监听地址(App/媒体) | 要只在内网暴露时改成内网地址 |
| `service_port` | `6677` | 服务端口 | 与 App/端口映射冲突时改 |
| `admin_host` | `0.0.0.0` | 管理端口监听地址 | **建议只绑内网地址**(管理端不要对公网开) |
| `admin_port` | `6678` | 管理端口 | 冲突时改 |
| `admin_token` | 空 | 非空则管理 `/api/*` 需 `X-Auth-Token`/`?token=`/`Bearer` | 管理端口不得已要暴露时设一个强令牌 |
| `data_dir` | `/var/lib/ktv-source` | 数据目录(db/日志/默认身份文件/默认 tmp) | 数据要挪到别的盘时改(**FUSE/云盘要配 `db_journal_mode=DELETE`**) |
| `media_dir` | `/var/lib/ktv-source/media` | 媒体文件落地目录 | 媒体要放云盘时改 |
| `tmp_dir` | 空(= `<data_dir>/tmp`) | 分片/`.part` 临时目录 | **必须在本地盘**:放云盘会无谓上传且 FUSE 随机写慢 |
| `db_path` | `/var/lib/ktv-source/server.db` | SQLite 主库 | 一般不用改(跟随 `data_dir` 手工改) |
| `log_path` | `/var/lib/ktv-source/server.log` | 文件日志 | 日志要单独分区时改 |
| `db_journal_mode` | `WAL` | SQLite journal 模式 | **数据目录在 FUSE/云盘/网络盘时必须改 `DELETE`**(WAL 需要 mmap `-shm`,这类文件系统上会 SIGBUS);本地盘保持 WAL |
| `config_version` | `2` | 配置版本(迁移用) | **别手改** |
### 6.2 对 App 暴露的自身信息
| 键 | 默认值 | 作用 | 什么时候要改 |
|---|---|---|---|
| `public_media_base` | `http://ktv.example.com:6677/media` | 对 App 暴露的媒体前缀,`do.php` 用它拼 `<前缀>/<编号>.ts` | **部署必改**:换成你的域名;**必须是域名**(App 拒收裸 IP),建议以 `/media` 结尾 |
| `public_host` | `ktv.example.com:6677` | 返回地址用的主机(含端口) | 与 `public_media_base` 的域名保持一致 |
### 6.3 上游歌曲源与取址节奏
| 键 | 默认值 | 作用 | 什么时候要改 |
|---|---|---|---|
| `upstream_hosts` | `["http://zk.ktv1.top:6677"]` | 上游歌曲源,可多个按序尝试 | 换源/加源时改;健康度与冷却见 §5.5 |
| `upstream_resolution` | `"720"` | 请求分辨率(`do.php` 的 `resolution`) | 上游只有其它分辨率时改 |
| `upstream_mac` | 空 | 上报给上游的 MAC | **空 = 跟随自动生成的盒子身份**;只有要写死身份(排错)时才填 |
| `upstream_sn` | 空 | 上报给上游的 SN | 同上 |
| `upstream_interval_min_ms` | `5000` | 取链(`i.php`/`do.php`)之间的**最小**随机间隔 | **目的:避免被上游判定为攻击**(风控/封禁)。**别往小改**;配置校验 <5000 会给警告 |
| `upstream_interval_max_ms` | `10000` | 随机间隔上限(与最小值之间随机取值,避免固定节奏) | 同上;必须 ≥ 最小值(保存时会自动钳制) |
| `upstream_request_interval_ms` | `300` | **旧字段**:仅当上面两项缺失时兜底 | 不用改(保留兼容) |
| `upstream_token_fail_cooldown_sec` | `900` | 某源连续 3 次 `i.php` 拿不到 token → 冷却这么久 | 上游长期限流、想让某源更快回候选时调小 |
> 限速是**全局串行**的(`resolver._pace()` 内一把锁):10 个下载线程共用同一节奏,不会随并发放大;
> `/api/upstream/probe` 这类一次性批量取址也走同一节奏,**不允许绕过**(§8.4 第 8 条)。
### 6.4 设备身份
| 键 | 默认值 | 作用 | 什么时候要改 |
|---|---|---|---|
| `device_identity_auto` | `true` | 自动生成/载入"安卓盒子"身份(`device-identity.json`) | **只在排错时**改 `false`(会上报真实本机机型) |
| `device_identity_file` | 空(= `<data_dir>/device-identity.json`) | 身份文件路径(含 mac/sn,0600) | 要把身份文件放到别处(随数据目录走)时改;建议填**绝对路径** |
| `device_identity_preset` | 空(= 随机模板) | 指定盒子模板:`aml_s905x`/`aml_s905x3`/`aml_s905x4`/`rk3328`/`rtd1295` | 想固定成某型号时填;**保存后会立刻按新模板重新生成身份**(mac/sn 变化) |
| `device_identity_ttl_hours` | `24` | 身份有效期(小时):超期由维护任务自动换新 | 想让身份更频繁/更少更换时调;`0` = 关闭时效检查(永久固定) |
### 6.5 whoami 心跳
| 键 | 默认值 | 作用 | 什么时候要改 |
|---|---|---|---|
| `whoami_enabled` | `true` | 心跳**总开关**;false 时后台线程一次都不发(`/api/whoami/send` 仍可手工) | 上游不接受心跳/排错时可关 |
| `whoami_url` | `http://hbeat.ktvsky.com/whoami` | 上报地址(抓包值) | 上游换地址时改;必须以 `http://`/`https://` 开头 |
| `whoami_interval_sec` | `3600` | 响应没给 `next_interval` 时的间隔(**夹在 60s ~ 24h**) | 想更勤/更省时改 |
| `whoami_ktv_id` | `"10006789"` | `ktv_id`(门店/包厢 id,抓包值) | 有真实门店号时填(显式配置优先) |
| `whoami_src` | `"600007"` | `_src` 渠道来源(抓包值) | 一般不用改 |
| `whoami_src_at` | `"600001"` | `_src_at` 归因渠道(抓包值) | 一般不用改 |
| `whoami_api` | `31` | `_api`(Android API 级别,31=Android 12) | 显式改它时,`d_release` 与指纹版本段会**同步改**以保持自洽(否则一眼是假的) |
| `whoami_pkn` | `"com.origjoy.local.ktv"` | `_pkn` 包名 | 一般不用改(伪装成原版包名) |
| `whoami_manufacturer` | 空 | `_manuf` 厂商 | 空 = 跟随盒子身份;**有身份时只在显式写过才生效** |
| `whoami_model` | 空 | `_m` 机型 | 同上 |
| `whoami_cid` | 空 | `_cid` | 空 = 跟随身份(`A_<mac>`);同上 |
### 6.6 歌曲表自动维护
| 键 | 默认值 | 作用 | 什么时候要改 |
|---|---|---|---|
| `maintain_enabled` | `false` | 服务启动时是否**自动**开始维护 | 想让机器开机就跑就改 `true`(旧键 `batch_enabled=true` 等效) |
| `maintain_interval_sec` | `600` | 每轮间隔(秒);本轮结束时间 + 它 = 下次开跑 | 想跑更勤/更省时改;**下限 30** |
| `maintain_scan_media` | `true` | 每轮是否扫描媒体目录(登记新文件 + 缺失回队) | 媒体目录很慢/挂载不稳时可关(改用 `POST /api/keeps/scan` 手工扫) |
| `maintain_retry_failed` | `true` | 每轮是否把 `failed`/`missing` 重置为待下载 | 想手工控制重试时关 |
| `maintain_heartbeat` | `true` | 有歌要下时是否启停 whoami 心跳 | 不想在维护轮里发心跳时关(`whoami_enabled` 是总开关) |
| `maintain_drain_timeout_sec` | `3600` | 单轮"等队列排空"最长等多久(秒) | 单轮要下很多、间隔又要短时调大;希望更快轮转时调小 |
| `maintain_legacy_daemons` | `false` | 维护未启动时是否退回旧的三个独立守护线程 | **新部署保持 false**;老部署排错时才开 |
### 6.7 媒体目录扫描
| 键 | 默认值 | 作用 | 什么时候要改 |
|---|---|---|---|
| `media_scan_enabled` | `true` | 是否允许媒体扫描(维护任务步骤 b / `POST /api/keeps/scan`) | 不想自动登记时关 |
| `media_scan_interval_sec` | `600` | 旧的独立扫描线程间隔(默认不走,见 §3.5);维护任务用的是 `maintain_interval_sec` | 一般不用改;`0` = 关闭 |
| `media_scan_min_bytes` | `6291456`(6 MiB) | 视为有效媒体的最小字节数;**0 = 跟随 `min_file_bytes`** | 想更宽松地认领历史文件时调小(注意别把半截图当有效媒体) |
| `media_scan_timeout_sec` | `30` | 列举媒体目录的超时(秒);超时 = 本次扫描放弃且**不改状态** | 云盘/FUSE 上 `listdir` 慢(常见)时调大,例如 120 |
### 6.8 下载与队列
| 键 | 默认值 | 作用 | 什么时候要改 |
|---|---|---|---|
| `concurrency` | `10` | 同时下载数 | **硬上限 10**:改大也会被压回 10 并给提示;上游/磁盘吃不住时调小 |
| `download_order` | `"hot"` | 补队排序:`hot`(热度)/`tid`(编号)/`name`(歌名)/`lang`(语言) | 想先下冷门/按编号下时改 |
| `download_order_desc` | `true` | 排序方向(true = 降序) | 与上一项配合 |
| `verify_md5` | `false` | 是否额外校验媒体 md5 | 上游一般不给 md5;想核对时开 |
| `min_file_bytes` | `6291456`(6 MiB) | 视为有效媒体的最小字节数(与 App 的 `SongFileValidator` 同口径) | **不要调小**(会把广告/半截图当歌);调大更严格 |
| `max_attempts` | `3` | 单首最大下载尝试次数 | 上游抖动大时可调大 |
| `retry_backoff_sec` | `20` | 失败后退避窗口(仅对重试生效,新任务立即可下) | 上游限流严重时调大 |
| `timeout_sec` | `60` | 单次 HTTP 超时(HEAD/GET/取址) | 上游慢时调大 |
| `user_agent` | `"MaidongKTV/1.2"` | 下载自己发请求时的 UA(取址走 App 口径,不用它) | 一般不用改 |
| `push_token` | `"local"` | **App 点歌推送**(§5.9)的鉴权串:请求头 `X-Push-Token` 或 body `token`。**留空回落成 `local`** | App 侧 `pushToken` 改成别的值时同步改 |
| `push_rate_limit_per_min` | `60` | 推送限流:同一对端 IP 每分钟最多接受多少条(`0` = 关闭) | 设备多/被误刷时调大;想更严就调小 |
| `push_dedupe_sec` | `300` | 推送去重窗口(秒):同一编号在此窗口内重复推送直接跳过;`0` = 关闭此项判重 | 想让"刚移除又重推"立刻生效就调小 |
> 上面三个键只影响**服务端收到推送之后**的行为;App 侧默认**不推送**(`pushServerUrl` 为空 = 关闭),
> 不配置它们就完全收不到推送,保持默认即可。
### 6.9 失败任务自动重试
| 键 | 默认值 | 作用 | 什么时候要改 |
|---|---|---|---|
| `retry_failed_enabled` | `true` | 是否允许自动重试失败任务 | 想完全手工控制时关 |
| `retry_failed_interval_sec` | `600` | 旧的独立重试线程间隔(默认不走,见 §3.5);维护任务每轮都重试 | 一般不用改;`0` = 关闭 |
| `retry_failed_max_attempts` | `3` | 自动重试用的"重试上限";**0 = 跟随 `max_attempts`** | 想统一跟随 `max_attempts` 就填 0 |
> 跳过条件(**预期行为**,不是故障):下载池被**人工暂停**、**存储守卫水位不足**、下载池因空间暂停、
> 或重置数量为 0(N==0 不写日志)。`GET /api/download/status` 的 `auto_retry.skipped_reason` 会写明原因。
### 6.10 存储守卫与限速
| 键 | 默认值 | 作用 | 什么时候要改 |
|---|---|---|---|
| `local_space_path` | `"/"` | 本地盘(上传缓冲所在)路径,看它的剩余 | 上传缓冲不在 `/` 时改 |
| `min_free_local_bytes` | `21474836480`(20 GiB) | 本地盘剩余低于此值 → 暂停下载 | 本地盘小/大时按比例调 |
| `min_free_media_bytes` | `21474836480`(20 GiB) | 媒体所在文件系统剩余低于此值 → 暂停下载 | 云盘配额小/大时调 |
| `resume_free_margin_bytes` | `5368709120`(5 GiB) | 恢复阈值 = 下限 + 该余量(迟滞,避免抖动) | 水位在阈值附近反复暂停/恢复时调大 |
| `storage_check_interval_sec` | `15` | 水位检查间隔(秒,最小 5) | 一般不用改 |
| `pause_on_low_space` | `true` | 水位不足是否自动暂停下载池 | 想只告警不暂停时关(**不建议**) |
| `media_rate_limit_mb_s` | `5.0` | 媒体下载**聚合限速**(MB/s) | **必须 ≤ 云盘上传速度**(50 Mbps ≈ 6.25 MB/s → 填 5);上传更快时可调大 |
| `cloud_upload_mbps` | `50` | 云盘上传带宽(Mbps),**仅界面提示/推算用** | 换套餐时改,仅影响提示 |
> 媒体在云盘/FUSE 上时:写入先落**本地缓冲**再由云盘客户端异步上传;下载速度长期高于上传速度会把
> 本地盘撑满。暂停有**归属保护**:空间暂停不会被手动"继续"解除,手动暂停也不会被"空间恢复"解除。
### 6.11 曲库同步 / 性能 / 旧批量兼容
| 键 | 默认值 | 作用 | 什么时候要改 |
|---|---|---|---|
| `muse_login_url` | `https://mconn.cherryonline.cn/mls-api/v1/login` | 官方曲库登录地址 | 官方换域名时改 |
| `muse_sync_url` | `https://mconn.cherryonline.cn/mls-api/v1/sqlite/sync` | 官方曲库同步地址 | 同上 |
| `muse_device_id` | `"s00e04b021b2d"` | 同步用的设备号 | 被风控/换设备时改 |
| `muse_music_file_type` | `214` | 同步请求的媒体类型(抓包值) | 一般不用改 |
| `muse_auto_sync_hours` | `24` | 定时同步曲库(小时);`0` = 关闭 | 想每天自动同步保持 24,想手工点就填 0 |
| `stats_cache_sec` | `4` | `/api/stats` 全表统计/TTL 缓存秒数(`0` = 不缓存) | 库很大、管理页轮询重时调大;排错时填 0(写入 keeps/songs/downloads 会自动失效缓存) |
| `batch_enabled` | `false` | **旧键**:开机是否自动跑批量 → 现在等价于"自动开始维护" | 老部署沿用即可,新部署用 `maintain_enabled` |
| `batch_target_pending` | `200` | 队列里始终保持多少首待下载(维护任务补队目标) | 想把队列压深/压浅时改 |
| `batch_check_interval_sec` | `30` | 旧批量驱动器巡检间隔(仅 legacy 模式) | 一般不用改 |
| `batch_report_interval_sec` | `600` | 旧批量驱动器进度报告间隔(仅 legacy 模式) | 一般不用改 |
| `batch_max_consecutive_failures` | `20` | 旧批量:连续失败达到该值 → 致命停止 | 仅 legacy 模式生效 |
| `batch_low_space_checks` | `5` | 旧批量:空间不足连续 N 次检查 → 致命停止 | 仅 legacy 模式生效 |
| `batch_stale_minutes` | `20` | 旧批量:无新完成超过 N 分钟 → 致命停止 | 仅 legacy 模式生效 |
| `batch_fatal_auto_retry` | `true` | 旧批量:致命停止后自动重试(自愈) | 仅 legacy 模式生效 |
| `batch_fatal_retry_minutes` | `60` | 旧批量:首次自动重试等待 | 仅 legacy 模式生效 |
| `batch_fatal_retry_backoff` | `2.0` | 旧批量:每次失败后等待翻倍 | 仅 legacy 模式生效 |
| `batch_fatal_max_retries` | `6` | 旧批量:最多自动重试几次(含首次) | 仅 legacy 模式生效 |
| `batch_retry_reset_failed` | `true` | 旧批量:重试前把 failed 任务重置 | 仅 legacy 模式生效 |
### 6.12 已删除的配置键(写了会被忽略)
| 键 | 原默认值 | 现状 |
|---|---|---|
| `keep_prune_orphans` | `false`(更早是 `true`) | **已从 `DEFAULTS` 删除**:媒体扫描发现未知文件一律**登记**,不存在"删孤儿";删本地文件只有「从歌曲表移除」一条路径 |
| `keep_delete_missing_files` | —— | **已删除**;`done` 但文件缺失/过小一律**回队**,不删登记 |
| `keep_reconcile_interval_sec` | —— | **已删除**;对账接口与独立定时器都没了,缺失回队并入维护任务的媒体扫描 |
> 老配置文件里残留这些键不会报错(`Config.load()` 只认 `DEFAULTS` 里有的键),但**不会生效**。
### 6.13 反向代理(IIS / ARR)与 HTTP 层
> 部署形态、IIS 侧的完整配置与排错见 §8.6 与仓库里的 `deploy/iis/`(`web.config` +
> `applicationHost.config.snippet.xml` + `README.md`)。
| 键 | 默认值 | 作用 | 什么时候要改 |
|---|---|---|---|
| `trust_proxy_headers` | `false` | 是否采信代理头(`X-Forwarded-For`/`X-Real-IP`/`X-Forwarded-Proto`/`X-Forwarded-Host`)的**总开关** | **反代部署必须打开**(`true`):否则推送限流会串成一份、日志看不到设备 IP、HTTPS 下 `do.php` 会返回 http 内网地址 |
| `trusted_proxies` | `[]` | **可信代理**的 IP/CIDR 列表(字符串数组,也可手写 `"127.0.0.1,192.168.100.0/24"`) | 反代部署必填**代理机器的 IP**(IIS 机器的内网 IP)。只有直连对端命中它,才采信代理头;为空 = 一个都不信(等于关闭) |
| `http_backlog` | `128` | 监听队列长度(`ThreadingHTTPServer.request_queue_size`,范围 5~4096);**改后需重启进程**(socket 的 `listen()` 只在启动时做一次) | IIS/负载均衡探活 + 设备突发请求时"连不上/被拒"就调大 |
| `max_body_bytes` | `8388608`(8MiB) | 单个请求体上限(CSV 导入等,范围 4KiB~1GiB);超限 → **413 + 中文说明**;**改完立刻生效** | 要导入更大的 CSV 时调大;**同时**要把 IIS 的 `maxAllowedContentLength` 调到 ≥ 它(否则 413 由 IIS 先返回) |
口径与安全前提(`ktv/proxy.py`):
- 默认**全关**:`trust_proxy_headers=false` 时一个代理头都不看,行为与直连完全一致;
- 即使打开,也**只有**直连对端 IP 命中 `trusted_proxies` 才采信(任何人都能自己塞
`X-Forwarded-For`,所以"不可信就一律用 TCP 对端"是硬前提);
- `X-Forwarded-For` 取**最左侧非代理 IP**(客户端伪造"内网/回环 IP 放最左"会被跳过);
整条链都是可信代理时退回最左侧(原始客户端),不会退化成代理地址;
- `X-Forwarded-Proto` 只认 `http`/`https`;`X-Forwarded-Host` 只接受域名 / `IPv4[:端口]` /
`[IPv6][:端口]` 形式(含 `/`、空格、CRLF 的一律丢弃,避免拼出坏地址或头注入);
- 真实客户端 IP 用于:`/push_download` 限流、所有 HTTP 访问日志、事件日志里的"来源IP=";
绝对地址(`/music/do.php` 的 `data`、以及任何重定向用的 `proxy.absolute_url()`)也会在可信时
使用代理头的 scheme/host。
HTTP 层(`ktv/web.py`):
- 每个响应都带精确 `Content-Length`(HTTP/1.1 keep-alive 的前提,缺了代理会挂住);
- 媒体**流式分块**发送(256KB),不整块读入内存;`HEAD`/`Range` 与直连行为一致(206 +
`Content-Length` + `Accept-Ranges`);
- 请求体超过 `max_body_bytes` → **413 + 中文说明**,并主动 `Connection: close`(避免剩余字节
被当成下一个请求,管道错位);
- `http_backlog` 写入 socket 的 `listen()` backlog(在 `server_bind` 之前生效)。
`/healthz` 为什么快(`ktv/health.py`):
- 探活请求**只读内存快照**(微秒级),不查库、不列举媒体目录;
- 数字由后台守护线程每 30 秒刷一次(`store.stats()` / `store.keep_stats()` 本来就是 TTL 缓存的
全表统计,放在后台跑就不会拖住探活);
- 刷新失败只记**一条**事件并沿用上次的值(探活照样 200:进程还在服务就行);
- `snapshot_age_sec` = 快照距今多少秒(`-1` = 还没刷新过),运维一眼看出新鲜度。
长任务与超时(IIS 侧建议 §8.6):曲库同步、扫描、`run_now`、下载队列入队等**都是"发起即返回"**;
真正可能长时间占用连接的是**媒体下载**(大文件)与**大 CSV 上传**,IIS/ARR 的超时要给足
(建议 600 秒)。
### 6.14 App 令牌(`/token`)与双站点宿主隔离(2026-10 新增)
> 用法与兼容策略见 §5.10(App 令牌)与 §5.11(双站点反代);IIS 侧交付物见 `deploy/iis/`。
> **默认值全部是"不改变现有行为"**:不强制令牌、不校验宿主。
| 键 | 默认值 | 作用 | 什么时候要改 |
|---|---|---|---|
| `app_token_ttl_sec` | `600` | 令牌有效期(秒),钳制 30~86400 | 觉得 10 分钟太短/太长时改(**不建议超过 1 小时**) |
| `app_token_bind_ip` | `true` | 令牌绑定**签发时的真实客户端 IP**(换 IP → 401) | 客户端网络会变(多出口/常换 NAT)时可关,但会削弱凭证强度 |
| `app_token_single_use` | `false` | 用过即失效 | **一般别开**:媒体地址是多次调用(8 秒一次心跳、断点续传都算) |
| `app_token_persist` | `false` | 令牌落 `meta` 表(整表一行),重启后仍有效 | 进程会频繁重启、又不想让 App 重新取令牌时开 |
| `app_token_paths` | `"/music/do.php,/push_download"` | 受令牌管理的**路径前缀**(逗号分隔) | 自有 App 还要保护别的接口时加;`/i.php` 要强制就显式加进来 |
| `app_token_enforce` | `false` | `true` = 上面那些路径**必须**带令牌 | **见下方"何时打开"** |
| `token_rate_limit_per_min` | `60` | 同一来源 IP 每分钟最多签发几个(0 = 关限流) | 正常 10 分钟取一次,60 足够;被刷就调小 |
| `media_hosts` | `[]` | 媒体站宿主列表(如 `["md.kasugano.cn"]`) | 按 `deploy/iis/README.md` 拆两个站点时配 |
| `api_hosts` | `[]` | API 站宿主列表(如 `["mdmuse.kasugano.cn"]`) | 同上(配了它,非媒体宿主拉 `/media/*` 就 404) |
**何时打开 `app_token_enforce`**(默认 `false`,**别提前打开**):
1. **前提**:自有 App 已经上线,并且它的每一次 `/music/do.php`、`/push_download` 调用都会先
`GET /token` 拿令牌、带上 `X-App-Token`(令牌过期要能自动重取);
2. **确认现场没有老客户端**:所有在用设备要么是自有 App,要么已停止取址。老 App **不会**带令牌,
打开强制后它们会全部 401 —— 这是本项唯一的"破坏性"开关;
3. **先在小范围验证**:把 `app_token_paths` 只写一条路径(如 `/push_download`)打开强制,
观察事件日志里没有 `App 令牌:校验失败` 的持续增长,再扩大到 `/music/do.php`;
4. **回滚很简单**:`app_token_enforce=false` 立刻恢复"只有带了才校验"的行为(改完立刻生效,无需重启)。
> 相关日志(`GET /api/events`):启动时会写一条
> `App 令牌:有效期 600 秒、绑定客户端IP=True、…、强制=False、受管路径=[…]`,
> 以及(`media_hosts` 非空时)`双站点宿主隔离已启用:媒体宿主=[…]、API 宿主=[…]`。
## 7. 媒体地址闸门规则(服务端与 App 一致)
### 7.1 服务端返回给 App 的地址
- `GET /music/do.php` 的 `data` 由 `public_media_base` 拼成:`<public_media_base>/<编号>.ts`;
- **必须是域名**:`public_media_base` 不能是裸 IP(App 的 `isDemoUrl`/`isValidSongUrl` 会拒收);
内网可自建 DNS(dnsmasq/AdGuard/路由器 hosts)把域名指到本机;
- 已下载完成(`downloads.state='done'`)才返回 `code:200`;否则 `code:404`。
### 7.2 闸门判据(`resolver.is_trusted_url()`,与 App 侧同一套规则)
服务端从上游取到的地址也要过闸门(否则会把广告/测试资源当歌曲下载入库):
1. **必须是 `http(s)://`**,且 **host 必须是域名** —— 拒绝裸 IP(`http://1.2.3.4[:port]/`)。
2. **一律先剥离 query/fragment 再判定**(`resolver.split_url()`,底层 `urllib.parse.urlsplit`):
`.ts` 文件名比对只看 **path**;广告特征只看 **host + path**(**不搜 query** —— query 里是签名 token,
拿整串搜 `wb66|demo` 会误杀合法真源)。
3. **路径里必须出现 `/<编号>.ts`**(path 匹配 `/([^/]+)\.ts(?:/|$)`,即 `.ts` 后跟 `/` 或路径结束),
且**文件名必须等于点歌编号**;路径里没有 `.ts` 时不因这条被拒(广告黑名单仍会拦)。
4. **黑名单(命中任一即拒)**:
| 特征 | 判据 |
|---|---|
| `wb66` / `demo` | host 或 path 含(不区分大小写) |
| `*.ac16.vip` | host 命中(含裸 `ac16.vip`) |
| `ad_files` / `my_ad_video` | path 含 `/ad_files/` 或 `my_ad_video` |
| `pub-*.r2.dev/ss/` | host 或 path 命中 `pub-<随机>.r2.dev/ss/` |
| 裸 IP | host 是 IPv4 字面量 |
5. **没有域名白名单、只有黑名单**:新真源域名(如 `txog.ktvsky.com`)直接通过。
判不通过时 `get_song_url()` 返回 `None` + `地址不可信(<原因>)`,下载任务按失败处理并记日志
(`地址不可信(命中 wb66/demo)` / `(host 是裸 IP…)` / `文件名 123.ts != 编号 456` 等)。
### 7.3 文件校验(下载完成后,缺一不可)
- **大小**:≥ `min_file_bytes`(默认 6 MiB = 6291456)——与 App 的 `SongFileValidator` 同口径。
- **真 TS(188 字节包)**:判定是**扫描式**的 —— 读头部 8192 字节,在偏移 `0..4095` 内找连续 3 个
188 字节包同步点(`0x47` 同时出现在 `off`/`off+188`/`off+376`)。
因此上游 `download.origjoy.com` 那种「**512 字节私有头 + TS**」的合法流**不会被误杀**,
内容**按原样落盘**,日志记 `媒体含 512 字节私有头(TS 从该偏移开始),按原样落盘`。
- **完整性**:若 `HEAD` 拿到了 `Content-Length`,下载字节数不足也判失败(`下载不完整 N/M B`)。
- `verify_md5=true` 时才额外算 md5(上游一般不提供媒体 md5)。
- 落盘流程:`.part` 写本地 `tmp_dir` → 完成 `os.replace` 进 `media_dir`(跨文件系统则 copy+remove)
→ 写 `downloads` 与 `keeps` 状态。
### 7.4 「未找到」绝不能用 403 表达
App 会把 **403 当成源站风控**(弹"换 IP/被限流"之类提示),所以:
- 曲库里没有这个编号 / 还没下载完成 → `GET /music/do.php` 返回 **`{"code":404,"msg":"not in library"}`**
或 **`{"code":404,"msg":"not ready: <state>"}`**;
- 媒体文件不存在 → `/media/<编号>.ts` 返回 **HTTP 404**(日志 `WARN 媒体缺失 编号=…`);
- 服务端**任何路径都不返回 403** 来表达"没有/还没好";地址闸门拒绝的地址也只在**下载侧**记失败,
不会以 403 回给 App。
## 8. 部署、首次使用与运维要点
### 8.1 部署(Debian 13.7)
```bash
# ① 依赖:只要系统 python3(Debian 13 自带 3.13),无需 pip/venv
apt-get update && apt-get install -y python3 curl
# ② 建用户与目录
useradd --system --home /var/lib/ktv-source --shell /usr/sbin/nologin ktv || true
install -d -o ktv -g ktv /opt/ktv-source /var/lib/ktv-source /var/lib/ktv-source/media /etc/ktv-source
# ③ 放代码(把本目录 server/ 整个拷过去)
rsync -a --delete ./ /opt/ktv-source/ && chown -R ktv:ktv /opt/ktv-source
# ④ 放 systemd 单元并启动
install -m 644 deploy/ktv-source.service /etc/systemd/system/
systemctl daemon-reload && systemctl enable --now ktv-source
systemctl status ktv-source --no-pager
# ⑤ 防火墙(服务端口对设备开放;管理端口只给自己/内网)
ufw allow 6677/tcp
ufw allow from 192.168.0.0/16 to any port 6678 proto tcp
```
或不手工敲:以 root 跑 `bash deploy/deploy-root.sh`(会做用户/目录 → 装代码 → 写默认配置 → 装单元 →
启动自检)。首次启动会自动生成 `/etc/ktv-source/server-config.json`(在管理界面里改也一样)。
自测(不启动服务):`cd /opt/ktv-source && python3 -m unittest discover -s tests -v`。
**部署后必改**:`public_media_base`(域名)、`upstream_hosts`、以及数据/媒体目录(若不在 `/var/lib`)。
### 8.2 首次使用流程(现状)
1. 打开 `http://<服务器>:6678/` → **概览** → 「同步曲库」(后台跑,概览有进度与最近同步任务)。
2. **曲库清单**页:搜索/按语言/按状态筛选,确认歌曲总数与版本。
3. **把要的歌加入「歌曲表」**(任选一种):
- 清单页勾选 → 「加入歌曲表(选中)」/「加入本页全部」;
- 清单页「按当前筛选条件批量加入(可填数量)」;
- 歌曲表页直接填「编号」(逗号/空格分隔)→「加入歌曲表」;
- 导入只含「编号」的 CSV(`mode=merge`)。
4. **设置**页:上游歌曲源、媒体域名、并发数、下载排序、取址间隔(保持 5~10 秒)、限速、媒体目录、
`maintain_*` 各项。
5. 概览 →「歌曲表自动维护」→ **启动**(想先试跑就点「立即维护一次」)。
想开机自动维护:`maintain_enabled=true`。
6. **歌曲表**页看进度(总数/已下/待下/失败/曲库未匹配/占用);「扫描媒体目录」可随时手工登记已有文件
(迁移/手工拷贝进来的文件用这里认领)。
7. 设备端把源指向本服务器(§1.2)。
### 8.3 现场定制(与仓库默认不同,升级时别覆盖)
- **现场 systemd 单元**是 `User=zijun` + `ProtectHome=no`:数据/媒体在 `/home/zijun/…` 的 **FUSE 云盘**挂载上,
FUSE 只认挂载参数里的 `uid=1000`(zijun),且 `ProtectHome=yes` 会把 `/home/zijun` 隐藏起来。
仓库里的 `deploy/ktv-source.service` 是"数据在 `/var/lib/ktv-source`"的**默认版**
(`User=ktv`、`ProtectHome=true`),**不要用它覆盖现场单元**。
- **`deploy/deploy-root.sh` 的三条保护**(都是为现场定制而加,别删):
1. **已存在的 systemd 单元不覆盖**(保留 `User=`/`ProtectHome=`/`ReadWritePaths=`),只在缺失时装默认单元;
2. **不夺权已存在的目录**:目录只在缺失时 `install -d`,已存在的一律不动归属/权限;
3. 运行身份可配:`SVC_USER`(默认 `ktv`;FUSE 场景用 `SVC_USER=zijun bash deploy-root.sh`)。
- **更新代码后必须重跑 `deploy/deploy-root.sh`**(它把 `~/ktv-src` 同步到 `/opt/ktv-source`);
只 `systemctl restart` 会继续跑旧代码。核对:本地与 `/opt` 各跑一遍
`find ktv -name '*.py' | xargs md5sum` 比对清单哈希。
- 单元里 `ProtectSystem=true`(**不要用 `full`**:会把 `/etc` 挂成只读 → 管理界面保存配置 500),
并保留 `ReadWritePaths=/var/lib/ktv-source /etc/ktv-source`;
⚠️ 单元文件里**不要写行内注释**(`ProtectSystem=true # 说明` 会被 systemd 解析成 `no`)。
### 8.4 运维要点与踩坑(现象 → 原因 → 处置)
| # | 现象 | 原因 | 处置 |
|---|---|---|---|
| 1 | 进程 `active`,但 6677/6678 **不监听**;或服务起来了"下载 worker 不动作" | 媒体目录在 **FUSE 云盘**挂载上,启动期 `stat`/`listdir` 可能长时间阻塞;一旦挡在监听之前,端口就永远起不来(历史上出现过两次) | "碰媒体/网络"的动作必须在**两个端口监听之后**、以后台线程启动并带超时:`maintain.start()` 在监听之后(`main.py` 顺序刻意如此);扫目录走 `keep_adopt_media()` 的带超时子线程(`media_scan_timeout_sec`);whoami 在后台守护线程里。**不要把这些动作挪到监听之前,也不要改成同步执行**。自查 `ss -lntp \| grep -E ':(6677\|6678)'` |
| 2 | 服务被 **SIGBUS** 打死(`systemctl status` = `status=7/BUS`)、无限重启 | 数据目录在 FUSE/云盘上时,SQLite `WAL` 需要 mmap 一个 `-shm` 文件,FUSE 不支持;**残留的 `-wal` 在打开时的恢复阶段**就会爆(不是运行期) | 数据目录在 FUSE/网络盘上时**必须** `db_journal_mode=DELETE`(`Store` 在 WAL 不可用时也会自动回退 DELETE);搬迁前在**本地盘**把 DB(+wal+shm) 复制出来,`wal_checkpoint(TRUNCATE)` + `journal_mode=DELETE` + `VACUUM` 后再放回。本项目数据目录现在在**本地盘**、用 `WAL`;退出路径会 `Store.checkpoint()` 把 WAL 落回主库(避免"重启后刚加的行不见") |
| 3 | 担心"扫描把文件删了""导入把歌删了" | 旧版本有对账/孤儿删除/非原子整表替换 | 删本地文件**唯一入口**是 `Store._safe_delete_media()`,三道校验缺一不可:① realpath 解析后仍**严格位于 `media_dir` 之下**(前缀 + 父目录双比较,防 `..`/软链接逃逸);② 文件名必须严格是 `<编号>.ts`;③ 必须是**常规文件**。失败只记日志、绝不抛异常。媒体扫描**只增不删**;整表替换是原子的且空 CSV 会被拒绝。要删文件就用「从歌曲表移除」 |
| 4 | 一次移除几百首,请求长时间不返回/超时 | FUSE 上删文件很慢(**现场实测约 1 秒/个**) | 批量删除要**分批 + 放宽超时**(界面按钮超时 20s),不要放在同步请求里做超大操作;量大就分几次「移除本页全部」 |
| 5 | 更新了代码,行为却没变 | 只 `systemctl restart`,`/opt/ktv-source` 还是旧代码 | 重跑 `deploy/deploy-root.sh`,并用 md5 清单核对(§8.3) |
| 6 | 管理界面「保存」报 500,日志见 `EROFS` | 单元里 `ProtectSystem=full` 把 `/etc` 挂成只读 | 改 `ProtectSystem=true` + 保留 `ReadWritePaths=/etc/ktv-source`;单元文件不写行内注释(§8.3) |
| 7 | 明明没在下载,却发现心跳在发;或"怎么一直不发心跳" | 心跳由维护任务启停,不是独立定时器 | 无歌可下 → **一次都不发**(这是设计);有歌要下 → `downloading` 期间发、排空后**无条件停**;维护任务没启动 → 不发。手工验证用 `POST /api/whoami/send`(§4.1、§9.3) |
| 8 | 上游开始 403/限流/只回广告地址 | 取址请求太密会被上游判定为攻击;短时间大量取链必然触发风控 | 保持 `upstream_interval_min_ms/max_ms = 5000/10000`;限速**全局串行**(10 并发也不叠加);`/api/upstream/probe` 等一次性批量取址也走同一节奏、**不允许绕过**;某源 403/连续失败会自动冷却 10 分钟并沉底(`GET /api/upstream/hosts` 看 `cooldown_left`,`POST /api/upstream/clear_health` 清除) |
| 9 | 磁盘/云盘被撑满,整机受影响 | 媒体在云盘上时写入先落**本地缓冲**再异步上传;下载快于上传会把本地盘撑爆 | `media_rate_limit_mb_s` ≤ 云盘上传带宽(默认 5 MB/s ≈ 50 Mbps);看概览「存储守卫」两个水位;守卫会自动暂停/恢复(有暂停归属保护,§6.10) |
| 10 | 媒体扫描返回 `ok=false`(列举超时),歌曲表没变化 | FUSE 卡住/挂载掉了;`keep_adopt_media` 超时后**故意不改任何状态**(避免把"挂载丢失"误判成"全部缺失"而把整表回队) | 先修挂载/确认能 `ls`,或调大 `media_scan_timeout_sec`(如 120),再点「扫描媒体目录」 |
| 11 | 「同步曲库」期间管理页像死了一样 | 旧版把 69 万行放一次 `executemany`,且前端整页重建 | 现已分块提交 + 动作按钮"发起即返回" + 统一轮询与 DOM 内容签名局部刷新;`stats_cache_sec` 给 `/api/stats` 做 TTL 缓存。若仍慢就调大 `stats_cache_sec` |
| 12 | 页面显示的状态和日志对不上 | 维护轮次的状态由后端给、界面只展示 | 以 `GET /api/maintain/status` 的 `state_text`/`last_result` 与「日志」页为准(最近 5000 条事件,`GET /api/events`);每轮结束都会写一条 `维护一轮完成(第 N 轮):…` |
### 8.5 备份与日志
- **备份**:`<data_dir>/server.db`(曲库清单 + 歌曲表 + 下载登记 + 事件)、`media/`、
同步留档的原始 `muse-*.db`,以及 `<data_dir>/device-identity.json`
(身份文件丢了 = 上游把我们当**新设备**,之前建立的"合法/活跃"状态全没了)。
- **日志**:`<log_path>`(默认 `/var/lib/ktv-source/server.log`)+ 管理界面「日志」页(最近 5000 条事件)。
- **定时同步**:`muse_auto_sync_hours`(`0` 关闭)。
### 8.6 通过 IIS 反向代理对外服务(2026-10 新增;**已拆成两个站点**)
**场景**:KTV 场所有一台 Windows 跑 IIS(对外域名 + HTTPS),内网 Linux 跑本服务(6677)。
对外只暴露 IIS,由它反代到 `http://192.168.100.232:6677`;**管理端口 6678 绝不对公网暴露**。
**两个站点**(2026-10 起拆分,原因与完整步骤见 `deploy/iis/README.md`):
| 站点 | 域名 / 端口 | 反代什么 |
|---|---|---|
| API 站 | `mdmuse.kasugano.cn` / HTTPS **443** | `token`(App 令牌)、`api.php`、`i.php`、`music/do.php`、`push_download`、`push_download/status`、`healthz`;可选(默认注释)`api/client_ip`、`token/status` |
| 媒体站 | `md.kasugano.cn` / HTTPS **4433** | **只有** `media/*`(必须保留 Range/206),其余 404 |
后端靠 `media_hosts` / `api_hosts` + 可信 `X-Forwarded-Host` 再做一道**宿主隔离**(§5.11):
媒体域名只能下文件、API 域名不能批量拉文件。
**交付物**(完整可用的配置 + 从零开始的分步指南 + 排错表)在仓库
**`selfhost-source/deploy/iis/`**(相对本目录是 `../deploy/iis/`):
| 文件 | 作用 |
|---|---|
| `deploy/iis/api-site/web.config` | API 站:上表 7 条路径反代到 6677,其余路径 **404**;带 `X-Forwarded-For`/`X-Real-IP`/`X-Forwarded-Proto`/`X-Forwarded-Host`;关压缩;`maxAllowedContentLength=32MiB` |
| `deploy/iis/media-site/web.config` | 媒体站:**只**反代 `media/*`(目标 `…:6677/media/<文件>`),其余 404;关压缩、`preserveHostHeader`、超时 600(保 Range/206)(→ 4433 绑定的做法见该目录 README 第 5.3 节) |
| `deploy/iis/web.config` | **索引**(上级目录):只说明该把上面哪份放进哪个站点,**本身不含任何规则**,避免三份配置互相矛盾 |
| `deploy/iis/applicationHost.config.snippet.xml` | 服务器级(管理员权限):`<proxy enabled="true" preserveHostHeader="true" proxyTimeout=600>`、放行 4 个服务器变量、`uploadReadAheadSize` |
| `deploy/iis/README.md` | 分步指南:装 URL Rewrite 2.1 + ARR 3.0 → **Enable proxy** → 建两个站点/两个绑定(含 **4433** 端口与一张 SAN 证书)→ HTTPS 证书 → app pool → 替换占位值 → `curl` 验证(healthz / token / i.php / do.php / media HEAD / Range 206 / push_download 401 / 两个站点的 404 互斥)+ 常见问题 |
**本机需要改的配置**(详见 §6.13、§6.14):
```jsonc
{
"trust_proxy_headers": true, // 反代部署必须打开
"trusted_proxies": ["192.168.100.10"], // ← IIS 机器的内网 IP(不是设备网段)
"public_media_base": "https://md.kasugano.cn:4433/media", // 换域名;HTTPS 站点用 https
"public_host": "md.kasugano.cn:4433",
"media_hosts": ["md.kasugano.cn"], // 媒体站宿主(端口不用写,判定对端口容错)
"api_hosts": ["mdmuse.kasugano.cn"], // API 站宿主
"app_token_enforce": false, // 先保持 false(老 App 不带令牌)
"http_backlog": 128, // 默认值即可
"max_body_bytes": 8388608 // IIS 的 maxAllowedContentLength 要 ≥ 它
}
```
**什么时候必须打开 `trust_proxy_headers`**(三者任一成立就必开):
1. 有设备/App **通过代理**访问(不打开则 `/push_download` 限流把全场算成一个来源);
2. 需要在日志里看到**设备真实 IP**(不打开则全是代理地址,设备问题无法定位);
3. 对外是 **HTTPS**(不打开则 `/music/do.php` 返回 `http://内网主机:6677/media/…`,
App 拒收且泄露内网拓扑)—— 也可用 `public_media_base` 写 https 域名兜底。
**IIS 侧超时建议**:ARR → Server Proxy Settings → **Time-out = 600 秒**(媒体是大文件、
慢客户端会拖很久;默认 120 秒偏短),Connect/Send 同样给 600。本服务的慢接口
(曲库同步、扫描、`run_now`、入队)都是**发起即返回**,不需要为它们加超时。
**快速验证**(把域名换成你的;两站点各自验证):
```bash
# ── API 站(mdmuse.kasugano.cn)──
curl -s https://mdmuse.kasugano.cn/healthz # 200 + {"ok":true,…}(应"秒回")
curl -s -D - https://mdmuse.kasugano.cn/token # 200 + expires_in:600 + Cache-Control: no-store
curl -s 'https://mdmuse.kasugano.cn/i.php?...' # {"code":200,…}
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mdmuse.kasugano.cn/push_download -d 'tid=1&url=x' # 401
curl -s -o /dev/null -w '%{http_code}\n' https://mdmuse.kasugano.cn/api/stats # 404(管理口不对外)
curl -s -o /dev/null -w '%{http_code}\n' https://mdmuse.kasugano.cn/media/1.ts # 404(媒体在另一个站点)
# ── 媒体站(md.kasugano.cn:4433)──
curl -s 'https://md.kasugano.cn:4433/music/do.php?musicno=1' -o /dev/null -w '%{http_code}\n' # 404
curl -sI 'https://md.kasugano.cn:4433/media/<编号>.ts' | grep -i 'accept-ranges\|content-length'
curl -s -D - -o /dev/null -r 0-1023 'https://md.kasugano.cn:4433/media/<编号>.ts' # 必须 206
# ── 后端侧自测(宿主判定 + 令牌流程)──
curl -s 'http://127.0.0.1:6677/api/client_ip' -H 'X-Forwarded-For: 203.0.113.9' \
-H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: md.kasugano.cn:4433'
# → host=md.kasugano.cn:4433、host_source=x-forwarded-host、media_host_match=true
TOKEN=$(curl -s http://127.0.0.1:6677/token | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
curl -s 'http://127.0.0.1:6677/music/do.php?musicno=7009126' -H "X-App-Token: $TOKEN"
# 排错接口 /api/client_ip 默认不对外(属内部信息):要么临时放开 IIS 规则(api-site 第 ⑨ 条),
# 要么像上面这样在 Linux 上直连自测。
```
## 9. 验收与自查(可直接复制)
> 下文的 `127.0.0.1:6678` 换成你的管理地址;`admin_token` 非空时给每个 curl 加
> `-H 'X-Auth-Token: <token>'`(或 URL 上加 `?token=<token>`)。
### 9.1 服务与协议连通
```bash
curl -s http://127.0.0.1:6677/healthz | python3 -m json.tool
curl -s -X POST -d 'action=get_config&device_id=deploy-test' http://127.0.0.1:6677/api.php
curl -s 'http://127.0.0.1:6677/i.php?appid=x&mac=<mac>_<sn>&sn=<sn>&time=1&ver=2.0&vn=4.1.3.03281430&sign=x'
curl -s 'http://127.0.0.1:6677/music/do.php?musicno=7009126&ls=1&resolution=720&token=x'
curl -s http://127.0.0.1:6677/token # 200 + expires_in:600(§5.10)
curl -s http://127.0.0.1:6677/token/status # 令牌计数
curl -sI 'http://127.0.0.1:6677/media/7009126.ts' # 200 + Accept-Ranges
curl -sI -H 'Range: bytes=0-1023' http://127.0.0.1:6677/media/7009126.ts # 206
ss -lntp | grep -E ':(6677|6678)' # 两个端口都在监听(§8.4 第 1 条)
```
### 9.2 看维护状态与下次时间
```bash
curl -s http://127.0.0.1:6678/api/maintain/status | python3 -m json.tool
# 关注:state / state_text / interval_sec / running / last_run_at / next_run_at / cycle_count / last_result
python3 - <<'EOF'
import json, urllib.request, time
d = json.load(urllib.request.urlopen("http://127.0.0.1:6678/api/maintain/status"))
now = int(time.time())
print("state =", d["state"], "/", d["state_text"], " running =", d["running"])
print("interval_sec =", d["interval_sec"])
print("last_run_at =", d["last_run_at"], " next_run_at =", d["next_run_at"],
" 还有", (d["next_run_at"] - now) if d["next_run_at"] else "-", "秒")
print("last_result =", json.dumps(d["last_result"], ensure_ascii=False))
EOF
```
- `next_run_at ≈ 本轮结束时间 + interval_sec`;未启用/已停止时 `next_run_at = 0`。
- 想确认"重启按钮不等间隔":先 `stop`(`next_run_at` 变 0),再 `restart` 后立刻查 —— `next_run_at` 应≈当前时间。
### 9.3 确认"心跳随下载起停"
```bash
# ① 停维护 → 心跳必停
curl -s -X POST http://127.0.0.1:6678/api/maintain/stop >/dev/null
curl -s http://127.0.0.1:6678/api/whoami \
| python3 -c 'import json,sys;d=json.load(sys.stdin);print("running=",d["running"],"sent=",d["sent"],"failed=",d["failed"])'
# ② 启动维护(歌曲表里要有"待下载"的歌,否则本轮不会起心跳)
curl -s -X POST http://127.0.0.1:6678/api/maintain/start >/dev/null
for i in 1 2 3 4 5 6 7 8; do
curl -s http://127.0.0.1:6678/api/maintain/status \
| python3 -c 'import json,sys;d=json.load(sys.stdin);print("state=",d["state"],"heartbeat_running=",d["heartbeat_running"],"pending=",d["pending"],"heartbeat_enabled=",d["heartbeat_enabled"])'
sleep 2
done
# ③ 心跳计数确实增长 + 排空后 running=false
curl -s http://127.0.0.1:6678/api/whoami | python3 -m json.tool | head -20
curl -s 'http://127.0.0.1:6678/api/events?limit=50' \
| python3 -c 'import json,sys;[print(e["level"],e["text"]) for e in json.load(sys.stdin)["items"] if "whoami" in e["text"] or "维护一轮" in e["text"]]'
```
- **无歌可下时心跳一次都不会发**:`state` 走完 `scanning`/`retrying` 直接回 `idle`,
`heartbeat_running` 恒 `false`、`sent` 不增长 —— 这是**正确行为**(不是故障)。
- 心跳失败不影响下载:`GET /api/whoami` 的 `last_error` / 日志 `WARN whoami 心跳失败…`。
### 9.4 确认身份换新(`age_sec` / `expires_in_sec` / `expired`)
```bash
# 当前身份与时效
curl -s http://127.0.0.1:6678/api/device | python3 -m json.tool
# 关注:preset/label/identity.mac/identity.sn、origin、ttl_hours、age_sec、expires_in_sec、expired、effective.mac_source
# 手工换一份:立刻生效,age_sec 归零
curl -s -X POST -H 'Content-Type: application/json' -d '{"preset":"aml_s905x3"}' \
http://127.0.0.1:6678/api/device/regenerate | python3 -m json.tool
# 验证"24h 时效到点会自动换新":把 ttl 临时改小 → 立即维护一次 → 看 last_result
curl -s -X POST -H 'Content-Type: application/json' -d '{"device_identity_ttl_hours":0.0001}' \
http://127.0.0.1:6678/api/settings >/dev/null
curl -s -X POST http://127.0.0.1:6678/api/maintain/run_now >/dev/null; sleep 3
curl -s http://127.0.0.1:6678/api/maintain/status \
| python3 -c 'import json,sys;d=json.load(sys.stdin);print("regenerated=",d["last_result"].get("identity_regenerated"),"age_sec=",d["identity_age_sec"],"expires_in_sec=",d["identity_expires_in_sec"],"expired=",d["identity_expired"])'
# 记得改回来
curl -s -X POST -H 'Content-Type: application/json' -d '{"device_identity_ttl_hours":24}' \
http://127.0.0.1:6678/api/settings >/dev/null
```
- 刚换过身份:`age_sec ≈ 0`、`expires_in_sec ≈ 86400`、`expired=false`、`origin=generated`,
且 `last_result.identity_regenerated=true`(说明是**维护任务**换的);
日志里应有 `设备身份已过期(X.Xh),自动更换:新 mac=… sn=…`。
- 换了身份后 `GET /api/device` 的 mac/sn 与 `GET /api/maintain/status` 的 `identity_mac` 应一致(三处同源)。
### 9.5 手工触发一次维护 / 扫描 / 心跳 / 失败重试
```bash
curl -s -X POST http://127.0.0.1:6678/api/maintain/run_now | python3 -m json.tool # 跑一轮,不改排程
curl -s -X POST http://127.0.0.1:6678/api/maintain/restart | python3 -m json.tool # 停后立即开新一轮
curl -s -X POST http://127.0.0.1:6678/api/keeps/scan | python3 -m json.tool # 扫媒体目录(只增不删)
curl -s http://127.0.0.1:6678/api/keeps/scan/status | python3 -m json.tool # 上次扫描结果
curl -s -X POST http://127.0.0.1:6678/api/whoami/send | python3 -m json.tool # 手工心跳(不受开关限制)
curl -s -X POST http://127.0.0.1:6678/api/download/retry_failed_now | python3 -m json.tool
```
- `run_now` **立刻返回** `{"ok":true,"started":true,…}`;几秒后再查 `/api/maintain/status` 看 `last_result`。
- `POST /api/keeps/scan` 若超时会返回 `{"ok":false,"error":"媒体目录列举超时…"}` 且**不改任何状态**。
### 9.6 确认失败任务被自动重试
```bash
# ① 自动重试状态(上次跑了没 / 重置几首 / 为什么跳过)
curl -s http://127.0.0.1:6678/api/download/status \
| python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["auto_retry"],ensure_ascii=False,indent=2))'
# ② 失败清单
curl -s 'http://127.0.0.1:6678/api/download/failed?limit=20' | python3 -m json.tool
# ③ 立刻跑一次(默认遵守跳过条件;{"force":true} 强制)
curl -s -X POST -H 'Content-Type: application/json' -d '{}' \
http://127.0.0.1:6678/api/download/retry_failed_now | python3 -m json.tool
# ④ 日志里应有「下载队列自动重试:重置 N 首失败任务」与维护轮次的「重试 N」
curl -s 'http://127.0.0.1:6678/api/events?limit=100' \
| python3 -c 'import json,sys;[print(e["level"],e["text"]) for e in json.load(sys.stdin)["items"] if "重试" in e["text"]]'
```
- 跳过是**预期行为**(`auto_retry.skipped_reason` 会写明):下载池被**人工暂停**、**存储水位不足**、
或重置数量为 0(N==0 不写日志)。
- 维护任务每轮的重试受 `maintain_retry_failed` 控制,上限由 `retry_failed_max_attempts`(0=跟随 `max_attempts`)决定。
### 9.7 单元自测(不启服务、不联网)
```bash
cd /opt/ktv-source && python3 -m unittest discover -s tests # 286 项(2026-10 复核,全绿)
```
## 10. 目录与已知边界
### 10.1 目录
```
server/
├── run.py 启动入口
├── ktv/
│ ├── config.py 配置(JSON)+ 校验(并发≤10、媒体地址不能是裸 IP、取值钳制)
│ ├── store.py SQLite:曲库清单(songs)+ 歌曲表(keeps)+ 下载登记 + 事件;
│ │ 媒体扫描登记(keep_adopt_media)、安全删文件(_safe_delete_media)
│ ├── musesync.py 曲库同步(纯标准库 RSA/PKCS#1 v1.5 + DER 解析)
│ ├── resolver.py 取址(App 协议)+ 可信地址闸门(同 App 的 isDemoUrl)+ 取链限速 _pace()
│ ├── identity.py 设备身份(伪装成安卓盒子:内置真盒子模板 + 随机 mac/sn,落盘复用 + 24h 时效)
│ ├── whoami.py 设备心跳(24 字段按抓包上报;由维护任务启停;身份同源自 identity.py)
│ ├── maintain.py ★「歌曲表自动维护」唯一后台任务(身份时效→扫描→重试→补队→排空→停心跳)
│ ├── downloader.py 下载池:并发≤10、断点续传、TS 扫描式校验、失败重试、存储守卫、媒体限速
│ ├── batch.py 旧的批量驱动器(仅 maintain_legacy_daemons=true 时退回使用)
│ ├── service.py 服务端口:api.php / i.php / do.php / media / push_download / healthz / api/client_ip
│ ├── proxy.py ★反向代理支持:可信代理判定、真实客户端 IP、协议/Host 感知的绝对地址
│ ├── health.py ★探活快照(/healthz 纯内存返回 + 后台线程刷新,不查库不碰媒体目录)
│ ├── web.py 极简 HTTP:路由/JSON/表单/静态文件(Range/206)、413、backlog、真实 IP 注入
│ ├── admin.py 管理端口 API(含维护任务、歌曲表、曲库清单、身份/心跳、扫描)
│ ├── ui.py 管理界面(单页 HTML,无外部依赖;统一轮询 + 局部刷新)
│ └── main.py 入口:两个监听 + 后台线程 + 信号处理(**监听之后**才启动维护等)
├── deploy/
│ ├── ktv-source.service systemd 单元(默认版;现场是 User=zijun/ProtectHome=no 的定制版)
│ ├── deploy-root.sh 一键部署(不覆盖已有单元、不夺权已有目录,SVC_USER 可配)
│ └── nginx-media.conf.sample 可选:媒体交给 nginx(生产建议)
├── ../deploy/iis/ ★IIS 反向代理交付物:web.config + applicationHost.config 片段 + README(§8.6)
└── tests/ 纯逻辑/接口级自测(354 项,不连外网;socket 用例只绑 127.0.0.1)
```
### 10.2 已知边界
1. **单进程 + SQLite**:适合单店/单机(1~3 台设备并发下载),不需要集群。
2. **媒体默认由本程序用 Range 分发**;要更高吞吐可把 `media/` 交给 nginx
(见 `deploy/nginx-media.conf.sample`),只要路径仍是 `/media/<编号>.ts` 即可。
3. **上游取址受其自身额度/风控影响**;本实现做了**全局最小请求间隔(5~10 秒随机)+ 403 退避/冷却**,
因此**不能快**:全库 69.6 万首在 5~10 秒/首下需要极长时间,只能按子集推进(容量也有限,见 §11.3)。
4. **曲库同步依赖官方服务可用**;同步失败不影响已下载歌曲的对外分发。
5. **媒体在云盘/FUSE 上**:容量看云盘配额(本机 `df` 显示的可能是云盘口径),性能与可用性受挂载质量影响;
目录列举超时会放弃本次扫描(不改状态),删除较慢(约 1 秒/个)。
6. **身份/心跳是"伪装"**:依赖内置模板与抓包口径;若上游策略变化(改字段/加校验),需要按新抓包复算后再改
(流程见 §11.6,别凭想象改参数)。
7. **`/api/keeps/import?mode=replace` 整表替换不删媒体文件**:换名单不会自动清盘,要清盘用「移除」。
---
## 11. 历史记录:功能核验与部署实测(2026-10-10 起;部分已变更)
> **本节是历史留档**(含当时的缺陷根因、证据与验证过程),保留原文便于追溯。
> 凡与现状冲突处已就地标注 **(历史,已变更)**;**现状请看 §1–§10**。
> 注意:本节里出现的 `keep_reconcile()`、`keep_prune_orphans`、`/api/maintenance/reconcile`、
> `/api/keeps/reconcile`、自动入队(`mode=auto`)、独立的 `FailedRetry`/`media_scan_scheduler`
> 守护线程等,在现版本里**已删除或已合并**,不要再照着本节操作(详见 §3、§5.7、§6.11)。
| # | 需求 | 实现 | 关键接口 / 代码 | 自测 |
|---|---|---|---|---|
| ① | **先同步数据库列举清单,后续更新时比对清单** | 首次同步全量导入清单;再次同步先算 diff 再导入:**新增 / 上游删除 / 内容变更**(name·lang·singers·hot 任一变化)。上游删除的歌曲**不物理删除**,只打 `removed_at` 标记(保留下载登记与已下载文件),可随时看到"这次少了哪些" | `store.diff_and_import()`、`/api/library/tasks`、`/api/songs?sate=`、概览与任务页的比对表 | `tests/test_tasks.py::TestRequirement1Diff`(3 项)|
| ② | **同步任务要有控制及任务清单** | 每条同步任务都在 `sync_tasks` 落一条记录(状态/新旧版本/总数/新增/删除/变更/样例/耗时/错误);支持**启动 → 进度 → 取消**;下载侧有**下载任务清单**(按状态筛选 + 分页)与**队列控制**(启动/暂停/继续/停止/失败重试/对账)(历史,已变更:**对账接口 `/api/maintenance/reconcile` 已删除**,队列的自动编排改由 `/api/maintain/*` 的维护任务负责;下载池手工控制 `/api/download/*` 仍保留) | `store.sync_task_start/finish`、`/api/library/tasks`、`/api/library/cancel`、`/api/download/tasks`、`/api/download/{start,pause,resume,stop,retry_failed}`(历史,已变更:删除 `/api/maintenance/reconcile`) | `TestRequirement2Tasks`(4 项)|
| ③ | **歌曲源可设置多个自动选择** | `upstream_hosts` 可配多个;**自动选择**:未冷却源优先 → **健康分高优先**(成功 +1 / 失败 -1,下限 -5,即"上次能用的源先用")→ 同分按配置顺序;**403 或连续失败自动冷却 10 分钟并沉底**;健康度持久化在 SQLite meta,重启不丢;界面可看每个源的成功/失败/分数/冷却剩余与"上次成功"标记,并可一键探测/清除 | `resolver.Resolver.hosts()/note_ok()/note_fail()/host_report()`、`/api/upstream/hosts`、`/api/upstream/probe`、`/api/upstream/clear_health` | `TestRequirement3MultiSource`(6 项)|
> 全部为**纯逻辑/接口级**验证:`python3 -m unittest discover -s tests`。**(历史,已变更)** 当时是 **42 项通过**;
> 现版本同一命令为 **286 项通过**(含 DER/RSA、地址闸门、去重状态机、App 接口契约、Range/206、
> 设备身份与 24h 时效、自动维护状态机、媒体扫描认领、失败自动重试、whoami 心跳等)。
> **歌曲表(keeps)说明**:服务器只按歌曲表下载/删文件。用户表里只存「编号 + 状态」,
> 歌名/语言/专辑/歌手/文件名/热度在查询与导出时**实时 LEFT JOIN 曲库补全**(天然不会过期,
> 所以**导入只含「编号」的 CSV 也能用**)。曲库暂时没有的编号会以 `matched=false` 留在表里,
> 曲库同步进来后自动补全(`GET /api/keeps/unmatched` 可核对是不是编号写错)。
### 11.1 部署期实测记录(2026-10-10,已部署到 192.168.100.232)
| 项 | 值 |
|---|---|
| 服务器 | Debian 13 (trixie) / 内核 6.12 / 2C2G / 磁盘 249G(可用 ~250G) |
| 路径 | 代码 `/opt/ktv-source`、数据 `/var/lib/ktv-source`(含 `media/`)、配置 `/etc/ktv-source/server-config.json` |
| 端口 | 服务 **6677**(`/api.php` `/i.php` `/music/do.php` `/media/<编号>.ts`)、管理 **6678** |
| 服务 | `systemctl status ktv-source`,已 `enable`(开机自启) |
| 首次同步 | 曲库 **696,393 首**(版本 1791504483411,`day=261009`),同步任务记录 1 条(新增 696,393 / 删除 0 / 变更 0)|
| 下载验证 | 15 首全部 `done`、0 失败,媒体占用 ~0.96GB;含私有头识别日志 |
| 端到端 | `api.php`✓ `i.php`✓ `do.php`→`/media/<编号>.ts`✓ `HEAD`✓ `Range`→**206**✓ |
**部署期发现并修复的 7 个真实缺陷**(均已加回归测试):
1. **重试循环打上游**:`claim_one` 允许无限重领 `failed` 任务且不看重试上限/无退避 → 2 分钟打了上游 **504 次取址**。
现:`attempts < max_attempts` + **失败退避窗口**(仅对重试生效,新任务立即可下)→ 实测变为每首最多 3 次。
2. **媒体流带 512 字节私有头**:上游 `download.origjoy.com` 的 TS 从偏移 **512** 开始(实测 512+n×188),
旧校验只看偏移 0 → 把合法流判成坏文件。现:**扫描式** TS 判定(连续 3 个同步点),内容按原样落盘。
3. **Range 失效**:`dict(HTTPMessage)` 后请求头大小写敏感,`Range` 匹配不到 `range` → 断点续传退化为全量下载。
现:请求头统一小写归一,`Range`→**206** 实测通过。
4. **大文件整块读进内存**:75MB 媒体一次性 `read()`,2GB 机器上并发会 OOM。现:媒体**流式发送**(256KB 分块)。
5. **HEAD 全部 404**:路由只认 GET。现:HEAD 按 GET 路由(`curl -I` 与 OkDownload 探测均可用)。
6. **管理界面「下载队列」一直显示"加载中…"**:`loadTasks()` 里两个 loader **先于** 模板赋值执行,
数据写进旧 DOM 后被含占位符的模板覆盖。现改为**先渲染模板、再填数据**(并加结构断言防回归)。
7. **管理界面保存配置 500**:systemd `ProtectSystem=full` 会把 `/etc` 挂成只读 →
`server-config.json` 写入 `EROFS`。现改 `ProtectSystem=true`(只保护 /usr 与 /boot)
并保留 `ReadWritePaths=/var/lib/ktv-source /etc/ktv-source`。
⚠️ 单元文件里**不要写行内注释**(`ProtectSystem=true # 说明` 会被解析成 `no`)。
**取链限速(2026-10-10 用户要求;2026-10-11 调整为 5~10 秒)**:取链请求(`i.php`/`do.php`)之间加入
**5~10 秒随机间隔**(配置项 `upstream_interval_min_ms` / `upstream_interval_max_ms`,默认 5000/10000)。
**目的:避免被上游判定为攻击**(短时间内大量取链会触发风控/封禁)。
限速是**全局串行**的(`resolver._pace()` 内一把锁):10 个下载线程共用同一节奏,不会随并发放大成对上游的洪水;
随机化则避免固定节奏被判风控。**任何一次性批量取址路径**(如 `/api/upstream/probe` 循环各源取 token)也必须
走同一个 `_pace()`,不允许绕过。
代价:全库 69.6 万首在 5~10 秒/首下需极长时间,实际按子集批量下载(容量也仅够 ~3,700 首)。
> ⚠️ **运维要点(我踩过)**:更新代码后**必须重新跑 `deploy/deploy-root.sh`**(它负责把 `~/ktv-src` 同步到 `/opt/ktv-source`),
> 只 `systemctl restart` 会继续跑旧代码。核对方法:比较本地与 `/opt` 的 `find ktv -name '*.py' | xargs md5sum` 清单哈希。
### 11.2 数据目录迁移到 FUSE 挂载(2026-10-10,按用户要求)
需求:**停服 → 把数据/媒体目录改到 `/home/zijun/mdmuse-data` → 重启**。实际执行中踩到 3 个环境问题:
| # | 现象 | 根因 | 处置 |
|---|---|---|---|
| 1 | `ktv` 用户写入被拒(`attempt to write a readonly database`)| `/home/zijun/mdmuse-data` 是 **FUSE 挂载(CloudFS)**,挂载参数 `user_id=1000,group_id=1000` → **只认 uid 1000(zijun)**;FUSE 上 `chown` 也不生效(两个会话看到不同归属)| 服务改以 **`User=zijun`** 运行(`/etc/ktv-source` 一并交给 zijun)|
| 2 | 进程被 **SIGBUS**(systemd `status=7/BUS`)打死、无限重启 | 搬迁带来的 **`server.db-wal` 残留**:SQLite 打开时恢复 WAL 需要 mmap `-shm`,FUSE 不支持 → 立刻 SIGBUS | 在**本地盘**上把 DB(+wal+shm) 复制出来 → `wal_checkpoint(TRUNCATE)` + `journal_mode=DELETE` + `VACUUM` → 单文件放回(校验 `songs=696,393 / downloads=24` 不变)|
| 3 | 单靠 `db_journal_mode=DELETE` 仍 SIGBUS | 是残留 WAL 在恢复阶段爆掉,不是运行期 | 同 #2 |
**代码改动**:新增配置 `db_journal_mode`(默认 `WAL`,FUSE/网络盘用 `DELETE`),
`Store` 在 WAL 不可用时**自动回退 DELETE**;`deploy-root.sh` **不再覆盖已存在的 systemd 单元**(保留现场定制)。
**结果**:服务 `active` + 开机自启;曲库 696,393 首、24 首媒体全部保留(迁移后新下一首 → 25 首,0 失败);
`/etc/ktv-source/server-config.json` 指向新目录,`journal=DELETE`;`HEAD`/`Range`(206) 正常;
FUSE 上实测读写 ~327MB/s 写、~475MB/s 读(性能无虞)。
> ⚠️ **注意**:该目录是**云盘/FUSE 挂载**,媒体实际存放在云端;请留意云盘容量与同步策略
> (本机 `df` 显示 250GB 可用,但云盘配额可能另算)。
### 11.3 存储守卫 + 上传速度匹配限速(2026-10-10,按用户要求)
**为什么需要**:媒体目录在云盘/FUSE(CloudDrive2,Web UI `:19798`)上时,
写入**先落本地缓冲**、再由云盘客户端异步上传。若下载速度长期高于上传速度,
本地盘会被缓冲撑满 → 任务失败甚至影响整机。
**实测到的两个空间口径(必须都看)**:
```
本地盘 / : 249G 总量, 可用 ~252G ← 上传缓冲所在(真正的约束)
FUSE 挂载 /home/zijun/mdmuse-data : statvfs 报 11TB ← 这是**云盘配额**,不是本地盘
```
> CloudDrive2 v1.1.2 的 Web API 不是公开的 `/api/v1/*` 形式(探测均 404),
> 因此采用**水位 + 速率匹配**这套不依赖第三方 API 的方案;若后续提供 API Token,
> 可以再接入它的上传队列做实时联动。
**实现**
| 能力 | 说明 |
|---|---|
| `StorageGuard` | 每 `storage_check_interval_sec`(默认 15s) 检查**本地盘**与**媒体文件系统**剩余;低于阈值 → 自动暂停下载池(标记 `by=space`);回到「阈值+余量」→ 自动恢复(迟滞,避免抖动)|
| 暂停归属保护 | 空间暂停**不会被手动"继续"解除**,手动暂停也不会被"空间恢复"解除(否则 15 秒后又被暂停,来回抖动);界面显示暂停原因 |
| `RateLimiter` | 媒体下载**聚合限速** `media_rate_limit_mb_s`(默认 **5 MB/s** ≈ 50 Mbps,与云盘上传速度匹配):令牌桶虚拟调度,长期平均速率≈设定值 |
| 领任务前检查 | 水位不足时工作线程不领新任务,已在下的任务跑完当前分片 |
| 配置项 | `local_space_path`(默认 `/`)、`min_free_local_bytes`(20GiB)、`min_free_media_bytes`(20GiB)、`resume_free_margin_bytes`(5GiB)、`pause_on_low_space`、`media_rate_limit_mb_s`、`cloud_upload_mbps`(仅提示)、`storage_check_interval_sec` |
| 界面/接口 | 概览「存储守卫」卡片(本地/云盘剩余、限速、状态、检查时间);`GET /api/storage`;`/api/stats` 内含 |
**真机验收**:①水位读取正确(本地 252.1GB / 云盘 11008.5GB,下限 21.5GB);
②把下限临时设为 1PB → 守卫 6 秒内**自动暂停**(事件 `WARN 空间不足,已暂停下载`,`paused_by=space`);
③恢复下限 → **自动继续**(事件 `INFO 空间已恢复,继续下载`);
④限速实测:51.7MB 一首,含 3~5s 取链共 19s(媒体段 ≈3.7MB/s,未超过 5MB/s 上限)。
**批量同步的量级提醒**(云盘 11TB / 本机 249G / 上传 50Mbps)
- 全库 696,393 首 × 均值 ~66MB ≈ **46TB** → **云盘 11TB 也装不下全库**(约 16.6 万首);
- 按 5MB/s 计,装满 11TB 需连续上传 **~25 天**;
- 建议按子集推进:**热门前 3,000 首 ≈ 200GB ≈ 11 小时**(下载与上传同时受限,取较慢者)。
### 11.4 全库批量下载驱动器(2026-10-10 启用;**历史,已变更**)
> **(历史,已变更)** 本节描述的 `BatchManager` **已不再自动运行**:补队/巡检/致命即停这套能力
> 已并入 §3 的「歌曲表自动维护」任务(一条时间线),`/api/batch/*` 现在只是维护任务的薄包装。
> 仅当显式设置 `maintain_legacy_daemons=true` 时,才会退回本节描述的独立批量驱动器。
> `batch_enabled=true` 仍被认(等价于"开机自动开始维护")。
需求:**按数据库把全部歌曲下到本地 + 定时持续检测 + 致命错误时停下**。
| 能力 | 说明 |
|---|---|
| 持续补队 | 保持队列有 `batch_target_pending`(默认 200) 首待下载;每 `batch_check_interval_sec`(30s) 补一次;已完成自动跳过(不重复下载)|
| 定时巡检 | 每轮采样水位/活动任务/完成数;每 `batch_report_interval_sec`(默认 600s,本部署设 300s) 写一条 `BATCH 进度`(% / 首/小时 / 预计剩余 / 本地与云盘水位)|
| **致命即停** | ① 空间不足**连续** `batch_low_space_checks`(5) 次检查未缓解;② 连续失败 ≥ `batch_max_consecutive_failures`(20) 首;③ `batch_stale_minutes`(20) 分钟内 0 完成且队列非空(上游失效/全 403 等)。命中即**暂停下载池 + 停止补队 + 写 `ERROR BATCH 致命错误停止:原因`**,界面显示"致命错误已停止"+原因 |
| 跨重启续跑 | `batch_enabled=true` 写入配置 → 服务重启后自动继续 |
接口:`POST /api/batch/start|stop`、`GET /api/batch/status`(**(历史,已变更)**:这三条现在指向
「歌曲表自动维护」任务,返回的也是维护任务的状态字段);界面概览的卡片已更名为「歌曲表自动维护」。
**容量与时限(重要)**:云盘 13.2TB(可用 10.97TB),全库 696,393 首 × 均值 ~57MB ≈ **39.7TB**
→ **云盘只能容纳约 19 万首**;按 5MB/s 限速 ≈ **320 首/小时 ≈ 18GB/小时** → 填满可用空间需 **~25 天**。
因此本批次的自然终点是"**云盘配额耗尽**"(届时空间守卫会暂停、5 次检查后判定为致命并停止),
若要下完整库需要更大存储或提高上传带宽后再继续。
### 11.5 两个真实缺陷的根因与修复(2026-10-11)
> **(历史,已变更)** 本节涉及的 `keep_reconcile()`、`prune_orphans`、`/api/keeps/reconcile`、
> 「启动即对账」在现版本里**已全部删除**;原先由对账承担的"done 但文件缺失 → 回队"与
> "媒体目录里的未知 `.ts` → 登记"两件事,现在**只有一条路径**:维护任务每轮的「扫描媒体目录」
> (§3.2 步骤 b,实现见 `store.keep_adopt_media()`)。`keep_prune_orphans` 这个配置键也已从
> `DEFAULTS` 移除(§6.11)。下面 A 里对"整表替换不原子"的修复与分析**仍然有效**。
#### A. "歌曲表条目莫名消失"(重启/对账后变 0 条)
**排查结论(逐条实测,不是猜)**:
| 怀疑点 | 结论 |
|---|---|
| `keep_reconcile()` 会不会删 `matched=false` / 文件还没下的 keeps 行? | **不会**。它只 `UPDATE keeps`:文件缺失 → 回 `want`;曲库匹配不到 → 原样保留。回归测试见 `tests/test_keeps.py::TestKeepReconcileNeverDeletes`(含"重开同一个库 + 启动对账后行数与内容不变") |
| `main.py` 的"启动即对账"是否做破坏性动作? | 会:它按 `keep_prune_orphans`(**2026 起默认 false**)处理孤儿媒体文件。启动这次**只回队/认领、不删孤儿**(`keep_prune_orphans_on_start=false` 默认;手动/定时对账也默认只报告,**只有显式传 `prune_orphans=true` 才删**)。另外媒体目录不可用时 `/api/keeps/reconcile` 以前会 `KeyError: 'requeued'` 报 500,现已返回 `skipped=true` |
| 那"整表变 0"是谁干的? | **`POST /api/keeps/import?mode=replace`(整表替换)不是原子的**:`Store.ex("DELETE FROM keeps")` 会立刻提交,之后才写新行 —— CSV 解析结果为空(只有表头 / 编号非法 / 状态无法识别)时,**删除已生效、新行没写进去**,歌曲表被永久清空且无法恢复;写新行时任何异常(磁盘满、库被锁、FUSE 掉线)同样如此 |
**修复**(`ktv/store.py`):
* 新增 `Store.tx()` 事务上下文 + `Store.replace_keeps()`:清空 + 清 downloads 登记 + 写新行**在一个事务里**,
任何异常整体回滚(`test_replace_rolls_back_when_write_fails` 守住);
* 空 CSV / 无有效行 → **拒绝执行整表替换**(返回 `replace_cancelled=true`,歌曲表一行不动);
* 全部语义写进文档字符串(store.py 顶部 + `keep_reconcile`/`keep_import_csv`/`keep_remove`),
并有源码级守卫测试:全项目只允许两处 `DELETE FROM keeps`(`keep_remove`、`replace_keeps`),
且 `_RETIRED_TABLES` 里不许出现 `keeps`;
* 退出路径补 `Store.checkpoint()`:数据目录在云盘/FUSE 上时残留 `-wal` 会在重启阶段丢失
(README §11.2 记录过 SIGBUS),表现为"重启后刚加的行不见、老数据还在"。
#### B. 管理页点「同步曲库」卡住 → 异步 + 局部刷新
**两个原因**:
1. **后端**:`import_library()` 把 69 万行放在**一次** `executemany` 里,全程独占 `Store._lock`
与 SQLite 写锁 → 同步期间所有管理接口排队(部署实测约 100 秒,"页面像死了")。
现改为**分块提交**(`IMPORT_CHUNK=2000`),`diff_and_import` 的旧清单快照也改成 keyset 分块读;
2. **前端**:动作按钮 `await` 长请求 + 每 2.5 秒 `loadOverview()` 整页 `innerHTML` 重建(丢焦点/勾选/滚动)。
现改为:动作按钮一律"发起即返回"(loading + 顶部提示 + 轮询出结果);一个共享 `setInterval`
(默认 4 秒,可暂停,顶部显示"最后更新时间")每轮只请求当前页签需要的接口
(`Promise.allSettled`,单接口失败互不影响;上一轮没回来就跳过这轮),
每个 DOM 块带"内容签名",**签名没变就一个字节都不动**;有勾选时不重画表格。
**`/api/stats` 性能**:新增配置 `stats_cache_sec`(默认 4 秒,管理界面可改,写入 keeps/songs/downloads 立即失效)。
实测 69.6 万行库:无缓存 ~0.67s(本机磁盘)/ 命中缓存 **~1ms**(部署机在 FUSE 上无缓存实测 3.1s)。
**实测脚本**(临时目录 + 临时配置,不碰 /opt、不碰真实数据目录、不连外网):
```bash
# POST /api/library/sync 立刻返回,后台任务真的在跑
curl -s -o /dev/null -w '%{time_total}s\n' -X POST http://127.0.0.1:16678/api/library/sync # 0.0015s
curl -s http://127.0.0.1:16678/api/library/tasks?limit=3 # state=running
# 同步进行中轮询照样秒回
curl -s -o /dev/null -w '/api/stats %{time_total}s\n' http://127.0.0.1:16678/api/stats # 0.077s → 0.001s(缓存)
# 缓存效果(先设 stats_cache_sec=0 再设回 4)
curl -s -X POST -H 'Content-Type: application/json' -d '{"stats_cache_sec":0}' .../api/settings
curl -s -o /dev/null -w '%{time_total}s\n' .../api/stats # 0.67s
# keeps 加入 → 重启 → 仍在
printf '编号\n4100081\n4100293\n9999999\n' > keeps.csv
curl -s -X POST --data-binary @keeps.csv '.../api/keeps/import?mode=merge' # updated=3, matched=2, unmatched=1
kill -TERM <pid> && 重启 && curl -s '.../api/keeps?page_size=50' # total=3(未消失)
```
### 11.6 新抓包对齐:取址参数复算 / whoami 心跳 / 带 query 的媒体地址(2026-10-11)
抓包文件:`zk.ktv1.top_2026_10_11_15_08_44.har`(Reqable,3 个请求)。
结论按"先复算、再改代码"的顺序给出,避免凭想象改参数。
#### A. 取歌参数**没有变化**(用现实现的拼串逐字节复算通过)
| 请求 | 复算 | 结果 |
|---|---|---|
| `GET /i.php` | `md5("appid=…&mac=ae60e9547958_2d7bb4a7d7f8&sn=2d7bb4a7d7f8&time=1791702376&ver=2.0&vn=4.1.3.03281430" + APP_KEY)` | `5ff87db502fb1d4f5336a190210722d6` = 抓包 ✅ |
| `GET /music/do.php` | `md5("appid=…&device=ae60e9547958_2d7bb4a7d7f8&ish265=0&ls=1&musicno=7200088&resolution=720&sn=2d7bb4a7d7f8&time=1791702377&token=NjI1…" + SDK_KEY)` | `4522dcbe72c0f583029f6f1b6fa7850b` = 抓包 ✅ |
为了让"没变"这件事**可复跑**(而不是只写在文档里),把拼串抽成
`resolver.token_params()/token_sign()` 与 `resolver.song_params()/song_sign()`,
并新增回归测试 `tests/test_basic.py::TestCanonicalSign` —— 其中两条**直接跑现网代码路径**
(`get_token` / `get_song_url`,固定抓包时间戳、桩掉 `_get`),断言产出的 URL 与 HAR **逐字节相同**:
```bash
python3 -m unittest tests.test_basic.TestCanonicalSign -v # 6 项:短签名字段、两个 sign、两条现网 URL
```
#### B. `whoami` 心跳(此前完全没实现)
* 新模块 `ktv/whoami.py`:24 个字段按抓包顺序全量上报、空 body、`Dalvik/2.1.0 …` UA;
* `main.py` 在两个 HTTP 监听**起来之后**才 `start()` 后台守护线程(顺序刻意如此:
即使这段出问题,端口也已经监听,不会出现"进程活着但不监听端口");
* 接口:`GET /api/whoami`(最近一次的时间/耗时/响应/是否成功 + 计数 + 下次间隔)、
`POST /api/whoami/send`(立刻打一次,不受 `whoami_enabled` 限制);概览页新增「设备心跳」卡片;
* 单测(**只连本地 stub**):`tests/test_whoami.py`(17 项,覆盖字段/顺序/编码/UA/间隔夹取/
失败吞掉/开关/线程不阻塞/两个接口)。端到端冒烟(临时端口 + 本地 stub,不碰真实主机):
管理端口 **0.20s 可连**、启动心跳已发出、两个接口与 UI 卡片在线。
#### C. 媒体地址的**查询串**处理(真 bug,已修)
抓包返回 `http://txog.ktvsky.com/…/480p/7200088.ts?token=…&t=1791706901`。核查结论:
* **`.ts` 文件名比对本身能容忍 `?`**(旧正则 `/([^/?]+)\.ts(?:\?|$)` 带 `?` 分支),
所以这条**不是**此前"取址失败"的成因;已用测试锁住(`TestQueryStringGate`);
* **真缺陷 1(漏放)**:遇到 `#fragment` 时旧正则完全匹配不到 → **整条文件名规则被跳过**,
广告地址只要带 `#` 就能过闸(三个实现都有,JS/原生/服务端);
* **真缺陷 2(误杀)**:`wb66|demo` 等特征**在整串(含 query)上**匹配,而 query 里是签名/随机串
→ 合法真源可能被判"地址不可信"(App 侧注释早就写过"`demo` 之类太宽松的词会误伤真源 token");
* **真缺陷 3(误杀)**:App 的 `isLikelyDemoUrl` 用 `java.net.URI(url).host`,query 里出现
`|`/空格之类会让 `URI()` 抛异常,被 `?: return true` 当成 demo → 合法地址被拒。
修法:**判定一律在"剥离 query/fragment"的 URL 上做** —— 服务端 `resolver.split_url()`
(`urlsplit`)、App `AdVideoFilter.hostOf()/pathOf()`(`java.net.URI` + 手工兜底)、
JS `splitUrl()`;`.ts` 比对只看 **path**,广告特征只看 **host + path**。
顺带把 App 的裸 IP / `pub-*.r2.dev/ss/` 判定收敛进 `AdVideoFilter.isTrustedSongUrl`
(唯一闸门,四条路径不再各判一套),`ktv_api.js` 改动按约定把 `KTV_BRIDGE_API` 12 → **13**。
#### D. 新域名 `txog.ktvsky.com` 可用
两侧都**没有域名白名单、只有黑名单**(保持现状):`txog.ktvsky.com` 直接通过,
`wb66.cn` / `demo*` / `*.ac16.vip` / `pub-*.r2.dev/ss/` / 裸 IP 仍被拒。
服务端 `tests/test_basic.py::TestQueryStringGate`、App `AdVideoFilterTest` 均有用例锁定。
### 11.7 失败自动重试 / 取址 5~10 秒 / 媒体扫描认领(2026-10-11)
三个需求一起做,互相关联(都围绕"媒体在云盘/FUSE 上、任何目录访问都必须容忍阻塞"这条硬约束):
| # | 需求 | 实现位置 | 关键配置(默认) |
|---|---|---|---|
| ① | **失败任务每 10 分钟自动重试** | `ktv/downloader.py::FailedRetry`(守护线程,参考 `StorageGuard`);`main.py` 在**端口监听之后** `retry.start()`;`admin.py` 的 `auto_retry_status()` 与接口。(**历史,已变更**:`FailedRetry` 不再单独起线程,默认由 §3 维护任务每轮的「失败重试」步骤执行;`downloader.retry` 仍在,供 `/api/download/retry_failed_now` 手工触发) | `retry_failed_enabled=true`、`retry_failed_interval_sec=600`、`retry_failed_max_attempts=3`(填 0 = 跟随 `max_attempts`) |
| ② | **单曲取址之间 5~10 秒缓冲** | `ktv/resolver.py::_pace()`(**全局串行**,一把锁);`i.php`/`do.php` 都经 `_get()` 走同一节奏;`admin.py::upstream_probe` 显式传配置节奏(不许绕过) | `upstream_interval_min_ms=5000`、`upstream_interval_max_ms=10000` |
| ③ | **扫描媒体目录,发现新文件登记进歌曲表** | `ktv/store.py::keep_adopt_media()` + `_scan_media_entries()`(**带超时的守护线程**里列举);`admin.py::run_media_scan()`;`main.py::media_scan_scheduler`(端口监听之后启动)。(**历史,已变更**:`media_scan_scheduler` 独立线程默认不再启动,扫描默认由 §3 维护任务每轮执行;接口 `POST /api/keeps/scan` 仍可手工扫一次) | `media_scan_enabled=true`、`media_scan_interval_sec=600`、`media_scan_min_bytes=6MiB`(填 0 = 跟随 `min_file_bytes`)、`media_scan_timeout_sec=30` |
**新增接口**:
* `GET /api/download/status`:新增 `auto_retry: {enabled, interval_sec, last_at, last_count, total_count, skipped_reason}`;
* `POST /api/download/retry_failed_now`:立刻执行一次自动重试(默认遵守跳过条件;body 带 `{"force": true}` 可强制);
* `POST /api/keeps/scan`:立刻扫描媒体目录一次(超时/异常返回 `ok=false` + `error`,**不改状态**);
* `GET /api/keeps/scan/status`:最近一次扫描结果(时间/新增/更新/跳过/耗时/错误);`GET /api/keeps/stats` 也带 `scan` 字段。
**★ 孤儿策略变更(重要)**:`keep_prune_orphans` 默认由 `true` 改为 **`false`**;`Store.keep_reconcile()`
的 `prune_orphans` 参数默认也改为 `false`。**(历史,已变更)**:现版本已**彻底删除**孤儿删除能力与
`keep_reconcile()`/`prune_orphans`/`/api/keeps/reconcile`,`keep_prune_orphans` 也**不再是配置键**
(见 §6.11)。现在的语义固定为:
> 媒体目录里发现的**未知文件一律"登记进歌曲表"(认领),不删除**。**(历史,已变更)** 原文随后写的
> "只有用户显式传 `prune_orphans=true`(手动对账接口 / 界面勾选 / 配置显式打开)时才走
> `_safe_delete_media` 删除孤儿" —— 这条**已不存在**:现在扫描**只增不删**,删本地文件只有
> 「从歌曲表显式移除」一条路径(`store.keep_remove` → `_safe_delete_media`,见 §2.3 / §8.4)。
原因:旧默认(发现不在歌曲表的 `.ts` 就删)与"扫描媒体目录发现新文件自动登记"直接冲突,
用户手动放进媒体目录/换机迁移过来的文件会被当垃圾清掉。**`keeps` 行的删除语义完全不变**:
仍然只有「显式移除」和「导入=整表替换」两处会删 keeps 行。
**⚠️ 老机器的配置迁移(一次性)**:`Config.save()` 会把**所有**默认值写进 `server-config.json`,
所以老机器上的配置文件里 `keep_prune_orphans: true`、`upstream_interval_min_ms: 3000` 这些
"当年的默认值"与"用户显式配置"无法区分 —— 不迁移的话本次策略变更在老机器上等于没做。
因此 `config.py` 新增 `config_version`(当前 2)与 `LEGACY_DEFAULTS`:加载到**没有**
`config_version` 的老文件时,会把"仍等于旧默认值"的两项(`upstream_interval_min_ms/max_ms`,
即 3000/5000 → 5000/10000)升级为新默认值并落盘 `config_version=2`。
**(历史,已变更)**:原文此处还列了 `keep_prune_orphans`,该键已删除、不再参与迁移
(老配置里残留的该键会被 `Config.load()` 直接忽略)。迁移**只发生一次**,之后用户显式改回旧值会被尊重。
启动时会写一条 `配置已按新默认值迁移:…` 事件日志,便于升级后核对。
**FUSE 阻塞容忍**:`keep_adopt_media()` 把 `isdir`/`listdir`/`getsize` 全部放进子线程并对 `join(timeout)`
设限;超时或异常立刻返回 `{"ok": false, "error": …}` 且**不改变任何状态**(超时子线程是 daemon,
不影响退出)。新增的两个后台线程都与 `whoami` 一样**只在两个端口监听之后启动**,失败只记日志。
**测试**:`tests/test_autoretry_scan.py`(41 项:配置默认值与老配置迁移、跳过条件、N==0 不写日志、
虚拟时钟断言 `_pace()` 等待 ≥ min、i.php/do.php 都走节奏、全局串行、probe 不绕过、
媒体扫描 adopt/跳过/不删除、超时路径、扫描/重试接口全链路、两个调度器循环的异常兜底、
main.py 启动顺序源码守卫),另有既有用例按新语义更新(`tests/test_keeps.py`、`tests/test_tasks.py`)。