版本号:
- 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 例)
1516 lines
126 KiB
Markdown
1516 lines
126 KiB
Markdown
# 麦动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`)。
|