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

288 lines
16 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.
"""入口:同时监听**服务端口**(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())