合并自建歌曲源支线(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 例)
This commit is contained in:
zijunchiang
2026-10-14 02:06:28 +08:00
parent e060ae5ebe
commit 87aa979abd
68 changed files with 21305 additions and 105 deletions
+692
View File
@@ -0,0 +1,692 @@
"""配置:JSON 文件 + 运行期校验。
管理端口与服务端口**分开**(见 settings 里的 admin_port / service_port)。
所有可调项都能在管理界面里改,改完写回 server-config.json。
"""
from __future__ import annotations
import json
import os
import re
import threading
from typing import Any
DEFAULTS: dict[str, Any] = {
# ── 配置版本(迁移用,别手改)──
# 老版本的配置文件里没有这个键 → 视作 version 1 → 触发一次"旧默认值迁移"
# (见 `_migrate_legacy_defaults`:取址间隔 3~5→5~10 秒)。
"config_version": 2,
# ── 监听 ──
"service_host": "0.0.0.0", # App / 媒体
"service_port": 6677,
"admin_host": "0.0.0.0", # 管理界面(建议只在内网/或加 admin_token)
"admin_port": 6678,
"admin_token": "", # 非空则管理界面需要 ?token= 或 Bearer
# ── 存储 ──
"data_dir": "/var/lib/ktv-source",
"media_dir": "/var/lib/ktv-source/media", # 歌曲视频落地位置(可在云盘/FUSE 上)
# 分片临时目录:**必须在本地盘**。写在云盘上会被无谓上传,且 FUSE 随机写性能差。
"tmp_dir": "", # 空 = 跟随 data_dir/tmp(迁移目录时自动跟随)
"db_path": "/var/lib/ktv-source/server.db",
"log_path": "/var/lib/ktv-source/server.log",
# SQLite journal 模式:本地盘用 WAL;FUSE/云盘/网络盘必须 DELETE
# (WAL 需要 mmap 一个 -shm 文件,这类文件系统上会 SIGBUS 打死进程)
"db_journal_mode": "WAL",
# ── 对 App 暴露的自身信息 ──
# ⚠️ App 会拒绝"裸 IP"的媒体地址(isDemoUrl / isValidSongUrl),必须是域名!
"public_media_base": "http://ktv.example.com:6677/media",
"public_host": "ktv.example.com:6677", # do.php 返回地址用的主机(含端口)
# ── 反向代理(IIS / ARR / nginx;完整交付物见 deploy/iis/)──
# 场景:对外只暴露一台 Windows 上的 IIS(HTTPS + 域名),由它反代到内网本服务(6677);
# 管理端口 6678 **绝不对外**。
# `trusted_proxies`:**可信代理**的 IP/CIDR 列表(如 ["127.0.0.1","192.168.100.0/24"])。
# 默认空 = 一个都不信(安全默认)。只有直连对端命中它时,才采信 X-Forwarded-* 头。
# `trust_proxy_headers`:总开关,默认 False。
# ⚠️ 两个都满足才生效;**反代部署时必须打开**,否则:所有设备的推送限流共用代理地址
# 一份额度、日志里的客户端 IP 全是代理地址、HTTPS 场景下 do.php 会返回 http 内网地址。
"trusted_proxies": [],
"trust_proxy_headers": False,
# `http_backlog`:监听队列长度(ThreadingHTTPServer.request_queue_size)。
# IIS/负载均衡探活 + 设备突发请求时,太小会直接 Connection refused(默认 128)。
"http_backlog": 128,
# `max_body_bytes`:单个请求体的字节上限(CSV 导入等;默认 8MiB)。
# 超限直接 **413 + 中文说明**(不再读进内存);IIS 侧 `maxAllowedContentLength` 要 ≥ 它,
# 否则 413 会由 IIS 先返回(HTML、无语义),两侧行为不一致。
"max_body_bytes": 8 * 1024 * 1024,
# ── App 令牌(`GET /token`,默认 10 分钟时效;实现见 ktv/apptoken.py)──
# 用途:**未来的自有 App** 先 `GET /token` 拿一个随机串,再带请求头 `X-App-Token` 调
# `/music/do.php` / `/push_download`。已发布的官方 App 不带令牌 → 默认**不强制**
# (见 `app_token_enforce`),老 App 照常可用,这是硬红线。
# 口径速记:
# * `app_token_ttl_sec` 有效期(秒),钳制 30~86400;
# * `app_token_bind_ip` True = 令牌**绑定签发时的真实客户端 IP**(配合
# `trusted_proxies`/`trust_proxy_headers` 取真实 IP),换 IP 一律拒绝;
# * `app_token_single_use` True = 用过即失效(媒体地址是多次调用,**默认必须是 False**);
# * `app_token_persist` True = 令牌落 `meta` 表(**一行**、重启仍有效);默认纯内存,
# 不为每个令牌写库;
# * `app_token_paths` 受管**路径前缀**(逗号分隔);
# * `app_token_enforce` False = 只"带了就校验"(带错令牌报 401,避免"以为生效了");
# True = 上面那些路径**必须**带令牌(给自有 App 用);
# * `token_rate_limit_per_min` 同一真实客户端 IP 每分钟最多签发几个(0 = 关限流)。
"app_token_ttl_sec": 600,
"app_token_bind_ip": True,
"app_token_single_use": False,
"app_token_persist": False,
"app_token_paths": "/music/do.php,/push_download",
"app_token_enforce": False,
"token_rate_limit_per_min": 60,
# ── 双站点反代:**宿主隔离**(配合 deploy/iis/ 的 api-site / media-site)──
# 形态:`mdmuse.kasugano.cn`(HTTPS 443)只提供 API,`md.kasugano.cn`(HTTPS **4433**)
# 只提供 `/media/*` 文件下载。后端靠**可信的** `X-Forwarded-Host`(不可信时退回 `Host`)
# 区分两类流量:
# * `media_hosts` 非空时:`/media/*` 之外的路径若来自媒体宿主 → 404;
# * `/media/*` 若来自非媒体宿主(且 `api_hosts` 非空)→ 404。
# ⚠️ 默认**两个都空 = 不校验**(单站点部署行为完全不变)。判定对端口容错:
# `media_hosts=["md.kasugano.cn"]` 能命中 `X-Forwarded-Host: md.kasugano.cn:4433`。
"media_hosts": [],
"api_hosts": [],
# ── 上游歌曲源(App 的取址协议:i.php → do.php)──
"upstream_hosts": ["http://zk.ktv1.top:6677"], # 输入歌曲源,可多个,按序尝试
"upstream_resolution": "720",
# 伪装设备身份:**空 = 跟随自动生成的"安卓盒子身份"**(见 ktv/identity.py),
# 非空 = 用户显式写死(此时自动身份不再覆盖它)。i.php 发 `mac=<mac>_<sn>`、
# do.php 发 `device=<mac>_<sn>`、whoami 发 `_mac`/`androidid`/`_cid`,三处同源。
"upstream_mac": "",
"upstream_sn": "",
# ── 设备身份(伪装成一台安卓盒子:随机 mac/sn + 内置真盒子模板)──
# 生成一次、落盘、两侧共用;服务端与 App 用同一套模板(ktv/identity.py ↔ DeviceIdentity.kt)。
# ⚠️ 加载是纯本地小文件(可同步);任何**网络**动作(whoami)仍在端口监听之后、后台线程里。
"device_identity_auto": True, # True = 自动生成/载入身份
"device_identity_file": "", # 空 = <data_dir>/device-identity.json
"device_identity_preset": "", # 空 = 随机模板;可填 aml_s905x3 / rk3328 等
# 设备身份**时效**:`device-identity.json` 的 `created_at` 距今超过这个小时数 → 每轮维护开始时
# 自动 `regenerate()`(换新 mac/sn),并写事件「设备身份已过期(Xh),自动更换…」。
# 0 = 关闭时效检查(身份生成一次就永久固定)。
"device_identity_ttl_hours": 24,
# 取链(i.php / do.php)之间的随机间隔:**每次请求前等 5~10 秒**。
# ⚠️ 目的:**避免被上游判定为攻击**(短时间内大量取链会触发风控/封禁),
# 同时也避免给上游造成拥堵。i.php 与 do.php 都走同一个 `resolver._pace()`。
# 全局串行生效(所有下载线程共用同一个节奏,`_pace()` 内一把锁),
# 因此 10 并发也不会叠加成对上游的洪水。
"upstream_interval_min_ms": 5000,
"upstream_interval_max_ms": 10000,
"upstream_request_interval_ms": 300, # 旧字段:仅作为上面两项缺失时的兜底
# ── whoami 设备心跳(官方 App 的第三个请求,见 ktv/whoami.py)──
# 官方客户端启动后会 `POST http://hbeat.ktvsky.com/whoami?<24 个字段>` 登记设备身份,
# 并按响应里的 `next_interval` 周期续报;上游据此判断"设备是否合法/活跃"。
# ⚠️ 全部在后台守护线程里跑,失败只记日志,**绝不阻塞启动**。
"whoami_enabled": True,
"whoami_url": "http://hbeat.ktvsky.com/whoami",
"whoami_interval_sec": 3600, # 响应里没给 next_interval 时用这个
"whoami_ktv_id": "10006789", # 门店/包厢 id(抓包值)
"whoami_src": "600007", # 渠道来源(抓包值)
"whoami_src_at": "600001", # 归因渠道(抓包值)
"whoami_api": 31, # _api:Android API 级别(31 = Android 12)
"whoami_pkn": "com.origjoy.local.ktv", # _pkn:伪装成原版包名
"whoami_manufacturer": "", # 空 = 自动(platform.system())
"whoami_model": "", # 空 = 自动(platform.machine())
"whoami_cid": "", # 空 = 自动(A_<upstream_mac>)
# ── 曲库同步(官方 Muse,App 的同步方式)──
"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=关闭定时同步
# ── 存储守卫(媒体在云盘/FUSE 上时尤其重要)──
# ⚠️ 媒体走 CloudDrive2 这类挂载时:**本地盘**是上传缓冲、**云盘**是最终存储,
# 写盘速度超过云盘上传速度会把本地盘撑爆 → 因此既看水位、也按上传速度限速。
"local_space_path": "/", # 上传缓冲/本地盘所在(看它的剩余)
"min_free_local_bytes": 20 * 1024 ** 3, # 本地剩余低于此值 → 暂停下载
"min_free_media_bytes": 20 * 1024 ** 3, # 云盘配额剩余低于此值 → 暂停下载
"resume_free_margin_bytes": 5 * 1024 ** 3, # 恢复阈值 = 下限 + 该余量(迟滞,避免抖动)
"storage_check_interval_sec": 15,
"pause_on_low_space": True,
# 媒体下载的**聚合限速**(MB/s):务必 ≤ 云盘上传速度,否则本地缓冲会积压。
# 例:云盘上传 50 Mbps ≈ 6.25 MB/s → 这里填 5;若 50 表示 50 MB/s 则可填 40。
"media_rate_limit_mb_s": 5.0,
"cloud_upload_mbps": 50, # 仅用于界面提示与默认值推算
# ── 歌曲表(keeps)批量下载(批量驱动器:只补歌曲表里的歌)──
"batch_enabled": False, # 启动时是否自动开始批量(也可在界面点开始)
"batch_target_pending": 200, # 队列里始终保持多少首待下载
"batch_check_interval_sec": 30, # 巡检间隔
"batch_report_interval_sec": 600, # 定时报告间隔(写 BATCH 进度 事件)
"batch_max_consecutive_failures": 20, # 连续失败达到该值 → 致命停止
"batch_low_space_checks": 5, # 空间不足连续 N 次检查 → 致命停止
"batch_stale_minutes": 20, # 无新完成超过 N 分钟 → 致命停止
# 致命停止后**自动重试**(自主恢复):上游被限流/断网这类问题等一段时间会自愈
"batch_fatal_auto_retry": True,
"batch_fatal_retry_minutes": 60, # 首次重试等待
"batch_fatal_retry_backoff": 2.0, # 每次失败后等待翻倍
"batch_fatal_max_retries": 6, # 最多自动重试几次(含首次),超过则彻底停下等人处理
"batch_retry_reset_failed": True, # 重试前把 failed 任务重置(否则它们会一直卡着)
# 上游 token 失效冷却(i.php 拿不到 token 时不再死磕)
"upstream_token_fail_cooldown_sec": 900,
# ── 歌曲表**自动维护**(把"补队下载 / 扫描登记 / 失败重试"合并成一个任务:ktv/maintain.py)──
# 每 `maintain_interval_sec`(默认 600 = 10 分钟)跑一轮,顺序固定:
# 身份时效检查 → 扫描媒体目录(登记新文件)→ 失败重试 → 按歌曲表补队下载 → 等队列排空 → 停心跳。
# ⚠️ 默认**不自动运行**(要人在界面点「启动」);扫描/重试各自 try/except,失败只记日志不影响本轮下载。
# ⚠️ 访问媒体目录(FUSE 可能长时间阻塞)与网络(whoami)全在**后台守护线程**里,
# 并且**必须在两个 HTTP 端口监听之后**才启动(见 main.py 的启动顺序)。
"maintain_enabled": False, # 服务启动时是否自动开始维护(默认否,界面手点)
"maintain_interval_sec": 600, # 每轮间隔(秒);默认 600 = 每十分钟
"maintain_scan_media": True, # 每轮扫描媒体目录并把新文件登记进歌曲表
"maintain_retry_failed": True, # 每轮把 failed/missing 重置为待下载(失败重试)
"maintain_heartbeat": True, # 有歌要下时启动 whoami 心跳,排空后自动停
"maintain_drain_timeout_sec": 3600, # 单轮"等队列排空"最多等多久(秒),超时记 last_result 并进入下一轮
# 兼容旧部署的兜底:维护任务**未启动**时,是否退回"三个独立守护线程"
# (whoami / FailedRetry / media_scan_scheduler)。默认 False —— 这些能力已合并进本任务。
"maintain_legacy_daemons": False,
# ── 歌曲表(keeps)↔ 媒体文件 一致性 ──
# 一致性只由「媒体目录扫描」承担(`Store.keep_adopt_media`,见下):
# ① 状态 done 但文件不存在/过小 → 回到待下载(缺失回队);
# ② 媒体目录里不在歌曲表的文件 → **一律登记进歌曲表**(认领),**绝不删除**;
# ③ 曲库匹配不到 / 文件还没下的行原样保留(允许"先加入、以后再下")。
# ⚠️ **永不删除 keeps 行**(只改状态);只有「显式移除」和「导入=整表替换」会删 keeps 行。
# ⚠️ 删本地文件只有两条路径:用户显式移除(`keep_remove` → `_safe_delete_media`,
# realpath 前缀校验 + 只删 `<编号>.ts`),以及确认后的整表替换之外**没有**别的入口。
# ── 媒体目录扫描(发现新文件 → 自动登记进歌曲表;缺失 → 回队)──
# ⚠️ 媒体目录在云盘/FUSE 上,`listdir`/`stat` 可能长时间阻塞,因此:
# * 列举动作一律在**带超时的守护线程**里做(见 `Store._scan_media_entries` /
# `Store.keep_adopt_media`),超时或异常就放弃本次扫描,**绝不阻塞调用方、绝不改状态**;
# * 扫描结果只有三种动作:**新增登记**(不在歌曲表的文件)/ **更新状态**(在表里但不是 done)
# / 跳过(文件名非法或过小);此外把"done 但文件缺失/过小"的条目**回队**。
# **绝不删除任何文件**("删文件"只有用户显式移除才会发生)。
"media_scan_enabled": True, # 定时扫描媒体目录(守护线程,端口监听之后启动)
"media_scan_interval_sec": 600, # 扫描间隔(秒);0 = 关闭定时扫描
"media_scan_min_bytes": 6 * 1024 * 1024, # 视为有效媒体的最小字节数(默认沿用 min_file_bytes;
# 填 0 = 跟随 min_file_bytes 的当前值)
"media_scan_timeout_sec": 30, # 列举媒体目录的超时(秒);超时 = 本次扫描放弃、不改状态
# ── 管理界面性能 ──
# /api/stats 含全表统计(69 万行);管理界面每几秒轮询一次 → 带 TTL 缓存,目标 < 300ms。
# 0 = 关闭缓存(每次都真查,小库/排错时用)。写入 keeps/songs/downloads 会自动让缓存失效。
"stats_cache_sec": 4,
# ── 下载 ──
"concurrency": 10, # 同时下载数(硬上限 10)
"download_order": "hot", # hot(热度) | tid(编号) | name(歌名) | lang
"download_order_desc": True,
"verify_md5": False, # 上游一般不提供媒体 md5,默认只校验大小/TS 结构
"min_file_bytes": 6 * 1024 * 1024, # App 的 SongFileValidator 阈值
"max_attempts": 3,
"retry_backoff_sec": 20,
"timeout_sec": 60,
"user_agent": "MaidongKTV/1.2",
# ── App 点歌推送(`POST /push_download`,见 ktv/pushdl.py)──
# App 点歌拿到下载地址后,会把该地址同步推给本服务器;服务器**先校验、再去重**,
# 无重复才登记进歌曲表并把这条 URL 直接入队下载(省一次上游取址)。
# 鉴权口径与 service_token 一致:请求头 `X-Push-Token` 或 body 里的 `token`。
"push_token": "local", # 推送鉴权串(空 = 回落到 "local",不会变成"谁都能推")
"push_rate_limit_per_min": 60, # 同一 IP 每分钟最多接受多少条推送(0 = 关闭限流)
"push_dedupe_sec": 300, # 同一编号在该秒数内重复推送 → 判重(不重复下载);0 = 关闭
# ── 下载队列:失败任务**自动重试**(后台守护线程,间隔 10 分钟)──
# 目的:failed/missing 的任务不用等人点「失败任务重试」,到点自动重置为待下载(重下)。
# 跳过条件(见 downloader.FailedRetry):下载池被**人工暂停**、**存储守卫水位不足**、
# 或重置数量为 0(不写日志,避免刷屏)。
"retry_failed_enabled": True,
"retry_failed_interval_sec": 600, # 自动重试间隔(秒);0 = 关闭
"retry_failed_max_attempts": 3, # 自动重试时用的"重试上限"(默认沿用 max_attempts;
# 填 0 = 跟随 max_attempts 的当前值)
}
CONCURRENCY_CAP = 10
_IP_RE = re.compile(r"^https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?(?:/|$)", re.I)
# **旧版本的默认值**(仅含本版本改过默认值的键)。
# 为什么需要:`save()` 会把**所有** DEFAULTS 写进配置文件,所以老机器上的配置文件里
# `upstream_interval_min_ms: 3000` 这些"当年的默认值"和"用户显式配置"长得一模一样 ——
# 不迁移的话,本次变更(取址 5~10 秒、避免被上游判定为攻击)在老机器上**永远不会生效**。
# 迁移只覆盖"值恰好等于旧默认值"的项;用户改过的其它值一律尊重(详见 `_migrate_legacy_defaults`)。
LEGACY_DEFAULTS: dict[str, Any] = {
"upstream_interval_min_ms": 3000, # 旧默认 3~5 秒 → 新默认 5~10 秒
"upstream_interval_max_ms": 5000,
}
class Config:
def __init__(self, path: str):
self.path = os.path.abspath(path)
self._lock = threading.RLock()
self._data = dict(DEFAULTS)
# `_file_keys` = 配置文件里**真正写过**的键(= 用户显式配置过的),
# `_runtime_keys` = 运行期自动填进去、**不落盘**的键(如自动设备身份覆盖的 mac/sn)。
# 两者支撑 `is_explicit()`:自动生成的值永远不会被误当成"用户显式配置"。
self._file_keys: set[str] = set()
self._runtime_keys: set[str] = set()
# 本次加载里"按新默认值迁移"的键(main.py 会写一条事件日志,便于老机器升级后核对)。
self.migrated_keys: list[str] = []
self.load()
# ── 读写 ──
def load(self) -> None:
try:
with open(self.path, "r", encoding="utf-8") as fh:
saved = json.load(fh)
if isinstance(saved, dict):
self._data.update({k: v for k, v in saved.items() if k in DEFAULTS})
self._file_keys = {k for k in saved if k in DEFAULTS}
# 手写 JSON 时 `trusted_proxies` 可能是 "127.0.0.1, 192.168.100.0/24" 这种字符串,
# 统一归一化成列表(运行期认定只认列表/字符串都行,但界面与落盘口径要一致)。
if "trusted_proxies" in saved:
from . import proxy as _proxy
self._data["trusted_proxies"] = _proxy.normalize_trusted(saved.get("trusted_proxies"))
# 手写 JSON 时 `media_hosts` / `api_hosts` / `app_token_paths` 也常写成字符串,
# 统一归一化成列表(运行期两种写法都认,但界面与落盘口径要一致)。
from . import apptoken as _apptoken
from . import proxy as _proxy2
for key in ("media_hosts", "api_hosts"):
if key in saved:
self._data[key] = _proxy2.normalize_hosts(saved.get(key))
if "app_token_paths" in saved:
self._data["app_token_paths"] = _apptoken.normalize_paths(saved.get("app_token_paths"))
self.migrated_keys = self._migrate_legacy_defaults(saved)
except FileNotFoundError:
# 首次运行:文件由我们自己按默认值创建,**不算用户显式配置**。
self.save()
except Exception:
pass
def _migrate_legacy_defaults(self, saved: dict) -> list[str]:
"""把老配置文件里"仍是旧默认值"的项升级成本版本的新默认值(**一次性、幂等**)。
判定"老配置"靠 `config_version`:老版本写的文件里没有这个键 → 视作 1 → 迁移一次并把
版本号落盘;之后用户**显式**把取址间隔改回去也会被尊重(不会每次启动都被迁移覆盖)。
⚠️ 只动 `LEGACY_DEFAULTS` 里列出的键,且**仅当值恰好等于旧默认值**时才覆盖
(用户真改过的值不会等于旧默认,除了刻意填回旧默认的情况 —— 那种情况下本次变更优先,
因为"取址 5~10 秒"是本版本的明确策略)。
"""
try:
ver = int(saved.get("config_version") or 1)
except Exception:
ver = 1
if ver >= int(DEFAULTS["config_version"]):
return []
out: list[str] = []
for key, old in LEGACY_DEFAULTS.items():
if key not in saved:
continue
if saved.get(key) == old and self._data.get(key) != DEFAULTS.get(key):
self._data[key] = DEFAULTS[key]
out.append(key)
self._data["config_version"] = int(DEFAULTS["config_version"])
try:
self.save() # 落盘:迁移只做一次
except Exception: # pragma: no cover - 只读挂载等
pass
return out
def save(self) -> None:
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
tmp = self.path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump({k: v for k, v in self._data.items() if k not in self._runtime_keys},
fh, ensure_ascii=False, indent=2)
os.replace(tmp, self.path)
def get(self, key: str, default: Any = None) -> Any:
with self._lock:
return self._data.get(key, DEFAULTS.get(key, default))
def as_dict(self) -> dict:
with self._lock:
return dict(self._data)
def is_explicit(self, key: str) -> bool:
"""该键是否是**用户显式配置**的(配置文件里写过、且非空、且不是旧版本的抓包常量)。
`ktv/identity.py` 用它实现"显式配置优先":`upstream_mac`/`upstream_sn` 为空(或还是
旧的默认抓包值)→ 自动身份覆盖它;用户真写过 → 不覆盖。
"""
with self._lock:
if key not in self._file_keys:
return False
value = str(self._data.get(key) or "").strip()
if not value:
return False
from . import identity as _identity # 延迟导入:config 是底层模块
return value != _identity.LEGACY_DEFAULTS.get(key)
def set_runtime(self, key: str, value: Any) -> None:
"""只改**运行期**取值,不落盘(自动设备身份的 mac/sn 走这里)。
⚠️ 为什么不能落盘:落盘后它就成了"用户显式配置",`regenerate()` 再生成新身份时
反而覆盖不掉旧的 mac/sn(自动生成的值把自己锁死了)。
"""
with self._lock:
self._data[key] = value
self._file_keys.discard(key)
self._runtime_keys.add(key)
def update(self, patch: dict) -> list[str]:
"""写入并返回校验警告(不阻断保存,供界面提示)。"""
warns: list[str] = []
with self._lock:
for k, v in patch.items():
if k in DEFAULTS:
self._data[k] = v
self._runtime_keys.discard(k)
# 空值 = "交还给自动身份",不算显式配置(下次启动 auto 重新填)。
if v is None or (isinstance(v, str) and not v.strip()):
self._file_keys.discard(k)
else:
self._file_keys.add(k)
# 并发硬上限
try:
c = int(self._data.get("concurrency", 10))
except Exception:
c = 10
if c > CONCURRENCY_CAP:
self._data["concurrency"] = CONCURRENCY_CAP
warns.append(f"并发数已限制为上限 {CONCURRENCY_CAP}")
elif c < 1:
self._data["concurrency"] = 1
# 媒体地址不能是裸 IP(App 会拒收)
base = str(self._data.get("public_media_base", ""))
if base and _IP_RE.match(base):
warns.append("public_media_base 是裸 IP:App 会拒收该地址,请改用域名(内网可用自建 DNS)")
if base and not base.rstrip("/").endswith("/media"):
warns.append("public_media_base 建议以 /media 结尾,与内置媒体路由保持一致")
# 取链间隔:最小值不得低于 0,最大值不得小于最小值
try:
lo = int(self._data.get("upstream_interval_min_ms", 5000))
hi = int(self._data.get("upstream_interval_max_ms", 10000))
except Exception:
lo, hi = 5000, 10000
lo = max(0, lo)
hi = max(lo, hi)
self._data["upstream_interval_min_ms"], self._data["upstream_interval_max_ms"] = lo, hi
if lo < 5000:
# 取链太快会**被上游判定为攻击**(风控/封禁)—— 默认 5~10 秒,别往小改。
warns.append("取链间隔小于 5 秒:容易被上游判定为攻击/风控,建议 5000~10000ms")
# 失败任务自动重试:间隔与重试上限做基本钳制(填错只警告,不阻断保存)
try:
rf = int(self._data.get("retry_failed_interval_sec", 600))
except Exception:
rf = 600
if rf < 0:
rf = 0
warns.append("自动重试间隔不能为负数,已按 0(关闭)处理")
elif 0 < rf < 60:
rf = 60
warns.append("自动重试间隔不得低于 60 秒,已改为 60")
self._data["retry_failed_interval_sec"] = rf
try:
rmax = int(self._data.get("retry_failed_max_attempts", 3))
except Exception:
rmax = 3
warns.append("自动重试上限必须是整数,已改回 3")
self._data["retry_failed_max_attempts"] = max(0, rmax)
# App 点歌推送:限流条数与去重窗口做基本钳制(填错只警告,不阻断保存)
try:
pr = int(self._data.get("push_rate_limit_per_min", 60))
except Exception:
pr = 60
warns.append("推送限流必须是整数(每分钟条数),已改回 60")
if pr < 0:
pr = 0
warns.append("推送限流不能为负数,已按 0(关闭限流)处理")
elif 0 < pr < 5:
warns.append("推送限流低于 5 条/分钟:正常点歌也可能被限流(沿用所填值)")
self._data["push_rate_limit_per_min"] = pr
try:
pd = int(self._data.get("push_dedupe_sec", 300))
except Exception:
pd = 300
warns.append("推送去重窗口必须是整数(秒),已改回 300")
if pd < 0:
pd = 0
warns.append("推送去重窗口不能为负数,已按 0(关闭)处理")
self._data["push_dedupe_sec"] = pd
# 媒体目录扫描:间隔 / 超时 / 最小字节数做基本钳制
try:
ms = int(self._data.get("media_scan_interval_sec", 600))
except Exception:
ms = 600
if ms < 0:
ms = 0
warns.append("媒体扫描间隔不能为负数,已按 0(关闭)处理")
elif 0 < ms < 30:
ms = 30
warns.append("媒体扫描间隔不得低于 30 秒,已改为 30")
self._data["media_scan_interval_sec"] = ms
try:
mt = float(self._data.get("media_scan_timeout_sec", 30))
except Exception:
mt = 30.0
if mt < 1:
mt = 1.0
warns.append("媒体扫描超时不得低于 1 秒,已改为 1")
self._data["media_scan_timeout_sec"] = mt
try:
mb = int(self._data.get("media_scan_min_bytes", 0))
except Exception:
mb = 0
warns.append("媒体扫描最小字节数必须是整数,已按 0(跟随 min_file_bytes)处理")
self._data["media_scan_min_bytes"] = max(0, mb)
# 歌曲表自动维护:间隔 / 排空超时做基本钳制(填错只警告,不阻断保存)
try:
mi = int(self._data.get("maintain_interval_sec", 600))
except Exception:
mi = 600
warns.append("维护间隔必须是整数(秒),已改回 600")
if mi < 0:
mi = 0
warns.append("维护间隔不能为负数,已按 0 处理")
elif 0 < mi < 30:
mi = 30
warns.append("维护间隔不得低于 30 秒,已改为 30")
self._data["maintain_interval_sec"] = mi
try:
md = float(self._data.get("maintain_drain_timeout_sec", 3600))
except Exception:
md = 3600.0
warns.append("单轮排空超时必须是非负秒数,已改回 3600")
if md < 0:
md = 0.0
self._data["maintain_drain_timeout_sec"] = md
# 设备身份时效:单位小时,0 = 关闭检查
try:
ttl = float(self._data.get("device_identity_ttl_hours", 24))
except Exception:
ttl = 24.0
warns.append("设备身份时效必须是小时数,已改回 24")
if ttl < 0:
ttl = 24.0
warns.append("设备身份时效不能为负数,已改回 24 小时")
self._data["device_identity_ttl_hours"] = ttl
# whoami 心跳:间隔/API 级别/地址做基本钳制(填错也只警告,不阻断保存)
try:
wi = int(self._data.get("whoami_interval_sec", 3600))
except Exception:
wi = 3600
if wi < 60:
self._data["whoami_interval_sec"] = 60
warns.append("whoami 心跳间隔不得低于 60 秒,已改为 60")
elif wi > 24 * 3600:
self._data["whoami_interval_sec"] = 24 * 3600
warns.append("whoami 心跳间隔超过 24 小时,已改为 24 小时")
try:
self._data["whoami_api"] = int(self._data.get("whoami_api", 31))
except Exception:
self._data["whoami_api"] = 31
warns.append("whoami_api 必须是整数(Android API 级别),已改回 31")
wurl = str(self._data.get("whoami_url") or "")
if wurl and not re.match(r"^https?://", wurl, re.I):
warns.append("whoami_url 必须以 http:// 或 https:// 开头,否则心跳一定失败")
# 设备身份(伪装成安卓盒子):模板名与文件路径做基本校验(填错只警告,不阻断保存)
preset = str(self._data.get("device_identity_preset") or "").strip()
if preset:
from . import identity as _identity
if preset not in _identity.PRESETS:
warns.append(f"设备身份模板 {preset} 不存在,将按随机模板处理"
f"(可用:{', '.join(_identity.PRESETS)})")
dpath = str(self._data.get("device_identity_file") or "").strip()
if dpath and not os.path.isabs(os.path.expanduser(dpath)):
warns.append("device_identity_file 建议填绝对路径,否则以进程工作目录为基准")
# 反向代理(IIS/ARR):可信代理列表 / 总开关 / 监听队列 / 请求体上限
# 口径:**默认全关**(安全默认);开了 switch 却没配可信代理 = 等于没开(会给警告)。
from . import proxy as _proxy # 延迟导入:config 是底层模块
trusted = _proxy.normalize_trusted(self._data.get("trusted_proxies"))
bad_proxies = _proxy.invalid_trusted(trusted)
if bad_proxies:
warns.append("trusted_proxies 里这些项解析不了(已忽略):" + "、".join(bad_proxies[:5])
+ ";请写 IP 或 CIDR,如 192.168.100.10 / 192.168.100.0/24")
self._data["trusted_proxies"] = trusted
tph = self._data.get("trust_proxy_headers", False)
if isinstance(tph, str):
tph = tph.strip().lower() in ("1", "true", "yes", "on")
self._data["trust_proxy_headers"] = bool(tph)
if self._data["trust_proxy_headers"] and not trusted:
warns.append("trust_proxy_headers=true 但 trusted_proxies 为空:代理头一律不采信"
"(等于关闭);反代部署请填代理的 IP/CIDR")
try:
hb = int(self._data.get("http_backlog", 128))
except Exception:
hb = 128
warns.append("http_backlog 必须是整数(监听队列长度),已改回 128")
if hb < 5:
hb = 5
warns.append("http_backlog 不得低于 5,已改为 5")
elif hb > 4096:
hb = 4096
warns.append("http_backlog 不得高于 4096,已改为 4096")
self._data["http_backlog"] = hb
try:
mbb = int(self._data.get("max_body_bytes", 8 * 1024 * 1024))
except Exception:
mbb = 8 * 1024 * 1024
warns.append("max_body_bytes 必须是整数(字节),已改回 8MiB")
if mbb < 4096:
mbb = 4096
warns.append("max_body_bytes 不得低于 4096 字节,已改为 4096")
elif mbb > 1024 ** 3:
mbb = 1024 ** 3
warns.append("max_body_bytes 不得高于 1GiB,已改为 1GiB")
self._data["max_body_bytes"] = mbb
# App 令牌(GET /token):时效 / 绑定 IP / 单次 / 持久化 / 受管路径 / 强制 / 签发限流。
# 口径:**默认只"带了就校验"**(老 App 不带令牌照常可用),填错只警告不阻断保存。
from . import apptoken as _apptoken # 延迟导入:config 是底层模块
try:
ttl = int(self._data.get("app_token_ttl_sec", 600))
except Exception:
ttl = 600
warns.append("app_token_ttl_sec 必须是整数(秒),已改回 600")
if ttl < _apptoken.TTL_MIN:
ttl = _apptoken.TTL_MIN
warns.append(f"令牌有效期不得低于 {_apptoken.TTL_MIN} 秒,已改为 {_apptoken.TTL_MIN}")
elif ttl > _apptoken.TTL_MAX:
ttl = _apptoken.TTL_MAX
warns.append(f"令牌有效期不得高于 {_apptoken.TTL_MAX} 秒(24 小时),已改为 {_apptoken.TTL_MAX}")
self._data["app_token_ttl_sec"] = ttl
for key, default in (("app_token_bind_ip", True), ("app_token_single_use", False),
("app_token_persist", False), ("app_token_enforce", False)):
raw = self._data.get(key, default)
if isinstance(raw, str):
self._data[key] = raw.strip().lower() in ("1", "true", "yes", "on")
else:
self._data[key] = bool(raw)
paths_raw = self._data.get("app_token_paths", "")
paths = _apptoken.normalize_paths(paths_raw)
if bool(self._data.get("app_token_enforce")) and not paths:
warns.append("app_token_enforce=true 但 app_token_paths 为空:等于没有强制任何路径")
self._data["app_token_paths"] = paths # 字符串写法统一落成列表
try:
trl = int(self._data.get("token_rate_limit_per_min", 60))
except Exception:
trl = 60
warns.append("token_rate_limit_per_min 必须是整数(每分钟签发数),已改回 60")
if trl < 0:
trl = 0
warns.append("令牌签发限流不能为负数,已按 0(关闭限流)处理")
elif 0 < trl < 5:
warns.append("令牌签发限流低于 5 个/分钟:正常换令牌也可能被限流(沿用所填值)")
self._data["token_rate_limit_per_min"] = trl
# 双站点宿主隔离:media_hosts / api_hosts 归一化成列表;给了宿主但没开代理头要提醒
from . import proxy as _proxy2
self._data["media_hosts"] = _proxy2.normalize_hosts(self._data.get("media_hosts"))
self._data["api_hosts"] = _proxy2.normalize_hosts(self._data.get("api_hosts"))
if self._data["media_hosts"] and not self._data["trust_proxy_headers"]:
warns.append("media_hosts 非空但 trust_proxy_headers=false:宿主只能用 Host 头判定,"
"反代场景请打开 trust_proxy_headers 并配好 trusted_proxies")
for key in ("media_hosts", "api_hosts"):
bad = [h for h in self._data[key] if not _proxy2.valid_host(h)]
if bad:
warns.append(f"{key} 里这些宿主写法看不懂(判定时不会被命中):"
+ "、".join(bad[:5]) + ";请写域名(可带端口),如 md.kasugano.cn")
hosts = self._data.get("upstream_hosts") or []
if isinstance(hosts, str):
hosts = [h.strip() for h in hosts.split(",") if h.strip()]
self._data["upstream_hosts"] = hosts
if not hosts:
warns.append("未配置上游歌曲源(upstream_hosts),下载任务无法取址")
self.save()
return warns
# ── 便捷 ──
@property
def media_dir(self) -> str:
return str(self.get("media_dir"))
@property
def tmp_dir(self) -> str:
"""分片临时目录:显式配置优先,否则跟随 data_dir/tmp(本地盘)。"""
return str(self.get("tmp_dir") or os.path.join(str(self.get("data_dir")), "tmp"))
def retry_failed_max_attempts(self) -> int:
"""自动重试时用的"重试上限":`retry_failed_max_attempts <= 0` → **跟随 max_attempts**。
默认值就等于 `max_attempts`(3);用户在设置里把它填 0,则始终跟随
`max_attempts` 的当前值(改了 max_attempts 立刻生效)。
"""
try:
v = int(self.get("retry_failed_max_attempts") or 0)
except Exception:
v = 0
if v > 0:
return v
try:
return max(1, int(self.get("max_attempts") or 3))
except Exception:
return 3
def media_scan_min_bytes(self) -> int:
"""媒体扫描认定的"有效媒体"最小字节数:`media_scan_min_bytes <= 0` → 跟随 min_file_bytes。"""
try:
v = int(self.get("media_scan_min_bytes") or 0)
except Exception:
v = 0
if v > 0:
return v
try:
return max(0, int(self.get("min_file_bytes") or 0))
except Exception:
return 0
def ensure_dirs(self) -> None:
# 建目录**绝不抛异常**(路径可能不可写或在只读挂载上)
for d in (self.get("data_dir"), self.get("media_dir"), self.tmp_dir):
try:
os.makedirs(str(d), exist_ok=True)
except Exception:
pass
def media_public_url(self, tid: int) -> str:
base = str(self.get("public_media_base")).rstrip("/")
return f"{base}/{tid}.ts"