合并自建歌曲源支线(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 例)
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
"""探活快照(`GET /healthz`):**纯内存返回 + 后台线程刷新**,绝不阻塞探活请求。
|
||||
|
||||
为什么不能"每次探活都查一次库":
|
||||
* 曲库 69 万行时 `count(*)` 这类全表统计在云盘/FUSE 上要数秒 —— IIS/负载均衡的探活会超时,
|
||||
还会把它们自己的线程池拖住;
|
||||
* 探活只关心"进程活着、能服务、大致有多少活干",**不需要秒级精确**。
|
||||
|
||||
所以:后台守护线程每 `REFRESH_SEC` 秒查一次(只查数据库的**缓存值**,绝不列举媒体目录、
|
||||
绝不 stat 媒体文件),把结果塞进内存快照;`/healthz` 直接返回快照(微秒级)。
|
||||
|
||||
快照字段:
|
||||
* `library`:曲库(songs)条数;`keeps`:歌曲表(keeps)条数;
|
||||
* `downloader`:下载池的**纯内存**状态(跑没跑、暂停没、并发、在跑几首)——
|
||||
来自 `Downloader.health()`,不查库、不碰磁盘。
|
||||
|
||||
⚠️ 刷新失败只记一次日志(不刷屏),快照保留上一次的值(探活照样 200:进程还在服务)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from . import __version__
|
||||
|
||||
# 后台刷新间隔(秒)。探活本身不查库,几十秒的"数字略旧"完全可接受;
|
||||
# 想更快看到数字,调小这里即可(代价是更频繁的全表统计)。
|
||||
REFRESH_SEC = 30.0
|
||||
# 首次刷新前的占位(进程刚起来、库还没打开时的兜底值)
|
||||
_EMPTY = {"library": 0, "keeps": 0, "downloader": {}}
|
||||
|
||||
|
||||
class HealthSnapshot:
|
||||
def __init__(self, cfg, store, downloader=None, refresh_sec: float = REFRESH_SEC):
|
||||
self.cfg = cfg
|
||||
self.store = store
|
||||
self.downloader = downloader
|
||||
self.refresh_sec = max(1.0, float(refresh_sec or REFRESH_SEC))
|
||||
self._lock = threading.Lock()
|
||||
self._data = dict(_EMPTY)
|
||||
self._at = 0.0 # 最近一次刷新成功时刻(monotonic)
|
||||
self._started = time.monotonic() # 进程/快照起点:uptime_sec 用
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._err_logged = ""
|
||||
|
||||
# ── 生命周期 ──
|
||||
def start(self) -> None:
|
||||
"""启动后台刷新线程(**必须在两个端口监听之后**调用,见 main.py 的启动顺序)。"""
|
||||
if self._thread is not None:
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._loop, name="health-refresh", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
|
||||
def _loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self.refresh()
|
||||
except Exception: # pragma: no cover - refresh 内部已兜底
|
||||
pass
|
||||
if self._stop.wait(self.refresh_sec):
|
||||
return
|
||||
|
||||
# ── 刷新(**只有后台线程和测试会调用**;探活路径绝不调用它)──
|
||||
def refresh(self) -> dict:
|
||||
"""查一次库/下载池,更新内存快照并返回快照(任何异常都不抛出)。"""
|
||||
data = dict(_EMPTY)
|
||||
try:
|
||||
st = self.store.stats()
|
||||
data["library"] = int(st.get("songs") or 0)
|
||||
data["done"] = int(st.get("done") or 0)
|
||||
data["pending"] = int(st.get("pending") or 0)
|
||||
except Exception as exc:
|
||||
self._note_err(f"曲库统计失败:{exc}")
|
||||
try:
|
||||
data["keeps"] = int(self.store.keep_stats().get("total") or 0)
|
||||
except Exception as exc:
|
||||
self._note_err(f"歌曲表统计失败:{exc}")
|
||||
try:
|
||||
data["downloader"] = self.downloader.health() if self.downloader is not None else {}
|
||||
except Exception as exc:
|
||||
self._note_err(f"下载池状态失败:{exc}")
|
||||
data["downloader"] = {}
|
||||
with self._lock:
|
||||
self._data = data
|
||||
self._at = time.monotonic()
|
||||
return dict(data)
|
||||
|
||||
def _note_err(self, text: str) -> None:
|
||||
"""同一类错误只写一条事件(避免探活把日志刷爆)。"""
|
||||
if text == self._err_logged:
|
||||
return
|
||||
self._err_logged = text
|
||||
try:
|
||||
self.store.event("WARN", f"探活快照刷新失败(沿用上次的值):{text}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 读(探活路径:纯内存,微秒级)──
|
||||
@property
|
||||
def uptime_sec(self) -> int:
|
||||
return int(max(0.0, time.monotonic() - self._started))
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
"""当前快照(**不查库、不碰媒体目录、绝不阻塞**)。"""
|
||||
with self._lock:
|
||||
data = dict(self._data)
|
||||
at = self._at
|
||||
data.update({
|
||||
"ok": True,
|
||||
"version": __version__,
|
||||
"uptime_sec": self.uptime_sec,
|
||||
"snapshot_age_sec": int(max(0.0, time.monotonic() - at)) if at else -1,
|
||||
})
|
||||
return data
|
||||
|
||||
# 老接口字段(`songs`/`done`/`pending`/`concurrency`)也一并给出,老脚本不用改
|
||||
def snapshot_compat(self) -> dict:
|
||||
data = self.snapshot()
|
||||
data.setdefault("songs", data.get("library", 0))
|
||||
dl = data.get("downloader") or {}
|
||||
data["concurrency"] = int(dl.get("concurrency") or 0)
|
||||
return data
|
||||
Reference in New Issue
Block a user