Files
zijunchiang 87aa979abd 合并自建歌曲源支线(selfhost-source)+ 版本号按约定修正为 1.6.0
版本号:
- 1.5.11 越界(规则 3 规定 patch ≤ 10)→ 修正为 1.6.0(进位到 minor、patch 归 0)
- versionCode 43 不变:43 从未对外发布,本次修正只是把命名拉回约定

合并支线(原在工作区根目录、App 仓库之外,DESIGN.md 自称"支线"):
- selfhost-source/server/   自建歌曲源服务器(纯 Python 标准库、零 pip 依赖)
- selfhost-source/deploy/、DESIGN.md、LIBRARY.md、build_inventory.py
- selfhost-source/inventory/ 小清单(playlists.csv、song-stats.md 等)
- ⚠️ inventory 生成物(songs-*.csv / singers-top.csv,共 84MB)不入库,
  已写入 .gitignore;需要时用 build_inventory.py 依据 muse.db 重新生成

同时入库的 App 侧支线改动(此前为工作树未提交状态):
- Whoami.kt(whoami 设备心跳)、DeviceIdentity.kt(设备身份伪装)
- PushDownload.kt / PushDownloadClient.kt(点歌推送)
- AdVideoFilter 带 query/fragment 的地址闸门;KTV_BRIDGE_API 11 → 13
- 修 ktv_api.js 的 global.KtvBridgeApiVersion(12 → 13,与注释及 Kotlin 两处校验一致)
- 对应单测:WhoamiTest / DeviceIdentityTest / PushDownloadTest / AdVideoFilterTest(共 39 例)

文档:
- DESIGN.md 的"在 App 仓库之外"约定改为"已并入仓库统一版本化"
- AGENTS.md 版本号行更新为 1.6.0/43,并新增支线并入说明

验证:assembleRelease + testReleaseUnitTest 全过(39 例)
2026-10-14 02:06:28 +08:00
..

麦动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):

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 判,命中任一种即视为重复、不重复下载:
a) keeps.state='done' 且 <media_dir>/<编号>.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 一个短时效随机凭证 —— 先取令牌,再带着它调接口,地址就不容易被抓取/转发。

# ① 取令牌(默认 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)

# ① 依赖:只要系统 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):

{
  "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、入队)都是发起即返回,不需要为它们加超时。

快速验证(把域名换成你的;两站点各自验证):

# ── 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 服务与协议连通

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 看维护状态与下次时间

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 确认"心跳随下载起停"

# ① 停维护 → 心跳必停
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)

# 当前身份与时效
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 手工触发一次维护 / 扫描 / 心跳 / 失败重试

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 确认失败任务被自动重试

# ① 自动重试状态(上次跑了没 / 重置几首 / 为什么跳过)
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 单元自测(不启服务、不联网)

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、不碰真实数据目录、不连外网):

# 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 逐字节相同:

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