版本号:
- 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 例)
678 lines
33 KiB
Python
678 lines
33 KiB
Python
"""管理端口:管理界面用到的 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
|