# 麦动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) | | **从歌曲表移除** | 用户(界面/接口) | 默认删本地 `/<编号>.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** 到 `/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=_`(`resolver.token_params`) | `upstream_mac` / `upstream_sn`(`identity.apply_to_cfg` 在运行期填入,**不落盘**) | | `GET /music/do.php` | `device=_`(`resolver.song_params`) | 同上 | | whoami 心跳 | `_mac` / `androidid`(=`mac`) / `_cid`(=`A_`) / `_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=`、或 `Authorization: Bearer `(`/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=` | `{"status":"success","config":{…trial_times:0…}}` | | `/i.php` | GET | `appid&mac=_&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":}`;响应 `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`:`/<编号>.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: ` **或** 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 后路径以 `/.ts` 结尾、不在 wb66/demo/ac16.vip/ad_files/my_ad_video/pub-*.r2.dev/ss 黑名单);不通过 → **400** + 原因,**绝不入库** | | 去重(核心) | 按 `tid` 判,命中任一种即**视为重复、不重复下载**:
a) `keeps.state='done'` **且** `/<编号>.ts` 确实在 → `reason=already_done`;
b) `keeps.state in ('want','downloading')` 或 `downloads.state in ('pending','downloading')` → `reason=already_queued`;
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: " \ --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` | 空(= `/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` | 空(= `/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_`);同上 | ### 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` 拼成:`/<编号>.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: "}`**; - 媒体文件不存在 → `/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 备份与日志 - **备份**:`/server.db`(曲库清单 + 歌曲表 + 下载登记 + 事件)、`media/`、 同步留档的原始 `muse-*.db`,以及 `/device-identity.json` (身份文件丢了 = 上游把我们当**新设备**,之前建立的"合法/活跃"状态全没了)。 - **日志**:``(默认 `/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` | 服务器级(管理员权限):``、放行 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: '`(或 URL 上加 `?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=_&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 && 重启 && 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`)。