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

252 lines
13 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.
"""歌曲表(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 ""))