"""管理端口:管理界面用到的 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