版本号:
- 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 例)
252 lines
13 KiB
Python
252 lines
13 KiB
Python
"""歌曲表(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 ""))
|