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

678 lines
33 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.
"""管理端口:管理界面用到的 JSON API(与服务端口完全分离)。
包含:概览 / 曲库清单查询导出(只读参考)/ 歌曲表(keeps:查询、增删、导入导出、媒体扫描)/
曲库同步(后台跑、可看进度)/ 下载队列控制 / 入队(显式传编号)/ 失败重试 /
设置读写 / 日志 / CSV 导出。
路径约定:
* `/api/songs`、`/api/export.csv` 是**曲库清单**(只读)的查询/导出,同时提供
`/api/library/songs`、`/api/library/export.csv` 两个语义更清晰的别名;
* `/api/keeps*` 是**歌曲表**(用户指定维护的名单,服务器只按它下载/删文件),
列表字段实时 LEFT JOIN 曲库补全;`/api/keeps/unmatched` 列出曲库暂时匹配不到的编号。
"""
from __future__ import annotations
import threading
import time
from typing import Any
from . import identity, musesync, web
SYNC_STATE = {"running": False, "text": "空闲", "pct": 0, "started_at": 0, "finished_at": 0,
"ok": None, "error": "", "result": None, "task_id": 0}
_sync_lock = threading.Lock()
_sync_cancel = threading.Event()
# /api/stats 里"跟数据库无关但可能很慢"的两项(媒体目录 listdir、磁盘用量)的 TTL 缓存:
# 媒体目录在云盘/FUSE 上时 listdir 可能几秒,管理界面每几秒轮询一次没必要次次真查。
_stats_side_cache: dict[str, tuple[float, Any]] = {}
# 媒体目录扫描(登记新文件 + done 但文件缺失/过小 → 回队)的**最近一次结果**(给界面/接口看)。
# 真正的列举在 Store.keep_adopt_media() 的带超时守护线程里做(FUSE 可能长时间阻塞),
# 这里只存"时间 / 新增 / 更新 / 跳过 / 回队 / 耗时 / 错误 / 样例"。
MEDIA_SCAN_STATE: dict[str, Any] = {
"running": False, "last_at": 0, "ok": None, "error": "",
"scanned": 0, "adopted": 0, "adopted_sample": [], "updated": 0, "skipped": 0,
"requeued": 0, "freed": 0, "elapsed_ms": 0,
}
_media_scan_lock = threading.Lock()
def _cfg_int(cfg, method: str, key: str, default: int) -> int:
"""优先用 Config 上的取值助手(如 media_scan_min_bytes()),否则退回原始配置键。"""
fn = getattr(cfg, method, None)
if callable(fn):
try:
return int(fn())
except Exception:
pass
try:
return int(cfg.get(key) or default)
except Exception:
return default
def media_scan_status(cfg) -> dict:
"""媒体扫描的完整状态:最近一次结果 + 当前开关/间隔(键一定齐全,界面直接渲染)。"""
with _media_scan_lock:
st = dict(MEDIA_SCAN_STATE)
st["enabled"] = bool(cfg.get("media_scan_enabled", True))
st["interval_sec"] = int(cfg.get("media_scan_interval_sec") or 0)
st["min_bytes"] = _cfg_int(cfg, "media_scan_min_bytes", "min_file_bytes", 0)
try:
st["timeout_sec"] = float(cfg.get("media_scan_timeout_sec") or 30)
except Exception:
st["timeout_sec"] = 30.0
return st
def run_media_scan(cfg, store) -> dict:
"""**扫描媒体目录一次**:把 `<编号>.ts` 登记/更新进歌曲表(**绝不删除任何文件**)。
⚠️ 列举动作在 `store.keep_adopt_media()` 的**带超时守护线程**里做(媒体目录在云盘/FUSE 上
时 `listdir`/`stat` 可能长时间阻塞,历史上导致过"进程活着但不监听端口");超时/异常时
该方法返回 `ok=false` 且**不改变任何状态**。本函数只额外记录"最近一次扫描结果"给界面看,
并发触发时直接拒绝(不让两个扫描同时读同一个目录)。
"""
with _media_scan_lock:
if MEDIA_SCAN_STATE.get("running"):
return {"ok": False, "error": "上一次媒体扫描还在进行中", "running": True,
"scan": media_scan_status(cfg)}
MEDIA_SCAN_STATE.update({"running": True})
try:
res = store.keep_adopt_media(cfg.media_dir, _cfg_int(cfg, "media_scan_min_bytes", "min_file_bytes", 0),
cfg.get("media_scan_timeout_sec") or 30)
with _media_scan_lock:
MEDIA_SCAN_STATE.update({
"running": False, "last_at": int(time.time()), "ok": bool(res.get("ok")),
"error": str(res.get("error") or ""),
"scanned": int(res.get("scanned") or 0),
"adopted": int(res.get("adopted_count", len(res.get("adopted") or []))),
"adopted_sample": list(res.get("adopted") or [])[:10],
"updated": int(res.get("updated") or 0),
"skipped": int(res.get("skipped") or 0),
"requeued": int(res.get("requeued") or 0), # done 但文件缺失/过小 → 回队
"freed": int(res.get("freed") or 0),
"elapsed_ms": int(res.get("elapsed_ms") or 0),
})
return res
except Exception as exc: # pragma: no cover - 兜底
with _media_scan_lock:
MEDIA_SCAN_STATE.update({"running": False, "last_at": int(time.time()),
"ok": False, "error": str(exc)})
try:
store.event("WARN", f"媒体扫描异常(不影响主流程):{exc}")
except Exception:
pass
return {"ok": False, "error": str(exc)}
def auto_retry_status(cfg, downloader) -> dict:
"""失败任务自动重试的状态(调度器在时用它的实时状态,否则按配置给一份空状态)。"""
r = getattr(downloader, "retry", None) if downloader is not None else None
if r is not None and callable(getattr(r, "status", None)):
try:
return r.status()
except Exception: # pragma: no cover - 兜底
pass
return {"enabled": bool(cfg.get("retry_failed_enabled", True)),
"interval_sec": int(cfg.get("retry_failed_interval_sec") or 0),
"last_at": 0, "last_count": 0, "total_count": 0, "skipped_reason": "",
"running": False,
"max_attempts": _cfg_int(cfg, "retry_failed_max_attempts", "max_attempts", 3)}
def _side_cached(key: str, ttl: float, producer):
ttl = max(0.0, float(ttl or 0))
if ttl <= 0:
return producer()
stamp = time.monotonic()
hit = _stats_side_cache.get(key)
if hit and (stamp - hit[0]) < ttl:
return hit[1]
value = producer()
_stats_side_cache[key] = (stamp, value)
return value
def _sync_progress(text: str, pct: int) -> None:
with _sync_lock:
SYNC_STATE.update({"text": text, "pct": int(pct)})
def run_sync(cfg, store) -> None:
with _sync_lock:
if SYNC_STATE["running"]:
return
_sync_cancel.clear()
SYNC_STATE.update({"running": True, "text": "启动", "pct": 0, "ok": None, "error": "",
"started_at": int(time.time()), "finished_at": 0, "result": None, "task_id": 0})
try:
result = musesync.sync_library(cfg, store, _sync_progress, cancel=_sync_cancel)
with _sync_lock:
cancelled = bool(result.get("cancelled"))
SYNC_STATE.update({"running": False, "text": "已取消" if cancelled else "完成",
"pct": 100 if not cancelled else SYNC_STATE["pct"],
"ok": not cancelled, "result": result, "cancelled": cancelled,
"task_id": result.get("task_id", 0), "finished_at": int(time.time())})
store.event("INFO", f"曲库同步结束:{result}")
except Exception as exc:
with _sync_lock:
SYNC_STATE.update({"running": False, "text": "失败", "ok": False, "error": str(exc),
"finished_at": int(time.time())})
store.event("ERROR", f"曲库同步失败:{exc}")
def build_router(cfg, store, downloader, batch=None, whoami=None, device_identity=None,
maintain=None) -> web.Router:
"""管理 API。
`maintain` = `ktv/maintain.py` 的**歌曲表自动维护**任务(新路径);`batch` 是旧的批量驱动器
(仅作兜底/向后兼容)。`/api/batch/*` 现在指向维护任务,老脚本不用改。
"""
r = web.Router()
def _maintain():
"""统一取"当前的后台维护器":优先 maintain,其次旧的 batch(都没有 = None)。"""
return maintain if maintain is not None else batch
def need_downloader():
"""下载器未启用时的统一响应(避免 None 调用崩溃)。"""
if downloader is None:
return web.json_response({"ok": False, "error": "downloader 未启用"}, 503)
return None
# ── 概览 ──
@r.get("/api/stats")
def stats(req: web.Request):
"""概览(管理界面每几秒轮询一次 → **必须快**,目标 < 300ms)。
慢项两类,分别用 TTL 缓存挡住(配置项 `stats_cache_sec`,默认 4 秒):
* 数据库全表统计(69 万行):`Store.stats()/keep_stats()` 内部带缓存,写入自动失效;
* 跟数据库无关但可能很慢的:媒体目录 `listdir`(云盘/FUSE 上可能数秒)、`disk_usage`。
"""
import os
import shutil
ttl = float(cfg.get("stats_cache_sec") or 0)
st = store.stats()
d = st
def _side():
try:
usage = shutil.disk_usage(str(cfg.get("media_dir")))
total, free = usage.total, usage.free
except Exception:
total, free = 0, 0
try:
files = len([f for f in os.listdir(cfg.media_dir) if f.endswith(".ts")]) \
if os.path.isdir(cfg.media_dir) else 0
except Exception:
files = -1 # -1 = 媒体目录暂时读不到(界面显示 0)
return {"disk_total": total, "disk_free": free, "media_files": files}
d.update(_side_cached("media", ttl, _side))
d["keeps"] = store.keep_stats() # 歌曲表统计(概览页卡片)
d["config"] = {k: v for k, v in cfg.as_dict().items() if k != "admin_token"}
d["downloader"] = downloader.status() if downloader else {}
d["batch"] = _maintain().status() if _maintain() is not None else {}
with _sync_lock:
d["sync"] = dict(SYNC_STATE)
return web.json_response(d)
# ── 曲库清单(**只读参考**:查询 / 分页 / 导出;不允许增删)──
@r.get("/api/songs")
@r.get("/api/library/songs")
def songs(req: web.Request):
return web.json_response(store.list_songs(
keyword=req.q("keyword"), lang=req.q("lang"), state=req.q("state"),
page=req.qi("page", 1), page_size=min(500, req.qi("page_size", 50)),
order=req.q("order", "hot"), desc=req.q("desc", "1") != "0"))
@r.get("/api/languages")
def languages(req: web.Request):
return web.json_response({"items": store.languages()})
@r.get("/api/export.csv")
@r.get("/api/library/export.csv")
def export_csv(req: web.Request):
"""曲库清单导出(列与歌曲表导出一致,便于互导)。"""
text = store.library_csv(req.q("keyword"), req.q("lang"))
return web.Response(text.encode("utf-8-sig"), 200, "text/csv; charset=utf-8",
{"Content-Disposition": 'attachment; filename="songs.csv"'})
# ── 歌曲表(keeps:**用户指定要维护的名单,服务器只按它下载/删文件**)──
def _tids_of(body: dict) -> list[int]:
"""容错解析 tids(非法编号直接跳过,不整单失败)。"""
out = []
for t in (body.get("tids") or []):
try:
out.append(int(t))
except Exception:
continue
return out
@r.get("/api/keeps")
def keeps(req: web.Request):
return web.json_response(store.keep_rows(
keyword=req.q("keyword"), lang=req.q("lang"), state=req.q("state"),
page=req.qi("page", 1), page_size=min(500, max(1, req.qi("page_size", 50))),
order=req.q("order", "hot"), desc=req.q("desc", "1") != "0"))
@r.post("/api/keeps/add")
def keeps_add(req: web.Request):
"""加入歌曲表:给了 tids 就按编号加;否则按筛选条件批量加(可给 limit)。"""
body = req.json()
tids = _tids_of(body)
user, note = str(body.get("user") or ""), str(body.get("note") or "")
if tids:
res = store.keep_add(tids, user=user, note=note)
else:
kw, lang = str(body.get("keyword") or ""), str(body.get("lang") or "")
try:
limit = int(body.get("limit") or 0)
except Exception:
return web.json_response({"ok": False, "error": "limit 必须是整数"})
if limit < 0:
return web.json_response({"ok": False, "error": "limit 不能为负数"})
if not (kw or lang or limit):
return web.json_response({"ok": False, "error": "没有要加入的编号,也没有筛选条件"})
res = store.keep_add_by_filter(kw, lang, limit, user=user, note=note)
return web.json_response({"ok": True, **res, "stats": store.keep_stats()})
@r.post("/api/keeps/remove")
def keeps_remove(req: web.Request):
"""从歌曲表移除(**默认连带删除本地媒体文件**,返回删了几个、释放多少字节)。"""
body = req.json()
tids = _tids_of(body)
if not tids:
return web.json_response({"ok": False, "error": "没有要移除的编号"})
delete_files = bool(body.get("delete_files", True))
res = store.keep_remove(tids, delete_files=delete_files, media_dir=cfg.media_dir)
return web.json_response({"ok": True, **res, "stats": store.keep_stats()})
@r.get("/api/keeps/export.csv")
def keeps_export(req: web.Request):
return web.Response(store.keep_csv().encode("utf-8-sig"), 200, "text/csv; charset=utf-8",
{"Content-Disposition": 'attachment; filename="keeps.csv"'})
@r.post("/api/keeps/import")
def keeps_import(req: web.Request):
"""导入 CSV:JSON 里给 {text, mode},或**直接把 CSV 字节放在请求体里**(`?mode=merge|replace`)。
⚠️ 必须把**原始请求体 `req.body`(bytes)**交给 `store.keep_import_csv()`:Excel 中文版
另存的 CSV 常是 GBK + TAB,一旦在 HTTP 层先用 `req.text()`(`errors="replace"`)解成 str,
坏字节会变成 U+FFFD 而**永久丢失**,gb18030 回退也救不回来 —— 用户看到的"表格错乱"
就是这么来的。编码/分隔符的识别都在 store 层做(utf-8-sig → utf-8 → gb18030 → utf-16)。
"""
raw = req.body or b""
body = req.json() if raw.lstrip()[:1] == b"{" else {} # JSON 信封:{text, mode}
mode = str(body.get("mode") or req.q("mode", "merge") or "merge").strip().lower()
if mode not in ("merge", "replace"):
return web.json_response({"ok": False, "error": "mode 只能是 merge(合并)或 replace(整表替换)"})
text = body.get("text")
payload: "str | bytes" = raw if text is None else str(text)
if not payload.strip():
return web.json_response({"ok": False, "error": "CSV 内容为空"})
res = store.keep_import_csv(payload, mode=mode)
return web.json_response({"ok": True, **res, "stats": store.keep_stats()})
@r.get("/api/keeps/stats")
def keeps_stats(req: web.Request):
"""歌曲表统计 + **最近一次媒体扫描结果**(时间/新增/更新/跳过/耗时/错误)。"""
return web.json_response({"ok": True, **store.keep_stats(), "scan": media_scan_status(cfg)})
@r.post("/api/keeps/scan")
def keeps_scan(req: web.Request):
"""**立刻扫描媒体目录一次**:把 `<编号>.ts` 登记/更新进歌曲表(**绝不删除文件**)。
媒体目录在云盘/FUSE 上可能很慢 —— 列举在带超时的守护线程里做,超时/异常返回
`ok=false`(`error` 说明原因)且**不改变任何状态**;`scan` 里带最近一次结果。
"""
res = run_media_scan(cfg, store)
return web.json_response({**res, "ok": bool(res.get("ok")),
"scan": media_scan_status(cfg),
"stats": store.keep_stats()})
@r.get("/api/keeps/scan/status")
def keeps_scan_status(req: web.Request):
"""最近一次媒体扫描的结果(时间/新增/更新/跳过/耗时/错误 + 开关与间隔)。"""
return web.json_response({"ok": True, "scan": media_scan_status(cfg)})
@r.get("/api/keeps/unmatched")
def keeps_unmatched(req: web.Request):
"""当前曲库里匹配不到的歌曲表编号(便于核对是不是编号写错)。"""
items = store.keep_unmatched(req.qi("limit", 0))
return web.json_response({"ok": True, "total": len(items), "items": items})
# ── 曲库同步 ──
@r.post("/api/library/sync")
def library_sync(req: web.Request):
with _sync_lock:
if SYNC_STATE["running"]:
return web.json_response({"ok": False, "error": "同步已在进行中"})
threading.Thread(target=run_sync, args=(cfg, store), name="muse-sync", daemon=True).start()
return web.json_response({"ok": True, "started": True})
@r.post("/api/library/cancel")
def library_cancel(req: web.Request):
with _sync_lock:
if not SYNC_STATE["running"]:
return web.json_response({"ok": False, "error": "当前没有进行中的同步"})
_sync_cancel.set()
store.event("WARN", "已请求取消曲库同步(在当前阶段结束时停止)")
return web.json_response({"ok": True, "cancelling": True})
@r.get("/api/library/tasks")
def library_tasks(req: web.Request):
"""需求②:同步任务清单(含每次的清单比对结果)。**有上限**,绝不一次吐出全表。"""
return web.json_response({"items": store.sync_tasks(min(500, max(1, req.qi("limit", 50)))),
"running": store.running_sync_task()})
@r.get("/api/library/task")
def library_task(req: web.Request):
return web.json_response(store.sync_task_get(req.qi("id", 0)))
@r.get("/api/download/tasks")
def download_tasks(req: web.Request):
"""需求②:下载任务清单(按状态筛选 + 分页)。"""
return web.json_response(store.download_tasks(req.q("state"), req.qi("page", 1),
min(500, req.qi("page_size", 50))))
@r.get("/api/upstream/hosts")
def upstream_hosts(req: web.Request):
"""需求③:多源健康度与自动选择顺序。"""
res = downloader.resolver if downloader is not None else None
if res is None:
from .resolver import Resolver
res = Resolver(cfg, store)
return web.json_response({"items": res.host_report(), "order": res.hosts(),
"last_ok_host": res.last_ok_host})
@r.post("/api/upstream/clear_health")
def upstream_clear(req: web.Request):
res = downloader.resolver if downloader is not None else None
if res is None:
from .resolver import Resolver
res = Resolver(cfg, store)
res.clear_health()
store.event("INFO", "已清除上游源健康度(全部源重新按配置顺序)")
return web.json_response({"ok": True, "items": res.host_report()})
@r.post("/api/upstream/probe")
def upstream_probe(req: web.Request):
"""手动探测:对每个配置的源做一次 api.php 配置查询 + i.php 取 token。
⚠️ 这是**一次性批量取址路径**(循环多个源):`i.php` 取 token 必须走与下载线程
同一个 `resolver._pace()` 节奏(全局串行),因此这里显式把配置里的
`upstream_interval_min_ms/max_ms` 传下去,**不允许绕过限速**。
"""
from . import resolver as R
mac = str(cfg.get("upstream_mac"))
sn = str(cfg.get("upstream_sn"))
gap = int(cfg.get("upstream_interval_min_ms", cfg.get("upstream_request_interval_ms", 5000)))
gap_max = int(cfg.get("upstream_interval_max_ms", gap))
out = []
for host in (cfg.get("upstream_hosts") or []):
h = host if str(host).startswith("http") else "http://" + str(host)
t0 = time.time()
conf = R.fetch_config(h, mac)
token = R.get_token(h, mac, sn, min_interval_ms=gap, max_interval_ms=gap_max)
ok = bool(token)
out.append({"host": h, "token": "有" if token else "无",
"trial_times": (conf or {}).get("trial_times"),
"network_verification": (conf or {}).get("network_verification"),
"has_api_php": conf is not None,
"ms": int((time.time() - t0) * 1000)})
store.event("INFO", f"手动探测上游源:{[(x['host'], x['token']) for x in out]}")
return web.json_response({"ok": True, "items": out})
@r.get("/api/library/status")
def library_status(req: web.Request):
with _sync_lock:
st = dict(SYNC_STATE)
st["library"] = store.library_meta()
return web.json_response(st)
# ── 入队 ──
@r.post("/api/download/enqueue")
def enqueue(req: web.Request):
"""显式入队:body 里带 `tids` 才会入队(下载队列页的"下载选中/本页"用这条)。
⚠️ 自动补队**只有一条路径**:`ktv/maintain.py` 的维护任务按歌曲表补队
(`store.keep_next_batch` + 本方法内部同一套 `store.enqueue`)。
这里**不接受**"按热度自己挑歌"的自动入队参数。
"""
body = req.json()
tids = body.get("tids") or []
if not tids:
return web.json_response({"ok": False, "error": "没有可入队的编号"})
res = store.enqueue([int(t) for t in tids], skip_done=bool(body.get("skip_done", True)),
batch=str(body.get("name") or ""))
if downloader is not None and not downloader._threads:
downloader.start()
return web.json_response({"ok": True, **res, "pool": downloader.status() if downloader else {}})
# ── 队列控制 ──
@r.post("/api/download/start")
def dl_start(req: web.Request):
bad = need_downloader()
if bad:
return bad
downloader.start()
return web.json_response({"ok": True, "pool": downloader.status()})
@r.post("/api/download/pause")
def dl_pause(req: web.Request):
bad = need_downloader()
if bad:
return bad
downloader.pause()
return web.json_response({"ok": True, "pool": downloader.status()})
@r.post("/api/download/resume")
def dl_resume(req: web.Request):
bad = need_downloader()
if bad:
return bad
downloader.resume()
if not downloader._threads:
downloader.start()
return web.json_response({"ok": True, "pool": downloader.status()})
@r.post("/api/download/stop")
def dl_stop(req: web.Request):
bad = need_downloader()
if bad:
return bad
downloader.stop()
return web.json_response({"ok": True, "pool": downloader.status()})
@r.get("/api/download/status")
def dl_status(req: web.Request):
"""下载池状态 + 队列统计 + **失败任务自动重试状态**(`auto_retry`)。"""
return web.json_response({"pool": downloader.status() if downloader else {}, "stats": store.stats(),
"active": store.downloading_list(),
"auto_retry": auto_retry_status(cfg, downloader)})
@r.post("/api/download/retry_failed")
def retry_failed(req: web.Request):
n = store.retry_failed(_cfg_int(cfg, "retry_failed_max_attempts", "max_attempts", 3))
return web.json_response({"ok": True, "reset": n})
@r.post("/api/download/retry_failed_now")
def retry_failed_now(req: web.Request):
"""**立刻执行一次"失败任务自动重试"**(便于手工验证;与定时线程同一套逻辑)。
* 默认**遵守跳过条件**(人工暂停 / 存储水位不足 → 只返回 `skipped_reason`,不重置);
* body 里带 `{"force": true}` 可绕过跳过条件强制重置一次(用户显式要求时用)。
"""
body = req.json()
force = bool(body.get("force"))
r = getattr(downloader, "retry", None) if downloader is not None else None
if r is not None and callable(getattr(r, "run_once", None)):
res = r.run_once(force=force)
return web.json_response({"ok": bool(res.get("ok", True)), "force": force, **res})
# 未装配调度器(测试/只读模式):退化为直接重置一次,保证接口可用
n = store.retry_failed(_cfg_int(cfg, "retry_failed_max_attempts", "max_attempts", 3))
return web.json_response({"ok": True, "reset": n, "skipped": False, "force": force,
"auto_retry": auto_retry_status(cfg, downloader)})
@r.get("/api/download/failed")
def failed(req: web.Request):
return web.json_response({"items": store.failed_list(req.qi("limit", 200))})
# ── 歌曲表**自动维护**(新:合并 扫描登记 / 失败重试 / 补队下载 / 身份时效 / 心跳)──
@r.get("/api/maintain/status")
def maintain_status(req: web.Request):
"""完整状态:state / next_run_at / last_run_at / 各计数 / 心跳 / 身份时效。"""
task = _maintain()
if task is None:
return web.json_response({"ok": False, "enabled": False, "state": "stopped",
"state_text": "未运行", "error": "维护任务未启用"}, 200)
return web.json_response(task.status())
@r.post("/api/maintain/start")
def maintain_start(req: web.Request):
"""启动(发起即返回;第一轮立刻跑,之后每 interval 秒一轮)。"""
task = _maintain()
if task is None:
return web.json_response({"ok": False, "error": "维护任务未启用"}, 503)
return web.json_response(task.start())
@r.post("/api/maintain/stop")
def maintain_stop(req: web.Request):
"""停止(同时停 whoami 心跳;不会等到本轮 drain 结束才生效)。"""
task = _maintain()
if task is None:
return web.json_response({"ok": False, "error": "维护任务未启用"}, 503)
return web.json_response(task.stop(str(req.json().get("reason") or "手动停止")))
@r.post("/api/maintain/restart")
def maintain_restart(req: web.Request):
"""重启 = 停止后**立即开始新的一轮**(不等 interval)。"""
task = _maintain()
if task is None:
return web.json_response({"ok": False, "error": "维护任务未启用"}, 503)
return web.json_response(task.restart(str(req.json().get("reason") or "手动重启")))
@r.post("/api/maintain/run_now")
def maintain_run_now(req: web.Request):
"""立即跑一轮(**立刻返回**,后台线程执行;不改变排程)。"""
task = _maintain()
if task is None:
return web.json_response({"ok": False, "error": "维护任务未启用"}, 503)
return web.json_response(task.run_now())
# ── 按歌曲表批量下载(**旧接口保留为薄包装**,指向自动维护任务,避免破坏已有脚本)──
@r.post("/api/batch/start")
def batch_start(req: web.Request):
task = _maintain()
if task is None:
return web.json_response({"ok": False, "error": "维护任务未启用"}, 503)
return web.json_response(task.start())
@r.post("/api/batch/stop")
def batch_stop(req: web.Request):
task = _maintain()
if task is None:
return web.json_response({"ok": False, "error": "维护任务未启用"}, 503)
return web.json_response(task.stop(str(req.json().get("reason") or "手动停止")))
@r.get("/api/batch/status")
def batch_status(req: web.Request):
task = _maintain()
if task is None:
return web.json_response({"enabled": False, "fatal": False, "reason": "未启用"})
return web.json_response(task.status())
# ── whoami 设备心跳(官方 App 的第三个请求,见 ktv/whoami.py)──
@r.get("/api/whoami")
def whoami_status(req: web.Request):
"""最近一次心跳:时间 / 耗时 / 响应 / 是否成功(含开关、计数与下次间隔)。"""
if whoami is None:
return web.json_response({"enabled": False, "running": False,
"error": "whoami 心跳未启用(服务未装配 WhoamiReporter)"})
return web.json_response(whoami.status())
@r.post("/api/whoami/send")
def whoami_send(req: web.Request):
"""立刻打一次 whoami(手工验证用;**不受 whoami_enabled 限制**)。"""
if whoami is None:
return web.json_response({"ok": False, "error": "whoami 心跳未启用"}, 503)
return web.json_response(whoami.send_now())
# ── 设备身份(伪装成一台安卓盒子:见 ktv/identity.py)──
@r.get("/api/device")
def device_get(req: web.Request):
"""当前设备身份:mac/sn/机型/模板 + 文件路径 + 是否自动生成 + 生效来源。"""
if device_identity is None:
return web.json_response({"ok": False, "error": "设备身份未启用(服务未装配 DeviceIdentity)"},
503)
return web.json_response(device_identity.status())
@r.post("/api/device/regenerate")
def device_regenerate(req: web.Request):
"""重新生成设备身份(body 可带 `{"preset":"aml_s905x3"}`,留空 = 随机模板)。
生成后立刻生效:
* 写回内存中的配置(`upstream_mac`/`upstream_sn`)→ 下一轮 `i.php`/`do.php` 用新身份;
* `whoami` 每轮都现读配置 → **下一轮心跳就是新身份**(无需重启,也不打断在跑的线程);
* 清掉取址器缓存的 token(token 绑在旧 device 上,继续用会被上游判"换设备/异常");
* 写一条事件日志,便于在「日志」页核对。
"""
if device_identity is None:
return web.json_response({"ok": False, "error": "设备身份未启用"}, 503)
preset = str((req.json() or {}).get("preset") or "")
# 一处完成:生成 + 落盘 + apply_to_cfg(i.php/do.php/whoami 同时换新)+ 清 token
ident = identity.regenerate_identity(cfg, device_identity, downloader, preset)
return web.json_response({"ok": True, "identity": ident,
"status": device_identity.status()})
# ── 存储水位 ──
@r.get("/api/storage")
def storage(req: web.Request):
guard = downloader.guard if downloader is not None else None
st = guard.state if guard else {}
if not st:
from .downloader import StorageGuard
st = StorageGuard(cfg, store, None).sample()
return web.json_response({"storage": st, "pool": downloader.status() if downloader else {},
"config": {k: cfg.get(k) for k in (
"local_space_path", "min_free_local_bytes", "min_free_media_bytes",
"resume_free_margin_bytes", "pause_on_low_space",
"media_rate_limit_mb_s", "cloud_upload_mbps")}})
# ── 设置 ──
@r.get("/api/settings")
def get_settings(req: web.Request):
return web.json_response(cfg.as_dict())
@r.post("/api/settings")
def set_settings(req: web.Request):
patch = req.json()
warns = cfg.update(patch)
cfg.ensure_dirs()
if "stats_cache_sec" in patch: # 统计缓存时长改动立即生效
store.stats_cache_sec = max(0.0, float(cfg.get("stats_cache_sec") or 0))
store.clear_stats_cache()
_stats_side_cache.clear()
if "concurrency" in patch and downloader is not None:
downloader.rebuild()
if downloader is not None and any(k in patch for k in (
"media_rate_limit_mb_s", "cloud_upload_mbps")):
downloader.apply_limits() # 限速改动立即生效
if device_identity is not None and "device_identity_preset" in patch:
# 换了模板就立刻按新模板重新生成一份身份(否则要等到用户点"重新生成")
# 同一处逻辑:apply_to_cfg + 清 token(i.php/do.php/whoami 三处同源)
ident = identity.regenerate_identity(
cfg, device_identity, downloader, str(cfg.get("device_identity_preset") or ""))
store.event("INFO", f"设备身份模板已切换:{ident.get('preset')} mac={ident.get('mac')}")
store.event("INFO", f"设置已更新:{', '.join(patch.keys())}")
return web.json_response({"ok": True, "warnings": warns, "config": cfg.as_dict()})
# ── 日志 ──
@r.get("/api/events")
def events(req: web.Request):
return web.json_response({"items": store.events(req.qi("limit", 200))})
return r