"""配置: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=_`、 # do.php 发 `device=_`、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": "", # 空 = /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_) # ── 曲库同步(官方 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"