Files
maidong-ktv/selfhost-source/server/ktv/config.py
T
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

693 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""配置: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"