版本号:
- 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 例)
288 lines
16 KiB
Python
288 lines
16 KiB
Python
"""入口:同时监听**服务端口**(App 接口 + 媒体)与**管理端口**(管理界面 + 管理 API)。
|
||
|
||
两个端口职责分离:
|
||
* 服务端口(默认 6677):只跑 App 协议与媒体分发,可以对外/对设备暴露;
|
||
* 管理端口(默认 6678):只跑管理界面与 /api/*,建议只在内网,必要时设 admin_token。
|
||
|
||
启动流程:读配置 → 建目录 → 打开 SQLite → 清理中断任务 → 启动下载池 →
|
||
装配「歌曲表自动维护」任务 → 起两个 HTTP 监听 → **监听之后**才
|
||
`maintain.start()`(扫描媒体目录/网络心跳都在它内部的后台线程里,失败只记日志)
|
||
→ 定时曲库同步。("表 ↔ 文件"一致性只有一条路径:维护任务里的「扫描媒体目录」,
|
||
它会登记新文件并把"done 但文件缺失/过小"的条目回队。)
|
||
|
||
⚠️ 2026 变更:原来三个独立守护线程(`BatchManager` / `media_scan_scheduler` / `FailedRetry`)
|
||
与 whoami 心跳的**自动启动入口**已合并进 `ktv/maintain.py` 的 `MaintainTask` ——
|
||
生产路径只剩**一条**时间线。旧的独立线程只在 `maintain_legacy_daemons=true` 时才退回使用。
|
||
|
||
退出流程:收到 SIGTERM/SIGINT → 停下载池 → 关两个监听 → 写退出日志 → `store.checkpoint()`
|
||
(把 WAL 落回主库;云盘/FUSE 上残留 `-wal` 会在重启阶段丢数据,表现为"刚加的行不见了")
|
||
→ 退出。**任何路径都不删 keeps 行**(只有"显式移除/整表替换"会删,见 store.py 顶部说明)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import logging
|
||
import os
|
||
import signal
|
||
import sys
|
||
import threading
|
||
import time
|
||
|
||
from . import admin, apptoken, health as health_mod, identity, service, ui, web
|
||
from .config import Config
|
||
from .batch import BatchManager
|
||
from .downloader import Downloader, FailedRetry, StorageGuard
|
||
from .maintain import MaintainTask
|
||
from .store import Store
|
||
from .whoami import WhoamiReporter
|
||
|
||
log = logging.getLogger("ktv")
|
||
|
||
|
||
def setup_logging(cfg: Config) -> None:
|
||
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stdout)]
|
||
try:
|
||
os.makedirs(os.path.dirname(str(cfg.get("log_path"))) or ".", exist_ok=True)
|
||
handlers.append(logging.FileHandler(str(cfg.get("log_path")), encoding="utf-8"))
|
||
except Exception:
|
||
pass
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", handlers=handlers)
|
||
|
||
|
||
def merge(*routers: web.Router) -> web.Router:
|
||
out = web.Router()
|
||
for r in routers:
|
||
out.routes.extend(r.routes)
|
||
# 守卫(App 令牌 / 双站点宿主隔离)也必须跟着路由一起合并:
|
||
# 漏了这一步,"直接 dispatch 有守卫、经端口跑却没守卫"就会变成两套行为。
|
||
if out.guard is None and getattr(r, "guard", None) is not None:
|
||
out.guard = r.guard
|
||
return out
|
||
|
||
|
||
def sync_scheduler(cfg: Config, store: Store, stop: threading.Event) -> None:
|
||
hours = float(cfg.get("muse_auto_sync_hours") or 0)
|
||
if hours <= 0:
|
||
return
|
||
log.info("曲库定时同步已启用:每 %.1f 小时", hours)
|
||
while not stop.is_set():
|
||
if stop.wait(hours * 3600):
|
||
return
|
||
try:
|
||
admin.run_sync(cfg, store)
|
||
except Exception as exc: # pragma: no cover
|
||
store.event("ERROR", f"定时曲库同步失败:{exc}")
|
||
|
||
|
||
def media_scan_scheduler(cfg: Config, store: Store, stop: threading.Event) -> None:
|
||
"""**媒体目录扫描**(旧守护线程入口,默认不走):每 `media_scan_interval_sec` 秒扫描媒体目录。
|
||
|
||
它**只增/只改状态,绝不删除任何文件**(发现未知文件 = 认领,不是清理):
|
||
登记新文件、并让"done 但文件缺失/过小"的条目回队。
|
||
真正的目录列举在 `store.keep_adopt_media()` 的带超时守护线程里做(FUSE 可能长时间阻塞),
|
||
本线程失败只记日志,**绝不阻塞或打断服务**。间隔 ≤ 0 或 `media_scan_enabled=false` 即关闭。
|
||
"""
|
||
if not bool(cfg.get("media_scan_enabled", True)):
|
||
log.info("媒体目录扫描:已关闭(media_scan_enabled=false)")
|
||
return
|
||
interval = int(cfg.get("media_scan_interval_sec") or 0)
|
||
if interval <= 0:
|
||
log.info("媒体目录扫描:已关闭(media_scan_interval_sec=0)")
|
||
return
|
||
log.info("媒体目录扫描已启用:每 %d 秒(超时 %ss)", interval, cfg.get("media_scan_timeout_sec"))
|
||
while not stop.is_set():
|
||
if stop.wait(interval):
|
||
return
|
||
try:
|
||
admin.run_media_scan(cfg, store)
|
||
except Exception as exc: # pragma: no cover - 兜底
|
||
store.event("ERROR", f"媒体目录扫描失败:{exc}")
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
ap = argparse.ArgumentParser(description="麦动KTV 自建歌曲源服务器")
|
||
ap.add_argument("--config", default=os.environ.get("KTV_CONFIG", "/etc/ktv-source/server-config.json"))
|
||
ap.add_argument("--service-port", type=int)
|
||
ap.add_argument("--admin-port", type=int)
|
||
ap.add_argument("--no-download", action="store_true", help="启动时不自动开始下载池")
|
||
args = ap.parse_args(argv)
|
||
|
||
cfg = Config(args.config)
|
||
if args.service_port:
|
||
cfg.update({"service_port": args.service_port})
|
||
if args.admin_port:
|
||
cfg.update({"admin_port": args.admin_port})
|
||
cfg.ensure_dirs()
|
||
setup_logging(cfg)
|
||
|
||
store = Store(str(cfg.get("db_path")), str(cfg.get("db_journal_mode") or "WAL"),
|
||
stats_cache_sec=float(cfg.get("stats_cache_sec") or 0))
|
||
store.event("INFO", f"服务启动:service_port={cfg.get('service_port')} admin_port={cfg.get('admin_port')} "
|
||
f"db={store.path} journal={store.journal_mode}")
|
||
if getattr(cfg, "migrated_keys", None):
|
||
# 老配置文件里"还是旧默认值"的项已升级到新默认值(如取址间隔 3~5 → 5~10 秒)
|
||
store.event("INFO", "配置已按新默认值迁移:" + "、".join(cfg.migrated_keys))
|
||
store.reset_stuck()
|
||
|
||
# 设备身份(伪装成一台安卓盒子):**同步加载**(纯本地小文件,不碰网络),
|
||
# 载入/生成后立刻接进配置 —— 这样 i.php 的 `mac=<mac>_<sn>`、do.php 的 `device=<mac>_<sn>`
|
||
# 与 whoami 的 `_mac`/`androidid`/`_cid` 从第一刻起就是**同一份身份**。
|
||
# 任何**网络**动作(whoami)仍然只能在端口监听之后、后台线程里(见本文件末尾)。
|
||
device_identity = identity.DeviceIdentity(cfg, store)
|
||
try:
|
||
ident = device_identity.ensure()
|
||
if ident:
|
||
store.event("INFO", f"设备身份(伪装安卓盒子):模板={ident.get('preset')} "
|
||
f"机型={ident.get('_m')} mac={ident.get('mac')} sn={ident.get('sn')} "
|
||
f"来源={device_identity.origin} 文件={device_identity.path()}")
|
||
else:
|
||
store.event("WARN", f"未启用自动设备身份(device_identity_auto="
|
||
f"{bool(cfg.get('device_identity_auto'))}),"
|
||
f"将上报真实本机机型;文件={device_identity.path()}")
|
||
except Exception as exc: # pragma: no cover - 兜底
|
||
store.event("WARN", f"设备身份加载失败(不影响主流程):{exc}")
|
||
|
||
downloader = Downloader(cfg, store)
|
||
guard = StorageGuard(cfg, store, downloader)
|
||
downloader.guard = guard
|
||
guard.start() # 空间守卫:水位不足自动暂停/恢复
|
||
# 失败任务自动重试:**装配好但不再单独起线程**(能力已并入「歌曲表自动维护」每轮的重试步骤);
|
||
# 管理接口通过 downloader.retry 仍可手工触发。
|
||
retry = FailedRetry(cfg, store, downloader, guard)
|
||
downloader.retry = retry
|
||
whoami = WhoamiReporter(cfg, store) # whoami 心跳:由维护任务 start/stop(不再独立定时)
|
||
if not args.no_download:
|
||
downloader.start()
|
||
# 歌曲表**自动维护**:唯一的后台调度任务(合并 扫描登记 / 失败重试 / 补队下载 / 身份时效 / 心跳)。
|
||
# 这里只**装配**;`start()` 必须在两个端口监听之后(历史上两次 FUSE 阻塞导致"不监听端口")。
|
||
maintain = MaintainTask(cfg, store, downloader, guard, whoami=whoami, identity=device_identity)
|
||
# 旧批量驱动器:仅作 `maintain_legacy_daemons=true` 的兜底(新路径全部走 maintain)
|
||
batch = BatchManager(cfg, store, downloader, guard)
|
||
|
||
stop = threading.Event() # 定时线程共用(退出时一起停)
|
||
threading.Thread(target=sync_scheduler, args=(cfg, store, stop), name="muse-scheduler", daemon=True).start()
|
||
|
||
# 探活快照(GET /healthz):**这里只装配**,刷新线程在端口监听之后才 start
|
||
# (见 ktv/health.py:探活必须微秒级返回,数字由后台线程每 30 秒刷一次)
|
||
health = health_mod.HealthSnapshot(cfg, store, downloader)
|
||
|
||
svc = merge(service.build_router(cfg, store, downloader, health=health))
|
||
adm = merge(admin.build_router(cfg, store, downloader, batch=batch, whoami=whoami,
|
||
device_identity=device_identity, maintain=maintain),
|
||
ui.build_router())
|
||
# 反向代理(IIS/ARR):把 cfg 与监听队列长度交给 HTTP 层 —— 只有给了 cfg,才会按
|
||
# `trusted_proxies`/`trust_proxy_headers` 认定真实客户端 IP(默认全关,行为不变)。
|
||
backlog = int(cfg.get("http_backlog") or 128)
|
||
s1 = web.start_server(str(cfg.get("service_host")), int(cfg.get("service_port")), svc,
|
||
auth_token="", log_fn=lambda m: log.debug("svc %s", m),
|
||
cfg=cfg, backlog=backlog)
|
||
s2 = web.start_server(str(cfg.get("admin_host")), int(cfg.get("admin_port")), adm,
|
||
auth_token=str(cfg.get("admin_token") or ""),
|
||
log_fn=lambda m: log.debug("adm %s", m),
|
||
cfg=cfg, backlog=backlog)
|
||
# ── 以下全部在**两个端口都已监听之后**启动(顺序是刻意的,别往上挪):就算这段出问题,
|
||
# 端口也已处于监听状态,不会出现"进程活着但不监听端口"。 ──
|
||
try:
|
||
health.start()
|
||
except Exception as exc: # pragma: no cover - 兜底
|
||
store.event("WARN", f"探活快照刷新线程启动失败(/healthz 仍可用,只是数字不更新):{exc}")
|
||
if bool(cfg.get("trust_proxy_headers")):
|
||
store.event("INFO", f"反向代理头已启用:trusted_proxies={cfg.get('trusted_proxies')}"
|
||
f"(只有这些地址发来的 X-Forwarded-* 才被采信)")
|
||
else:
|
||
store.event("INFO", "反向代理头未启用(trust_proxy_headers=false):一律按 TCP 对端认定客户端 IP")
|
||
# App 令牌(GET /token)与双站点宿主隔离:启动时把**生效口径**写一条事件,便于运维核对
|
||
# (配置可在界面里随时改,改完立刻生效,不必重启)。
|
||
_tok = apptoken.registry_for(cfg, store)
|
||
store.event("INFO", f"App 令牌:有效期 {_tok.ttl()} 秒、绑定客户端IP={_tok.bind_ip()}、"
|
||
f"单次={_tok.single_use()}、持久化={_tok.persist()}、"
|
||
f"限流={_tok.rate_per_min()}/分钟/来源IP、"
|
||
f"强制={_tok.enforce()}、受管路径={_tok.paths()}"
|
||
f"(强制=false 时老 App 不带令牌照常可用)")
|
||
if cfg.get("media_hosts"):
|
||
store.event("INFO", f"双站点宿主隔离已启用:媒体宿主={cfg.get('media_hosts')}、"
|
||
f"API 宿主={cfg.get('api_hosts')}(宿主按可信 X-Forwarded-Host 判定,"
|
||
f"不可信时退回 Host 头;端口自动容错)")
|
||
else:
|
||
store.event("INFO", "双站点宿主隔离未启用(media_hosts 为空):任何宿主都能访问全部路径")
|
||
maintain_started = False
|
||
if bool(cfg.get("maintain_enabled")) or bool(cfg.get("batch_enabled")):
|
||
# maintain_enabled = 新键;batch_enabled = 旧键(老部署靠它开机自动跑批量,继续认它)
|
||
try:
|
||
maintain.start()
|
||
maintain_started = True
|
||
store.event("INFO", f"歌曲表自动维护已启动:每 {cfg.get('maintain_interval_sec')}s"
|
||
f"(扫描={bool(cfg.get('maintain_scan_media', True))}、"
|
||
f"重试={bool(cfg.get('maintain_retry_failed', True))}、"
|
||
f"心跳={bool(cfg.get('maintain_heartbeat', True))})")
|
||
except Exception as exc: # pragma: no cover - 兜底
|
||
store.event("WARN", f"歌曲表自动维护启动失败(可在界面手动启动):{exc}")
|
||
else:
|
||
store.event("INFO", "歌曲表自动维护未自动启动(maintain_enabled=false):"
|
||
"可在管理界面点「启动」")
|
||
# 兼容兜底:只有显式打开 `maintain_legacy_daemons` 才退回"三个独立守护线程"
|
||
# (whoami / FailedRetry / media_scan_scheduler)。默认关闭 = 合并成一条时间线。
|
||
if not maintain_started and bool(cfg.get("maintain_legacy_daemons")):
|
||
try:
|
||
if bool(cfg.get("batch_enabled")):
|
||
batch.start()
|
||
whoami.start()
|
||
store.event("INFO", f"whoami 心跳已启动:{cfg.get('whoami_url')}")
|
||
retry.start()
|
||
store.event("INFO", f"失败任务自动重试已启动:每 {cfg.get('retry_failed_interval_sec')}s")
|
||
threading.Thread(target=media_scan_scheduler, args=(cfg, store, stop),
|
||
name="media-scan", daemon=True).start()
|
||
store.event("INFO", "已按 maintain_legacy_daemons=true 退回旧的独立守护线程"
|
||
"(扫描/重试/心跳各自定时,不再由维护任务驱动)")
|
||
except Exception as exc: # pragma: no cover - 兜底
|
||
store.event("WARN", f"旧守护线程启动失败(不影响主流程):{exc}")
|
||
log.info("服务接口: http://%s:%s/ (App: /api.php /i.php /music/do.php /media/<编号>.ts /push_download"
|
||
";探活 /healthz、排错 /api/client_ip;反向代理见 deploy/iis/)",
|
||
cfg.get("service_host"), cfg.get("service_port"))
|
||
log.info("管理界面: http://%s:%s/", cfg.get("admin_host"), cfg.get("admin_port"))
|
||
|
||
def shutdown(signum, _frame):
|
||
log.info("收到信号 %s,正在退出…", signum)
|
||
stop.set()
|
||
try:
|
||
maintain.stop("服务退出") # 维护任务:停排程 + 停 whoami 心跳
|
||
# 有界等待:让"正在跑的那一轮 / run_now 的临时线程"收尾,避免它和下面的
|
||
# store.checkpoint() 抢同一根 sqlite 连接(只等 2 秒,绝不拖住退出)。
|
||
maintain.join(timeout=2.0)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
whoami.stop() # 心跳线程:置停止位即可(daemon,不 join 等待)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
retry.stop() # 失败任务自动重试:同上
|
||
except Exception:
|
||
pass
|
||
try:
|
||
health.stop() # 探活刷新线程:置停止位即可(daemon,不 join)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
downloader.stop(wait=False)
|
||
except Exception:
|
||
pass
|
||
s1.shutdown()
|
||
s2.shutdown()
|
||
store.event("INFO", "服务已退出")
|
||
# ⚠️ 退出前把 WAL 落回主库:数据目录在云盘/FUSE 上时残留 -wal 会在重启阶段
|
||
# 丢失/无法恢复(README §11.2 的 SIGBUS 事故),表现为"重启后刚加的行不见了"。
|
||
store.checkpoint()
|
||
sys.exit(0)
|
||
|
||
signal.signal(signal.SIGTERM, shutdown)
|
||
signal.signal(signal.SIGINT, shutdown)
|
||
|
||
while True:
|
||
time.sleep(3600)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|