"""入口:同时监听**服务端口**(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=_`、do.php 的 `device=_` # 与 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())