合并自建歌曲源支线(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:
zijunchiang
2026-10-14 02:06:28 +08:00
parent e060ae5ebe
commit 87aa979abd
68 changed files with 21305 additions and 105 deletions
+251
View File
@@ -0,0 +1,251 @@
"""歌曲表(keeps)批量下载驱动器:**持续补队 + 定时巡检 + 致命错误自动停止**。
设计要点(对应"服务器只按歌曲表下载/更新文件"):
1. **持续补队(只补歌曲表里的)**:保持队列里始终有 `batch_target_pending` 首待下载
(默认 200),每 `batch_check_interval_sec` 补一次;补队来源是
`store.keep_next_batch()` —— 即 `keeps` 里 `state NOT IN ('done','downloading')`
的歌,不在歌曲表里的歌永远不会被下载。
2. **定时巡检**:每轮采样水位/活动任务/完成数;每 `batch_report_interval_sec` 往
事件日志写一条 `BATCH 进度`(% / 速率 / 预计剩余 / 本地与云盘水位),界面可查。
3. **致命错误即刻停止**(停止后不再补队,并暂停下载池、写入 `FATAL` 事件):
- 空间不足**连续** `batch_low_space_checks` 次(默认 5 次 ≈ 2.5 分钟)仍未缓解;
- 连续失败次数 ≥ `batch_max_consecutive_failures`(默认 20);
- 长时间无进展:`batch_stale_minutes`(默认 20 分钟)内 0 完成**且队列非空**
(队列为空说明只是"没有歌要下",不算致命)。
4. **歌曲表里的全部下完 = 正常收工**:队列为空且没有未完成的歌曲表条目时,写一条
`INFO BATCH 已按歌曲表下载完毕` 并以 `fatal=False` 正常停止(不是致命错误)。
"""
from __future__ import annotations
import threading
import time
class BatchManager:
def __init__(self, cfg, store, downloader, guard=None):
self.cfg = cfg
self.store = store
self.downloader = downloader
self.guard = guard
self._lock = threading.Lock()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self.state = {
"enabled": False, "fatal": False, "reason": "", "started_at": 0, "stopped_at": 0,
"last_check": 0, "last_report": 0, "checks": 0, "low_space_streak": 0,
"consecutive_failures": 0, "last_done": 0, "last_progress_at": 0,
"baseline_done": 0, "enqueued_total": 0,
"retries": 0, "next_retry_at": 0, "retry_timer": None,
}
# ── 只读状态 ──
def _keeps(self) -> dict:
"""歌曲表统计(旧假 store 没有该方法时返回空字典,绝不因此让巡检崩溃)。"""
fn = getattr(self.store, "keep_stats", None)
try:
return fn() if callable(fn) else {}
except Exception:
return {}
def _keep_remaining(self) -> int | None:
"""歌曲表里还没下完的数量;`None` = store 没有这个方法(此时不判"收工")。"""
fn = getattr(self.store, "keep_remaining", None)
if not callable(fn):
return None
try:
return int(fn())
except Exception:
return None
def _keep_candidates(self, limit: int, order: str, desc: bool) -> list[dict]:
"""补队候选:**只来自歌曲表**(keeps 里未完成的歌)。"""
fn = getattr(self.store, "keep_next_batch", None)
if not callable(fn):
return []
try:
return fn(int(limit), order, desc)
except Exception:
return []
def status(self) -> dict:
with self._lock:
st = dict(self.state)
stats = self.store.stats()
st.update({"done": stats["done"], "pending": stats["pending"], "failed": stats["failed"],
"library": stats["songs"], "enqueued": stats["enqueued"]})
st["remaining"] = max(0, stats["songs"] - stats["done"])
# 歌曲表维度(界面显示 待下/已下/失败;下载只认歌曲表)
ks = self._keeps()
st.update({"keeps_want": int(ks.get("want", 0)), "keeps_done": int(ks.get("done", 0)),
"keeps_failed": int(ks.get("failed", 0)), "keeps_total": int(ks.get("total", 0)),
"keeps_bytes": int(ks.get("bytes", 0)),
"keeps_remaining": self._keep_remaining() or 0})
return st
# ── 启停 ──
def start(self) -> dict:
with self._lock:
if self.state["enabled"] and not self.state["fatal"]:
return {"ok": False, "error": "批量任务已在运行"}
stats = self.store.stats()
self.state.update({"enabled": True, "fatal": False, "reason": "",
"started_at": int(time.time()), "stopped_at": 0,
"checks": 0, "low_space_streak": 0, "consecutive_failures": 0,
"last_done": stats["done"], "baseline_done": stats["done"],
"last_progress_at": int(time.time()), "enqueued_total": 0})
self._stop.clear()
if self.downloader is not None and self.downloader.paused_by_space:
self.downloader.resume(by="space")
if self.downloader is not None and not self.downloader._threads:
self.downloader.start()
if self._thread is None or not self._thread.is_alive():
self._thread = threading.Thread(target=self.loop, name="batch", daemon=True)
self._thread.start()
self.store.event("INFO", "BATCH 已启动:按排序持续补队下载**歌曲表(keeps)**里的歌"
"(致命错误会自动停止)")
return {"ok": True}
def stop(self, reason: str = "手动停止", fatal: bool = False) -> dict:
with self._lock:
if not self.state["enabled"] and not fatal:
return {"ok": False, "error": "批量任务未在运行"}
self.state.update({"enabled": False, "fatal": fatal, "reason": reason,
"stopped_at": int(time.time())})
if not fatal:
self.state["next_retry_at"] = 0 # 人工停止 = 取消后续自动重试
self._stop.set()
if self.downloader is not None and fatal:
self.downloader.pause(by="batch")
self.store.event("ERROR" if fatal else "INFO",
f"BATCH {'致命错误停止' if fatal else '已停止'}:{reason}")
if fatal and bool(self.cfg.get("batch_fatal_auto_retry")):
self._schedule_retry(reason)
return {"ok": True, "fatal": fatal, "reason": reason, "retry": self.state.get("next_retry_at", 0)}
def _schedule_retry(self, reason: str) -> None:
"""致命停止后安排自动重试(上游限流/断网这类问题等一段时间会自愈)。"""
with self._lock:
retries = int(self.state["retries"]) + 1
max_retries = int(self.cfg.get("batch_fatal_max_retries") or 6)
if retries > max_retries:
self.store.event("ERROR", f"BATCH 自动重试已达上限({max_retries} 次),彻底停止:{reason}")
return
wait = int(self.cfg.get("batch_fatal_retry_minutes") or 60) * 60
try:
wait = int(wait * (float(self.cfg.get("batch_fatal_retry_backoff") or 2.0) ** (retries - 1)))
except Exception:
pass
self.state.update({"retries": retries, "next_retry_at": int(time.time()) + wait})
def _retry():
time.sleep(max(5, wait))
if not self.state.get("next_retry_at"):
return # 已被人工停止取消
if self.state.get("enabled"):
return # 已经有人在跑
if bool(self.cfg.get("batch_retry_reset_failed")):
n = self.store.retry_failed(int(self.cfg.get("max_attempts") or 3))
self.store.event("INFO", f"BATCH 重试前重置失败/卡住任务:{n} 首")
self.store.event("INFO", f"BATCH 自动重试第 {retries} 次(上次致命原因:{reason})")
self.start()
t = threading.Thread(target=_retry, name="batch-retry", daemon=True)
t.start()
with self._lock:
self.state["retry_timer"] = id(t)
self.store.event("WARN", f"BATCH 将在 {wait // 60} 分钟后自动重试(第 {retries} 次;原因:{reason})")
# ── 巡检 ──
def evaluate(self, stats: dict, low_space: bool, now: int | None = None) -> str:
"""返回致命原因;空串表示健康。纯函数,便于测试。"""
now = now or int(time.time())
max_fail = int(self.cfg.get("batch_max_consecutive_failures") or 20)
low_need = int(self.cfg.get("batch_low_space_checks") or 5)
stale_min = int(self.cfg.get("batch_stale_minutes") or 20)
streak = self.state["low_space_streak"] + (1 if low_space else 0)
if not low_space:
streak = 0
if streak >= low_need:
return f"存储空间不足持续 {streak} 次检查仍未缓解(本地/云盘低于下限)"
if self.state["consecutive_failures"] >= max_fail:
return f"连续失败 {self.state['consecutive_failures']} 首(≥{max_fail})"
if stats["pending"] > 0 and now - self.state["last_progress_at"] > stale_min * 60:
return f"超过 {stale_min} 分钟没有任何新完成(队列仍有 {stats['pending']} 首待下载)"
return ""
def loop(self) -> None:
while not self._stop.wait(self._check_interval()):
try:
if not self.tick():
return
except Exception as exc: # pragma: no cover
self.store.event("WARN", f"BATCH 巡检异常:{exc}")
def _check_interval(self) -> int:
return max(5, int(self.cfg.get("batch_check_interval_sec") or 30))
def tick(self) -> bool:
"""巡检一轮(补队 → 收工判定 → 定时报告);返回 False 表示本轮已停止(循环应退出)。
独立成方法是为了可单测:不必真的等 `batch_check_interval_sec`(最小 5 秒)。
"""
report = max(60, int(self.cfg.get("batch_report_interval_sec") or 600))
target = max(1, int(self.cfg.get("batch_target_pending") or 200))
stats = self.store.stats()
low_space = bool(self.guard and not self.guard.state.get("ok", True))
now = int(time.time())
with self._lock:
self.state["checks"] += 1
self.state["last_check"] = now
if stats["done"] > self.state["last_done"]:
self.state["last_done"] = stats["done"]
self.state["last_progress_at"] = now
self.state["consecutive_failures"] = 0
self.state["low_space_streak"] = (self.state["low_space_streak"] + 1) if low_space else 0
self.state["consecutive_failures"] = stats["failed"]
reason = self.evaluate(stats, low_space, now)
if reason:
self.stop(reason, fatal=True)
return False
# 持续补队:**只补歌曲表(keeps)里的**(不在表里的一律不下)
if stats["pending"] < target:
need = target - stats["pending"]
order = str(self.cfg.get("download_order") or "hot")
desc = bool(self.cfg.get("download_order_desc", True))
cands = self._keep_candidates(need, order, desc)
if cands:
res = self.store.enqueue([int(c["tid"]) for c in cands])
with self._lock:
self.state["enqueued_total"] += res["added"]
# 歌曲表里的全部下完(队列空 + 歌曲表没有未完成条目)→ 正常收工,不是致命错误
if stats["pending"] == 0 and self._keep_remaining() == 0:
self.store.event("INFO", "BATCH 已按歌曲表下载完毕")
self.stop("已按歌曲表下载完毕", fatal=False)
return False
# 定时报告
if now - self.state["last_report"] >= report:
with self._lock:
self.state["last_report"] = now
self.report(stats)
return True
def report(self, stats: dict) -> None:
lib = max(1, stats["songs"])
done = stats["done"]
elapsed = max(1, int(time.time()) - self.state["started_at"]) if self.state["started_at"] else 1
gained = max(0, done - self.state["baseline_done"])
rate = gained / elapsed * 3600.0 # 首/小时
remain = max(0, lib - done)
eta_h = (remain / rate) if rate > 0 else -1
g = self.guard.state if self.guard else {}
ks = self._keeps()
self.store.event("INFO",
f"BATCH 进度 {done}/{lib}({done * 100.0 / lib:.2f}%)"
f",本轮 {gained} 首、{rate:.0f} 首/小时"
+ (f"、预计剩余 {eta_h / 24:.1f} 天" if eta_h >= 0 else "")
+ f";队列 {stats['pending']} 待下载、失败 {stats['failed']}"
+ f";歌曲表 待下 {ks.get('want', 0)}"
f"(其中 {self._keep_remaining() or 0} 未完成)"
f"、已下 {ks.get('done', 0)}、失败 {ks.get('failed', 0)}"
+ (f";本地剩余 {g['local_free'] / 1e9:.1f}GB、云盘剩余 {g['media_free'] / 1e9:.1f}GB"
if g else ""))