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

653 lines
31 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.
"""歌曲表**自动维护**:把"补队下载 / 扫描登记 / 失败重试 / 设备身份时效 / 心跳"合并成**一个**后台任务。
## 为什么合并(2026 新语义)
改之前是**三个独立守护线程**(`BatchManager` / `media_scan_scheduler` / `FailedRetry`)再加上
`WhoamiReporter`,各自定时、各自写日志 —— 谁在跑、跑到哪一步、为什么没动静,界面上说不清
(线上出现过"下载 worker 不动作",但三处都有各自的定时器,排查时互相干扰)。
现在只有**一条时间线**,每 `maintain_interval_sec`(默认 600 = 10 分钟)跑一轮:
a. 身份时效检查 → b. 扫描媒体目录(登记新文件 + done 但文件缺失/过小 → 回队)→ c. 失败重试
→ d. 按歌曲表补队下载 → e. 等队列排空(drain)→ f. 记结果 + 算 `next_run_at`
## 部署机硬性要求(本文件里处处体现)
媒体目录在**云盘 FUSE** 上,`stat`/`listdir` 可能长时间阻塞,历史上多次因此出现
"进程活着但不监听端口"、"下载 worker 不动作"。因此:
* 访问媒体目录一律走 `store.keep_adopt_media()`(内部是**带超时的守护线程**),
超时/异常只记 `scan_error`,**不影响本轮后续步骤**;
* 网络动作(whoami)只在**后台线程**里做,且只在"本轮确实要下载"时启停;
* 本任务自身是**守护线程**,而且必须在 `main.py` 里**两个 HTTP 端口监听之后**才 `start()`;
* `run_now()` 只负责"起线程",**立刻返回**,绝不在 HTTP 请求线程里同步跑长任务。
## 状态机(`state`)
`stopped`(未运行)/ `idle`(运行中,在等下一轮)/ `checking_identity` / `scanning` /
`retrying` / `downloading` / `error`(本轮出现未预期异常)。
## 心跳(whoami)与身份时效
* 心跳**不再独立定时**:由本任务在 `downloading` 之前 `start()`,drain 结束在 `finally` 里
`stop()`(成功/失败/超时/异常都停);本轮没有要下载的歌 → **一次都不启动**;
`whoami_enabled=False` 时任何情况下都不启动;一次长下载期间仍按 `next_interval` 周期上报
(`WhoamiReporter.loop()` 自带);
* 身份:`device-identity.json` 的 `created_at` 距今 > `device_identity_ttl_hours`(默认 24)
→ 自动 `regenerate()` 并写事件;换身份走 `identity.regenerate_identity()`(**一处**完成
`apply_to_cfg` + 清 token),保证 `i.php` / `do.php` / whoami 三处立刻同源。
"""
from __future__ import annotations
import logging
import threading
import time
from . import identity as identity_mod
log = logging.getLogger("ktv.maintain")
# ── 状态机取值(界面按它显示中文,见 `STATE_TEXT`)──
STATE_STOPPED = "stopped"
STATE_IDLE = "idle"
STATE_IDENTITY = "checking_identity"
STATE_SCANNING = "scanning"
STATE_RETRYING = "retrying"
STATE_DOWNLOADING = "downloading"
STATE_ERROR = "error"
# 状态码 → 界面中文("运行中 · 正在下载"这种口径;后端给一份,界面直接用,避免两处各写一套)
STATE_TEXT = {
STATE_STOPPED: "未运行",
STATE_IDLE: "运行中 · 空闲(等待下一轮)",
STATE_IDENTITY: "运行中 · 检查设备身份",
STATE_SCANNING: "运行中 · 扫描媒体目录",
STATE_RETRYING: "运行中 · 重试失败任务",
STATE_DOWNLOADING: "运行中 · 正在下载",
STATE_ERROR: "出错(见错误信息)",
}
DEFAULT_INTERVAL = 600 # 默认每十分钟一轮
MIN_INTERVAL = 30 # 间隔下限(防止有人填 0/1 把轮询打疯)
DRAIN_POLL_SEC = 1.0 # drain 的轮询粒度(秒);停止/超时都能及时退出
IDLE_POLL_SEC = 30 # 已停止/无排程时守护线程的睡眠粒度
class MaintainTask:
"""歌曲表自动维护的**唯一**后台任务(可单测:注入 clock / scan / retry / drain)。
参数里的 `scan_fn` / `retry_fn` / `drain_fn` / `now_fn` / `sleep_fn` 只为**单测**注入
(虚拟时钟、断言调用顺序、制造异常);生产路径全部走默认实现。
"""
def __init__(self, cfg, store, downloader=None, guard=None, whoami=None, identity=None,
scan_fn=None, retry_fn=None, drain_fn=None, now_fn=None):
self.cfg = cfg
self.store = store
self.downloader = downloader
self.guard = guard
self.whoami = whoami
self.identity = identity
self._scan_fn = scan_fn if callable(scan_fn) else self._default_scan
self._retry_fn = retry_fn if callable(retry_fn) else self._default_retry
self._drain_fn = drain_fn if callable(drain_fn) else self._default_drain
self._now = now_fn if callable(now_fn) else time.time
self._lock = threading.RLock()
self._stop = threading.Event() # 守护线程生命周期(start 清、stop 置)
self._abort = threading.Event() # "中止当前这一轮"(stop 置;守护线程每轮开头清)
self._cycle_lock = threading.Lock() # 防重入:同一时刻只允许一轮
self._thread: threading.Thread | None = None
self._bg_threads: list[threading.Thread] = [] # run_now() 起的临时线程(供 join())
self.state: dict = {
"enabled": False, "state": STATE_STOPPED, "fatal": False, "reason": "",
"started_at": 0, "stopped_at": 0, "last_run_at": 0, "next_run_at": 0,
"last_result": {}, "cycle_count": 0, "error": "", "running": False,
"baseline_done": 0, "last_heartbeat": False,
}
# ────────────── 只读状态 ──────────────
def _stats(self) -> dict:
"""队列统计;store 没有该方法/抛异常时返回空字典(绝不让状态查询崩)。"""
fn = getattr(self.store, "stats", None)
try:
return dict(fn() or {}) if callable(fn) else {}
except Exception:
return {}
def _keeps(self) -> dict:
fn = getattr(self.store, "keep_stats", None)
try:
return dict(fn() or {}) if callable(fn) else {}
except Exception:
return {}
def _keep_remaining(self) -> int:
fn = getattr(self.store, "keep_remaining", None)
if not callable(fn):
return 0
try:
return int(fn() or 0)
except Exception:
return 0
def _pending(self) -> int:
return max(0, int(self._stats().get("pending") or 0))
def _active(self) -> int:
"""下载池里**正在下载**的任务数(downloader 缺失/异常时按 0 算)。"""
d = self.downloader
if d is None:
return 0
fn = getattr(d, "status", None)
if callable(fn):
try:
return max(0, int((fn() or {}).get("active_count") or 0))
except Exception:
pass
try:
return max(0, len(getattr(d, "_active", {}) or {}))
except Exception:
return 0
def _heartbeat_running(self) -> bool:
if self.whoami is None:
return False
fn = getattr(self.whoami, "status", None)
if not callable(fn):
return False
try:
return bool((fn() or {}).get("running"))
except Exception:
return False
def _identity_dict(self) -> dict:
"""当前生效的设备身份(优先 `DeviceIdentity.identity`,否则配置里的运行时值)。"""
obj = getattr(self.identity, "identity", None)
if isinstance(obj, dict) and obj:
return obj
try:
val = self.cfg.get("device_identity")
except Exception:
return {}
return val if isinstance(val, dict) else {}
def _ttl_hours(self) -> float:
try:
raw = self.cfg.get("device_identity_ttl_hours")
except Exception:
return 24.0
if raw is None or raw == "":
return 24.0
try:
return max(0.0, float(raw))
except Exception:
return 24.0
def _interval(self) -> int:
"""本轮结束到下一轮的间隔(秒);低于下限按 `MIN_INTERVAL` 处理(防忙轮询)。"""
try:
v = int(self.cfg.get("maintain_interval_sec") or DEFAULT_INTERVAL)
except Exception:
v = DEFAULT_INTERVAL
return max(MIN_INTERVAL, v)
def _drain_timeout(self) -> float:
try:
return max(0.0, float(self.cfg.get("maintain_drain_timeout_sec") or 3600))
except Exception:
return 3600.0
def _heartbeat_enabled(self) -> bool:
"""是否允许在本轮启停心跳:维护开关 + whoami 总开关 + 装配了 reporter。"""
if self.whoami is None:
return False
if not bool(self.cfg.get("maintain_heartbeat", True)):
return False
return bool(self.cfg.get("whoami_enabled", True))
def status(self) -> dict:
"""完整状态(界面 4 秒轮询直接渲染;键一定齐全)。"""
with self._lock:
st = dict(self.state)
last = dict(st.get("last_result") or {})
stats = self._stats()
ks = self._keeps()
ident = self._identity_dict()
ttl = self._ttl_hours()
created = 0
try:
created = int((ident or {}).get("created_at") or 0)
except Exception:
created = 0
age = max(0, int(self._now()) - created) if created > 0 else 0
ttl_sec = int(ttl * 3600)
expires_in = max(0, ttl_sec - age) if (created > 0 and ttl > 0) else 0
state = str(st.get("state") or STATE_STOPPED)
st.update({
"ok": True,
"enabled": bool(st.get("enabled")),
"state": state,
"state_text": STATE_TEXT.get(state, state),
"interval_sec": self._interval(),
"running": bool(self._thread is not None and self._thread.is_alive()),
"last_run_at": int(st.get("last_run_at") or 0),
"next_run_at": int(st.get("next_run_at") or 0),
"last_result": last,
"cycle_count": int(st.get("cycle_count") or 0),
"checks": int(st.get("cycle_count") or 0), # 兼容旧 /api/batch/status 的字段名
"error": str(st.get("error") or ""),
"heartbeat_running": self._heartbeat_running(),
"heartbeat_enabled": self._heartbeat_enabled(),
"identity_age_sec": age,
"identity_expires_in_sec": expires_in,
"identity_ttl_hours": ttl,
"identity_expired": bool(created > 0 and ttl > 0 and age > ttl_sec),
"identity_mac": str((ident or {}).get("mac") or ""),
"done": int(stats.get("done") or 0),
"pending": int(stats.get("pending") or 0),
"failed": int(stats.get("failed") or 0),
"library": int(stats.get("songs") or 0),
"enqueued": int(stats.get("enqueued") or 0),
"baseline_done": int(st.get("baseline_done") or 0),
"keeps_total": int(ks.get("total") or 0),
"keeps_want": int(ks.get("want") or 0),
"keeps_done": int(ks.get("done") or 0),
"keeps_failed": int(ks.get("failed") or 0),
"keeps_bytes": int(ks.get("bytes") or 0),
"keeps_remaining": self._keep_remaining(),
})
return st
# ────────────── 启停 ──────────────
def start(self) -> dict:
"""启动(**立即返回**):第一轮马上跑,之后每 `interval` 秒一轮。"""
now = int(self._now())
with self._lock:
if self.state.get("enabled") and self._thread is not None and self._thread.is_alive():
return {"ok": False, "error": "歌曲表自动维护已在运行"}
self.state.update({
"enabled": True, "state": STATE_IDLE, "fatal": False, "reason": "",
"started_at": now, "stopped_at": 0, "error": "",
"next_run_at": now, "baseline_done": int(self._stats().get("done") or 0),
"cycle_count": 0,
})
# 先让可能还活着的老循环退出,再清停止位 —— 顺序反了会出现"两个循环线程"。
self._stop.set()
old = self._thread
if old is not None and old.is_alive():
old.join(timeout=1.0)
self._abort.clear()
self._stop.clear()
self._thread = threading.Thread(target=self.loop, name="maintain", daemon=True)
self._thread.start()
self._note("INFO", f"歌曲表自动维护已启动:每 {self._interval()} 秒一轮"
f"(扫描={bool(self.cfg.get('maintain_scan_media', True))}、"
f"重试={bool(self.cfg.get('maintain_retry_failed', True))}、"
f"心跳={self._heartbeat_enabled()})")
return {"ok": True, "enabled": True, "next_run_at": now, "state": STATE_IDLE}
def stop(self, reason: str = "手动停止") -> dict:
"""停止:置停止位 + **立刻停心跳**;drain 中调用也会尽快退出(状态 `stopped`)。"""
with self._lock:
self.state.update({
"enabled": False, "state": STATE_STOPPED, "next_run_at": 0,
"stopped_at": int(self._now()), "reason": str(reason or "手动停止"),
"running": False,
})
self._stop.set()
self._abort.set()
self._stop_heartbeat()
self._note("INFO", f"歌曲表自动维护已停止:{reason}")
return {"ok": True, "enabled": False, "state": STATE_STOPPED, "reason": str(reason or "手动停止")}
def restart(self, reason: str = "重启") -> dict:
"""重启 = 停止后**立即开始新的一轮**(不等 interval)。"""
self.stop(reason=f"重启:{reason}")
res = self.start()
with self._lock:
self.state["next_run_at"] = int(self._now()) # 立即,不等 10 分钟
res.update({"restart": True, "state": STATE_IDLE,
"next_run_at": int(self.state.get("next_run_at") or 0)})
return res
def run_now(self) -> dict:
"""**立即跑一轮,不影响排程**:起后台线程后立刻返回(绝不在请求线程里跑长任务)。"""
t = threading.Thread(target=self._run_now_bg, name="maintain-run-now", daemon=True)
with self._lock:
self._bg_threads = [x for x in self._bg_threads if x.is_alive()]
self._bg_threads.append(t)
t.start()
with self._lock:
return {"ok": True, "started": True, "state": str(self.state.get("state") or STATE_STOPPED),
"next_run_at": int(self.state.get("next_run_at") or 0)}
def _run_now_bg(self) -> None:
try:
self.run_cycle(update_schedule=False, reset_abort=True)
except Exception as exc: # pragma: no cover - 兜底
self._note("ERROR", f"维护(立即一次)异常:{exc}")
finally:
with self._lock:
self._bg_threads = [x for x in self._bg_threads
if x.is_alive() and x is not threading.current_thread()]
def join(self, timeout: float = 3.0) -> None:
"""等**所有**维护线程(调度循环 + 正在跑的 `run_now`)结束。
生产上不必须(daemon 线程随进程退出);但"关数据库/落盘"之前调用它,
可以避免"子线程还在写事件时父线程关连接"这类竞争(sqlite 同连接跨线程会段错误)。
"""
end = time.time() + max(0.0, float(timeout))
with self._lock:
threads = [self._thread] + list(self._bg_threads)
for th in threads:
if th is None or not th.is_alive():
continue
remain = end - time.time()
if remain <= 0:
return
th.join(timeout=remain)
remain = end - time.time()
if remain > 0 and self._cycle_lock.acquire(timeout=remain):
self._cycle_lock.release()
# ────────────── 调度循环 ──────────────
def loop(self) -> None:
while not self._stop.is_set():
with self._lock:
nxt = int(self.state.get("next_run_at") or 0)
wait = nxt - int(self._now())
if wait > 0:
if self._stop.wait(min(wait, IDLE_POLL_SEC)):
return
continue
try:
self.run_cycle()
except Exception as exc: # pragma: no cover - run_cycle 自带兜底
self._note("ERROR", f"维护循环异常:{exc}")
if self._stop.wait(IDLE_POLL_SEC):
return
# ────────────── 一轮 ──────────────
def run_cycle(self, now=None, update_schedule: bool = True, reset_abort: bool = False) -> dict:
"""跑一轮(顺序固定,见模块顶部);返回本轮结果字典(可直接放进 `last_result`)。
* `now`:虚拟时钟入口(单测不真等 10 分钟);不传就取 `now_fn()`;
* `update_schedule=False`:`run_now()` 用 —— 跑完**不改** `next_run_at`(不影响排程);
* `reset_abort=True`:`run_now()` 在任务已停止时也要完整跑一轮,故先清中止位。
"""
if reset_abort:
self._abort.clear()
started = int(now if now is not None else self._now())
if not self._cycle_lock.acquire(blocking=False):
return {"ok": False, "error": "上一轮维护还在进行中",
"state": str(self.state.get("state") or STATE_STOPPED)}
result: dict = {
"started_at": started, "ended_at": started, "elapsed_ms": 0,
"identity_regenerated": False, "identity_age_sec": 0, "identity_expires_in_sec": 0,
"identity_mac": "", "scanned": 0, "adopted": 0, "updated": 0, "skipped": 0,
"requeued": 0,
"retried": 0, "enqueued": 0, "done": 0, "failed": 0,
"pending_left": 0, "active_left": 0, "heartbeat": False, "drain_timeout": False,
"scan_error": "", "retry_error": "", "error": "",
}
heartbeat_on = False
heartbeat_attempted = False
done_before = 0
try:
with self._lock:
self.state.update({"running": True, "error": ""})
stats0 = self._stats()
done_before = int(stats0.get("done") or 0)
# a. 身份时效检查(超时/异常不影响后续步骤)
self._set_state(STATE_IDENTITY)
ident = self._check_identity(started)
result.update({
"identity_regenerated": bool(ident.get("regenerated")),
"identity_age_sec": int(ident.get("age_sec") or 0),
"identity_expires_in_sec": int(ident.get("expires_in_sec") or 0),
"identity_mac": str(ident.get("mac") or ""),
"identity_error": str(ident.get("error") or ""),
})
# b. 扫描媒体目录(登记新文件 + "done 但文件缺失/过小"回队;自带超时守护线程,失败只记日志)
if bool(self.cfg.get("maintain_scan_media", True)):
self._set_state(STATE_SCANNING)
try:
res = self._scan_fn() or {}
result["scanned"] = int(res.get("scanned") or 0)
result["adopted"] = int(res.get("adopted_count",
len(res.get("adopted") or [])) or 0)
result["updated"] = int(res.get("updated") or 0)
result["skipped"] = int(res.get("skipped") or 0)
result["requeued"] = int(res.get("requeued") or 0)
if res.get("ok") is False:
result["scan_error"] = str(res.get("error") or "扫描失败")
except Exception as exc:
result["scan_error"] = str(exc)
self._note("WARN", f"维护:扫描媒体目录失败(只记日志,不影响本轮后续):{exc}")
# c. 失败重试(failed/missing → 待下载)
if bool(self.cfg.get("maintain_retry_failed", True)):
self._set_state(STATE_RETRYING)
try:
result["retried"] = int(self._retry_fn() or 0)
except Exception as exc:
result["retry_error"] = str(exc)
self._note("WARN", f"维护:失败重试失败(只记日志,不影响本轮后续):{exc}")
# d. 按歌曲表补队(沿用 keep_next_batch + enqueue,绝不自己实现一套)
result["enqueued"] = int(self._top_up() or 0)
# e. **确实有歌要下**才起心跳 + 等排空;没有要下载的歌 → 一次都不启动心跳
if self._pending() > 0 or self._active() > 0:
self._ensure_pool()
if self._heartbeat_enabled():
heartbeat_attempted = True
try:
self.whoami.start() # 进入 downloading **之前**
heartbeat_on = True
result["heartbeat"] = True
except Exception as exc:
self._note("WARN", f"维护:whoami 心跳启动失败(不影响下载):{exc}")
self._set_state(STATE_DOWNLOADING)
try:
drained = self._drain_fn(self._drain_timeout()) or {}
result["drain_timeout"] = bool(drained.get("drain_timeout"))
result["pending_left"] = int(drained.get("pending_left") or 0)
result["active_left"] = int(drained.get("active_left") or 0)
except Exception as exc:
result["drain_error"] = str(exc)
self._note("WARN", f"维护:等待下载排空异常(已停心跳,进入下一轮):{exc}")
except Exception as exc:
result["error"] = f"{type(exc).__name__}: {exc}"
with self._lock:
self.state["error"] = result["error"]
self._note("ERROR", f"维护:本轮异常:{exc}")
finally:
try:
# ⚠️ 无论成功、失败、超时还是异常,**心跳必须停**(只有起过的轮次才需要停)
if heartbeat_on or heartbeat_attempted:
self._stop_heartbeat()
stats1 = self._stats()
result["done"] = max(0, int(stats1.get("done") or 0) - done_before)
result["failed"] = int(stats1.get("failed") or 0)
result["pending_left"] = self._pending()
result["active_left"] = self._active()
ended = int(self._now())
result["ended_at"] = ended
result["elapsed_ms"] = max(0, (ended - started) * 1000)
result["ok"] = not result.get("error")
with self._lock:
self.state["cycle_count"] = int(self.state.get("cycle_count") or 0) + 1
self.state["last_run_at"] = started
self.state["last_result"] = dict(result)
self.state["last_heartbeat"] = bool(result.get("heartbeat"))
self.state["running"] = False
self.state["state"] = (STATE_ERROR if result.get("error")
else (STATE_IDLE if self.state.get("enabled")
else STATE_STOPPED))
if update_schedule:
# next_run_at = **本轮结束** + interval(间隔 0 视为"不再自动排下一轮"→ 0)
interval = self._interval()
self.state["next_run_at"] = (ended + interval) if self.state.get("enabled") else 0
result["state"] = self.state["state"]
result["next_run_at"] = int(self.state.get("next_run_at") or 0)
finally:
self._cycle_lock.release()
self._note("INFO", self._summary(result))
return result
# ────────────── 各步骤实现 ──────────────
def _set_state(self, state: str) -> None:
"""切换阶段状态;**已停止/已中止时不再改**。
否则 `stop()` 之后仍在收尾的那一轮(例如刚 `run_now()` 过)会把状态从 `stopped`
改回 `scanning`/`retrying`,界面上看起来"点了停止却还在跑"。
"""
if self._abort.is_set():
return
with self._lock:
self.state["state"] = state
def _check_identity(self, now: int) -> dict:
"""身份时效:超期就自动重新生成(并写事件);未装配身份管理时直接跳过。"""
out = {"checked": False, "regenerated": False, "age_sec": 0, "expires_in_sec": 0,
"mac": "", "error": ""}
if self.identity is None or not callable(getattr(self.identity, "regenerate", None)):
return out
ident = self._identity_dict()
if not isinstance(ident, dict) or not ident:
return out
out["checked"] = True
try:
created = int(ident.get("created_at") or 0)
except Exception:
created = 0
ttl = self._ttl_hours()
ttl_sec = int(ttl * 3600)
age = max(0, int(now) - created) if created > 0 else 0
out.update({"age_sec": age, "mac": str(ident.get("mac") or ""),
"expires_in_sec": max(0, ttl_sec - age) if (created > 0 and ttl > 0) else 0})
if created <= 0 or ttl <= 0 or age <= ttl_sec:
return out
old_mac = str(ident.get("mac") or "")
try:
# 一处完成"生成 + 落盘 + apply_to_cfg + 清 token"(i.php/do.php/whoami 同时换新身份)
new = identity_mod.regenerate_identity(self.cfg, self.identity, self.downloader)
except Exception as exc:
out["error"] = f"{type(exc).__name__}: {exc}"
self._note("WARN", f"维护:设备身份已过期但自动更换失败(本轮继续):{exc}")
return out
out.update({"regenerated": True, "age_sec": 0, "expires_in_sec": ttl_sec,
"mac": str(new.get("mac") or ""), "old_mac": old_mac})
self._note("WARN", f"设备身份已过期({age / 3600.0:.1f}h),自动更换:"
f"新 mac={new.get('mac')} sn={new.get('sn')}")
return out
def _default_scan(self) -> dict:
from . import admin # 延迟导入,避免模块级循环依赖
return admin.run_media_scan(self.cfg, self.store)
def _max_attempts(self) -> int:
fn = getattr(self.cfg, "retry_failed_max_attempts", None)
if callable(fn):
try:
return int(fn())
except Exception:
pass
try:
return max(1, int(self.cfg.get("max_attempts") or 3))
except Exception:
return 3
def _default_retry(self) -> int:
return int(self.store.retry_failed(self._max_attempts()) or 0)
def _top_up(self) -> int:
"""按歌曲表补队:只补 `keeps` 里未完成的歌(沿用 store.keep_next_batch)。"""
target = max(1, int(self.cfg.get("batch_target_pending") or 200))
pending = self._pending()
if pending >= target:
return 0
fn = getattr(self.store, "keep_next_batch", None)
if not callable(fn):
return 0
order = str(self.cfg.get("download_order") or "hot")
desc = bool(self.cfg.get("download_order_desc", True))
try:
cands = fn(target - pending, order, desc)
except Exception as exc:
self._note("WARN", f"维护:补队候选查询失败(不影响本轮):{exc}")
return 0
if not cands:
return 0
res = self.store.enqueue([int(c["tid"]) for c in cands])
return int((res or {}).get("added") or 0)
def _ensure_pool(self) -> None:
"""确保下载池在跑(空间原因暂停过就先恢复);失败只记日志。"""
d = self.downloader
if d is None:
return
try:
if getattr(d, "paused_by_space", False):
d.resume(by="space")
except Exception as exc:
self._note("WARN", f"维护:恢复下载池失败:{exc}")
try:
if not getattr(d, "_threads", None):
d.start()
except Exception as exc:
self._note("WARN", f"维护:启动下载池失败:{exc}")
def _default_drain(self, timeout_sec: float) -> dict:
"""等队列排空:`pending == 0 且 active == 0`,或超时;`stop()` 也会立刻退出。"""
end = self._now() + max(0.0, float(timeout_sec))
timed_out = False
while not self._abort.is_set():
if self._pending() <= 0 and self._active() <= 0:
break
remain = end - self._now()
if remain <= 0:
timed_out = True
break
if self._abort.wait(min(DRAIN_POLL_SEC, max(0.05, remain))):
break
return {"drain_timeout": timed_out, "pending_left": self._pending(),
"active_left": self._active()}
def _stop_heartbeat(self) -> None:
if self.whoami is None:
return
try:
self.whoami.stop()
except Exception: # pragma: no cover - 兜底
pass
def _summary(self, result: dict) -> str:
"""一轮结束写一条 INFO 事件(界面「上一轮结果」与日志页都看它)。"""
parts = [f"扫描登记 {result.get('adopted', 0)}",
f"缺失回队 {result.get('requeued', 0)}",
f"重试 {result.get('retried', 0)}",
f"入队 {result.get('enqueued', 0)}",
f"完成 {result.get('done', 0)}",
f"失败 {result.get('failed', 0)}"]
if result.get("scan_error"):
parts.append(f"扫描失败({result['scan_error'][:60]})")
if result.get("retry_error"):
parts.append(f"重试失败({result['retry_error'][:60]})")
if result.get("drain_timeout"):
parts.append("本轮等待排空超时,下一轮继续")
extra = f",下一轮 {self.state.get('next_run_at')}" if self.state.get("next_run_at") else ""
return (f"维护一轮完成(第 {self.state.get('cycle_count', 0)} 轮):"
+ "、".join(parts) + extra)
def _note(self, level: str, text: str) -> None:
"""写日志 + 事件;**任何异常都吞掉**(日志/事件绝不能影响维护主流程)。"""
try:
log.info("%s", text)
except Exception: # pragma: no cover - 兜底
pass
if self.store is None:
return
try:
self.store.event(level, text)
except Exception: # pragma: no cover - 兜底
pass