合并自建歌曲源支线(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 例)
This commit is contained in:
zijunchiang
2026-10-14 02:06:28 +08:00
parent e060ae5ebe
commit 87aa979abd
68 changed files with 21305 additions and 105 deletions
+939
View File
@@ -0,0 +1,939 @@
"""管理界面:单页 HTML + 原生 JS(无构建、无外部 CDN,内网离线可用)。
前端约定(2026-10-11 按"点同步曲库就卡住"的反馈重做):
* **动作类按钮一律"发起即返回"**:点击后按钮进入 loading 态 + 顶上一行提示,
拒绝在前端 `await` 长任务(同步曲库要跑上百秒,等它就是"页面像死了"的根源);
* **一个统一的轮询(默认 4 秒,可暂停)**:每轮只请求当前页签需要的接口,
`Promise.allSettled` 并发 + 单个接口失败互不影响;上一轮没回来就跳过这一轮(防重入);
* **只更新变化的那几块 DOM**:每个块用"内容签名"比对,签名没变就一个字节都不动
(整页 `innerHTML` 重建会丢输入焦点/勾选/滚动位置);顶部实时显示"最后更新时间";
* **进度可见**:同步显示进度条/百分比/已用时;批量显示 keeps 总数/待下/已下/失败、
百分比、速率与预计剩余。
"""
from __future__ import annotations
from . import web
PAGE = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>麦动KTV 自建歌曲源 · 管理台</title>
<style>
:root{--bg:#0f1115;--card:#171a21;--line:#262b36;--fg:#e6e8ee;--mut:#8b93a7;--acc:#ff5c39;--ok:#2ecc71;--warn:#f1c40f;--err:#e74c3c}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
header{display:flex;align-items:center;gap:12px;padding:12px 18px;border-bottom:1px solid var(--line);background:var(--card);position:sticky;top:0;z-index:5}
h1{font-size:16px;margin:0;flex:0 0 auto}.grow{flex:1}
nav{display:flex;gap:6px;padding:10px 18px;border-bottom:1px solid var(--line)}
nav button{background:transparent;border:1px solid var(--line);color:var(--fg);padding:6px 14px;border-radius:8px;cursor:pointer}
nav button.on{background:var(--acc);border-color:var(--acc);color:#fff}
main{padding:16px 18px 40px}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:12px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px}
.card b{display:block;font-size:22px;margin-top:4px}.mut{color:var(--mut);font-size:12px}
button.btn{background:var(--acc);border:0;color:#fff;padding:8px 14px;border-radius:8px;cursor:pointer}
button.btn.ghost{background:transparent;border:1px solid var(--line);color:var(--fg)}
button.btn:disabled{opacity:.5;cursor:not-allowed}
input,select,textarea{background:#0d1017;border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:7px 9px;font:inherit}
table{width:100%;border-collapse:collapse;font-size:13px}th,td{border-bottom:1px solid var(--line);padding:7px 8px;text-align:left;vertical-align:top}
th{color:var(--mut);font-weight:500;position:sticky;top:0;background:var(--card)}
tr:hover td{background:#1c2029}.tag{display:inline-block;padding:1px 7px;border-radius:999px;font-size:12px;border:1px solid var(--line)}
.done{color:var(--ok);border-color:#1d6b45}.failed{color:var(--err);border-color:#7a2a25}.pending{color:var(--mut)}
.downloading{color:var(--warn);border-color:#7a6316}.missing{color:var(--err);border-color:#7a2a25}
button.btn.mini{padding:3px 9px;font-size:12px;border-radius:6px;margin-right:4px}
label.filebtn{display:inline-flex;align-items:center;gap:6px;cursor:pointer}
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:10px 0}
.tbl{max-height:60vh;overflow:auto;border:1px solid var(--line);border-radius:12px}
pre{background:#0d1017;border:1px solid var(--line);border-radius:10px;padding:10px;max-height:44vh;overflow:auto;font-size:12px}
.bar{height:8px;background:#0d1017;border:1px solid var(--line);border-radius:99px;overflow:hidden;margin-top:8px}
.bar>i{display:block;height:100%;background:var(--acc);width:0}
label.f{display:block;margin:10px 0 4px;color:var(--mut);font-size:12px}
.hint{color:var(--warn);font-size:12px}
code{background:#0d1017;padding:1px 5px;border-radius:5px}
#hint{display:none;margin:0 0 10px;padding:8px 10px;border:1px solid var(--line);border-radius:10px;background:#12151c}
</style>
</head>
<body>
<header>
<h1>🎤 麦动KTV 自建歌曲源</h1>
<span class="mut" id="verline">管理台</span>
<span class="grow"></span>
<label class="mut" style="display:flex;align-items:center;gap:6px">
<input type="checkbox" id="autoRefresh" checked> 自动刷新
</label>
<span class="mut" id="lastUpdate">—</span>
<button class="btn ghost mini" onclick="tick(true)">立即刷新</button>
<span class="mut" id="portline"></span>
<button class="btn ghost" onclick="askToken()">令牌</button>
</header>
<nav>
<button data-t="overview" class="on">概览</button>
<button data-t="songs">曲库清单</button>
<button data-t="keeps">歌曲表</button>
<button data-t="tasks">下载队列</button>
<button data-t="settings">设置</button>
<button data-t="logs">日志</button>
</nav>
<main>
<div id="hint"></div>
<section id="tab-overview"></section>
<section id="tab-songs" hidden></section>
<section id="tab-keeps" hidden></section>
<section id="tab-tasks" hidden></section>
<section id="tab-settings" hidden></section>
<section id="tab-logs" hidden></section>
</main>
<script>
const $ = (s, r=document) => (r||document).querySelector(s);
const esc = s => String(s==null?'':s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const bytes = n => { n=Number(n||0); const u=['B','KB','MB','GB','TB']; let i=0; while(n>=1024&&i<u.length-1){n/=1024;i++;} return n.toFixed(i?1:0)+u[i]; };
const nfmt = n => Number(n||0).toLocaleString();
const hms = s => { s=Math.max(0,Math.round(s||0)); const h=Math.floor(s/3600), m=Math.floor(s%3600/60), x=s%60; return (h?h+'小时':'')+((h||m)?m+'分':'')+x+'秒'; };
const orderName = o => ({hot:'热度',tid:'编号',name:'歌名',lang:'语言'})[o]||o;
const DL_CN={none:'未入队',pending:'待下载',downloading:'下载中',done:'已就绪',failed:'失败',missing:'缺失'};
const KEEP_CN={want:'待下载',downloading:'下载中',done:'已下',failed:'失败'};
const KEEP_FILTER_CN={unmatched:'曲库未匹配',matched:'曲库已匹配'};
const REFRESH_MS = 4000; // 统一轮询周期(3~5 秒)
let tab = 'overview'; // 当前页签
// ── 令牌 / 请求 ──
function token(){ return localStorage.getItem('ktv_token')||''; }
function askToken(){ const t=prompt('管理令牌(留空表示不使用)', token()); if(t!==null){ localStorage.setItem('ktv_token', t.trim()); location.reload(); } }
async function api(path, opts={}, silent=false){
const o = Object.assign({headers:{}}, opts);
const raw = o.raw === true; // raw=true:body 是**原始字节**(CSV 导入必须保字节,见 importKeeps)
delete o.raw;
o.headers['Content-Type'] = raw ? 'text/csv' : 'application/json';
if(token()) o.headers['X-Auth-Token']=token();
if(!raw && o.body && typeof o.body!=='string') o.body=JSON.stringify(o.body);
const res = await fetch(path, o);
if(res.status===401){ if(!silent) alert('需要管理令牌(右上角设置)'); throw new Error('未授权(401)'); }
const ct = res.headers.get('content-type')||'';
const data = ct.includes('json') ? await res.json() : await res.text();
if(!res.ok && data && data.error) throw new Error(data.error);
return data;
}
// ── 顶部提示行 / 最后更新时间 / "只在内容变化时才动 DOM" ──
let hintTimer = 0;
function hint(msg, kind=''){
const el = $('#hint'); if(!el) return;
if(!msg){ el.style.display='none'; return; }
el.textContent = msg; el.style.display='block';
el.style.color = kind==='err' ? 'var(--err)' : (kind==='ok' ? 'var(--ok)' : 'var(--warn)');
clearTimeout(hintTimer);
hintTimer = setTimeout(()=>{ el.style.display='none'; }, 15000);
}
function touchUpdate(){ const el=$('#lastUpdate'); if(el) el.textContent = '最后更新 '+new Date().toLocaleTimeString(); }
const SIG = {}; // 每个 DOM 块上一次渲染的内容签名
function clearSig(){ for(const k in SIG) delete SIG[k]; } // 作废签名:骨架重画后必须让 patch 真正写入
function patch(id, sig, html){ // 签名没变 → 完全不碰 DOM(保住勾选/焦点/滚动)
const el = document.getElementById(id); if(!el) return;
if(SIG[id] === sig) return;
SIG[id] = sig; el.innerHTML = html;
}
// ── 动作类按钮:**发起即返回**(不 await 长任务;loading + 提示 + 轮询出结果)──
const BTN = new WeakMap();
async function act(btn, path, body, opts={}){
if(btn){ BTN.set(btn, btn.textContent); btn.disabled=true; btn.textContent = opts.loading || '执行中…'; }
hint(opts.hint || '已发起,结果会自动刷新…');
// 即使后台任务很久也不让按钮"永远转圈":最多 8 秒恢复可点(结果仍由轮询显示)
const restore = setTimeout(()=>{ if(btn){ btn.disabled=false; btn.textContent = BTN.get(btn)||btn.textContent; } }, opts.timeout||8000);
try{
const r = await api(path, {method:'POST', body: body===undefined?{}:body}, true);
if(r && r.ok === false) hint((opts.errPrefix||'') + (r.error||'操作失败'), 'err');
else hint(opts.done ? opts.done(r) : (opts.okText||'已下发'), 'ok');
tick(true); // 操作后立刻局部刷新,不等下一个周期
return r;
}catch(e){ hint('请求失败:'+e.message, 'err'); }
finally{ clearTimeout(restore); if(btn){ btn.disabled=false; btn.textContent = BTN.get(btn)||btn.textContent; } }
}
// ── 统一轮询(防重入 + 单接口失败不影响其它卡片)──
let tickBusy = false;
async function tick(force){
if(tickBusy) return; // 上一轮没回来 → 跳过这一轮
const box = $('#autoRefresh');
if(box && !box.checked && force!==true) return; // 关掉自动刷新时只有手动/操作后才刷新
tickBusy = true;
try{
const jobs = [];
if(tab==='overview') jobs.push(ovCards(), ovSync(), ovBatch(), ovStorage(), ovUpstream(), ovDeviceHeartbeat());
else if(tab==='songs') jobs.push(songsBlock());
else if(tab==='keeps') jobs.push(keepsBlock());
else if(tab==='tasks') jobs.push(taskCards(), syncTasksBlock('syncTasksAll'), dlTasksBlock());
else if(tab==='logs') jobs.push(logsBlock());
await Promise.allSettled(jobs);
touchUpdate();
}finally{ tickBusy = false; }
}
setInterval(()=>tick(false), REFRESH_MS);
// ── 页签 ──
const TABS = ['overview','songs','keeps','tasks','settings','logs'];
const SKELETON = {overview:renderOverview, songs:renderSongs, keeps:renderKeeps,
tasks:loadTasks, settings:renderSettings, logs:renderLogs};
document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>{
const next = b.dataset.t;
const changed = (next !== tab);
document.querySelectorAll('nav button').forEach(x=>x.classList.toggle('on',x===b));
tab = next;
TABS.forEach(t=>{ const el=$('#tab-'+t); if(el) el.hidden = (t!==tab); });
if(changed){
SKELETON[tab](); // 只在真正切换页签时重画骨架
clearSig(); // ⚠️ 骨架里是"加载中…"占位;不清签名缓存,
// patch() 会因数据没变而跳过写入 → 占位符永久残留(卡死 bug)
}
tick(true); // 再次点击当前页签 = 原地刷新数据,不重画骨架
});
// ── 概览 ──
function renderOverview(){
$('#tab-overview').innerHTML = `
<div id="ovCards"><div class="grid"><div class="card mut">加载中…</div></div></div>
<div class="row" style="margin-top:16px">
<button class="btn" id="btnSync" onclick="act(this,'/api/library/sync',{},{
loading:'同步已开始…', hint:'同步已开始,进度见下方同步状态与任务列表',
okText:'同步已开始,进度见下方任务列表'})">同步曲库(官方源)</button>
<button class="btn ghost" onclick="cancelSync(this)">取消同步</button>
<button class="btn ghost" onclick="act(this,'/api/download/start',{},{
loading:'启动中…', hint:'已发起:启动下载池', okText:'下载池已启动'})">启动下载池</button>
<button class="btn ghost" onclick="act(this,'/api/download/pause',{},{
loading:'暂停中…', hint:'已发起:暂停下载池', okText:'下载池已暂停'})">暂停</button>
<button class="btn ghost" onclick="act(this,'/api/download/resume',{},{
loading:'继续中…', hint:'已发起:继续下载池', okText:'下载池已继续'})">继续</button>
<button class="btn ghost" onclick="act(this,'/api/download/stop',{},{
loading:'停止中…', hint:'已发起:停止下载池', okText:'下载池已停止'})">停止</button>
</div>
<div class="card">
<div class="row" style="margin:0 0 6px">
<span class="mut">曲库同步状态:</span>
<b id="syncText" style="display:inline;font-size:14px">—</b>
<button class="btn ghost mini" onclick="cancelSync(this)">取消同步</button>
<span class="mut" id="syncElapsed"></span>
<button class="btn ghost mini" onclick="act(this,'/api/upstream/clear_health',{},
{loading:'清除中…', hint:'已发起:清除上游健康度', okText:'已清除上游源健康度'})">清除健康度</button>
</div>
<div class="bar"><i id="syncBar" style="width:0%"></i></div>
<div class="mut" id="syncExtra"></div>
<table style="margin-top:10px"><thead><tr><th>最近同步任务</th><th>状态</th><th>总数</th><th>新增</th><th>上游删除</th><th>变更</th><th>耗时</th><th>时间</th></tr></thead><tbody id="syncTasks"><tr><td colspan=8 class="mut">加载中…</td></tr></tbody></table>
</div>
<div class="card" style="margin-top:12px">
<div class="row" style="margin:0 0 8px"><b style="font-size:14px">歌曲表自动维护</b>
<button class="btn" onclick="maintainAction(this,'start')">启动</button>
<button class="btn ghost" onclick="maintainAction(this,'stop')">停止</button>
<button class="btn ghost" onclick="maintainAction(this,'restart')">重启</button>
<button class="btn ghost" onclick="maintainAction(this,'run_now')">立即维护一次</button></div>
<div id="ovBatchBody" class="mut">加载中…</div>
</div>
<div class="card" style="margin-top:12px">
<div class="row" style="margin:0 0 8px"><b style="font-size:14px">存储守卫(云盘/FUSE 挂载必看)</b>
<button class="btn ghost" onclick="ovStorage()">刷新</button>
<span class="mut">水位不足会自动暂停下载;媒体下载按「上传速度匹配」限速</span></div>
<div id="ovStorageBody" class="mut">加载中…</div>
</div>
<div class="card" style="margin-top:12px">
<div class="row" style="margin:0 0 8px">
<b style="font-size:14px">上游歌曲源(多源自动选择)</b>
<button class="btn ghost" onclick="probeUpstream(this)">探测全部源</button>
<button class="btn ghost" onclick="act(this,'/api/upstream/clear_health',{},
{loading:'清除中…', hint:'已发起:清除上游健康度', okText:'已清除上游源健康度'})">清除健康度</button>
<span class="mut">排序规则:未冷却 → 健康分高 → 配置顺序优先;403/连续失败自动冷却 10 分钟</span>
</div>
<table><thead><tr><th>#</th><th>源</th><th>成功</th><th>失败</th><th>健康分</th><th>冷却剩余</th><th>最近错误</th></tr></thead>
<tbody id="upstream"><tr><td colspan=7 class="mut">加载中…</td></tr></tbody></table>
<div class="mut" style="margin-top:6px">对 App 暴露的媒体地址:<code id="ovMediaBase"></code>
<span id="ovMediaWarn"></span></div>
</div>
<div class="card" style="margin-top:12px">
<div class="row" style="margin:0 0 8px">
<b style="font-size:14px">设备身份与心跳</b>
<button class="btn" onclick="regenDevice(this)">重新生成身份</button>
<button class="btn ghost" onclick="act(this,'/api/whoami/send',{},{
loading:'上报中…', hint:'已发起:whoami 上报', okText:'已上报(结果见下方)',
done:r=>'whoami '+(r&&r.ok?'成功':'失败')+' http='+((r&&r.http)||0), timeout:15000})">立即上报心跳</button>
<span class="mut">服务端与 App 共用同一套盒子模板(<code>mac</code>/<code>device</code>/whoami 三处同源);心跳只在有歌要下时运行,排空后自动停</span>
</div>
<div id="ovDeviceBody" class="mut">加载中…</div>
</div>`;
}
async function ovCards(){
const d = await api('/api/stats', {}, true);
const st=d.library||{}, pool=d.downloader||{}, ks=d.keeps||{};
const pl=$('#portline'); if(pl) pl.textContent = `服务端口 ${d.config.service_port} · 管理端口 ${d.config.admin_port}`;
const mb=$('#ovMediaBase'); if(mb) mb.textContent = d.config.public_media_base;
const mw=$('#ovMediaWarn');
if(mw) mw.innerHTML = /^https?:\/\/\d/.test(d.config.public_media_base||'')
? '<span class="hint"> ⚠️ 这是裸 IP,App 会拒收,请改域名!</span>' : '';
const sig = JSON.stringify([st.songs, st.version, d.songs_removed_upstream, d.done, d.done_bytes,
d.pending, d.failed, d.missing, pool.concurrency, pool.active_count,
d.media_files, d.disk_free, d.disk_total, ks.total]);
patch('ovCards', sig, `
<div class="grid">
<div class="card"><span class="mut">曲库歌曲总数</span><b>${nfmt(st.songs)}</b><span class="mut">版本 ${esc(st.version)}${d.songs_removed_upstream?` · 上游已删除 ${d.songs_removed_upstream}`:''}</span></div>
<div class="card"><span class="mut">已就绪(本地有文件)</span><b style="color:var(--ok)">${nfmt(d.done)}</b><span class="mut">占用 ${bytes(d.done_bytes)}</span></div>
<div class="card"><span class="mut">待下载</span><b style="color:var(--warn)">${nfmt(d.pending)}</b><span class="mut">失败 ${nfmt(d.failed)} · 缺失 ${nfmt(d.missing)}</span></div>
<div class="card"><span class="mut">下载并发</span><b>${nfmt(pool.concurrency)}</b><span class="mut">活动 ${nfmt(pool.active_count)} 个(上限 10)</span></div>
<div class="card"><span class="mut">媒体文件</span><b>${nfmt(Math.max(0,d.media_files))}</b><span class="mut">目录 ${esc(d.config.media_dir)}</span></div>
<div class="card"><span class="mut">磁盘可用</span><b>${bytes(d.disk_free)}</b><span class="mut">共 ${bytes(d.disk_total)}</span></div>
</div>`);
}
async function ovSync(){
const s = await api('/api/library/status', {}, true);
const running=!!s.running, pct=Math.max(0, Math.min(100, Number(s.pct||0)));
const elapsed = running ? (Date.now()/1000 - (s.started_at||0))
: ((s.finished_at&&s.started_at) ? (s.finished_at-s.started_at) : 0);
const bar=$('#syncBar'); if(bar) bar.style.width = pct+'%';
const el=$('#syncElapsed');
if(el) el.textContent = running ? ('已用时 '+hms(elapsed)) : (elapsed ? ('上次用时 '+hms(elapsed)) : '');
const btn=$('#btnSync');
if(btn){ btn.disabled = running; btn.textContent = running ? '同步进行中…' : '同步曲库(官方源)'; }
const sig = JSON.stringify([s.text, pct, s.ok, s.error, running, Math.floor(elapsed/5), s.library&&s.library.songs]);
patch('syncText', sig, `${esc(s.text||'空闲')}${running?'…':''} <span class="mut">${pct}%</span>`
+ (s.library&&s.library.songs?` <span class="mut">· 曲库 ${nfmt(s.library.songs)} 首</span>`:''));
patch('syncExtra', sig, (s.ok===false && s.error) ? `<span style="color:var(--err)">${esc(s.error)}</span>`
: (running?'':''));
await syncTasksBlock('syncTasks');
}
async function syncTasksBlock(target){
const d = await api('/api/library/tasks?limit=10', {}, true);
const rows = (d.items||[]).map(t=>`<tr><td>${t.id}</td>
<td><span class="tag ${{done:'done',running:'downloading',failed:'failed',cancelled:'pending'}[t.state]||''}">${({done:'完成',running:'进行中',failed:'失败',cancelled:'已取消'})[t.state]||t.state}</span></td>
<td class="mut">${esc(t.old_version).slice(-12)}</td><td class="mut">${esc(t.new_version).slice(-12)}</td>
<td>${nfmt(t.total)}</td>
<td style="color:var(--ok)">+${t.added||0}</td>
<td style="color:var(--err)">-${t.removed||0}</td>
<td style="color:var(--warn)">~${t.changed||0}</td>
<td>${t.duration||0}s</td><td class="mut">${t.started_at?new Date(t.started_at*1000).toLocaleString():''}</td></tr>`).join('');
patch(target, JSON.stringify(d.items||[]), rows || '<tr><td colspan=10 class="mut">暂无同步任务</td></tr>');
}
// 维护状态码 → 中文(后端也带一份 state_text;这里再兜一层,页面离线改文案时不会显示英文码)
const MAINTAIN_CN={stopped:'已停止',idle:'运行中 · 空闲',
checking_identity:'运行中 · 检查设备身份',scanning:'运行中 · 扫描媒体目录',
retrying:'运行中 · 重试失败任务',downloading:'运行中 · 正在下载',error:'运行中 · 出错'};
async function ovBatch(){
const d = await api('/api/maintain/status', {}, true);
const want=d.keeps_want||0, done=d.keeps_done||0, failed=d.keeps_failed||0;
const total = d.keeps_total || (want+done+failed);
const pct = total ? (done*100/total) : 0;
const started = d.started_at ? (Date.now()/1000 - d.started_at) : 0;
const gained = Math.max(0, (d.done||0)-(d.baseline_done||0));
const rate = started>5 ? gained/started*3600 : 0; // 首/小时
const etaH = rate>0 ? ((d.keeps_remaining||0)/rate) : -1;
const lr = d.last_result||{};
const stateCn = MAINTAIN_CN[d.state] || d.state_text || d.state || '—';
const color = d.enabled ? (d.state==='error' ? 'var(--err)' : 'var(--ok)') : 'var(--mut)';
patch('ovBatchBody', JSON.stringify([d.state,d.enabled,lr.adopted,lr.requeued,lr.retried,lr.enqueued,
lr.done,lr.failed,lr.scanned,lr.updated,lr.scan_error,lr.retry_error,
lr.drain_timeout,d.cycle_count,pct,done,want,failed,total,
d.keeps_remaining,rate.toFixed(1),gained,d.pending,d.failed,d.library]),
`<div class="grid" style="margin-bottom:8px">
<div class="card"><span class="mut">状态</span><b style="font-size:15px;color:${color}">${esc(stateCn)}</b></div>
<div class="card"><span class="mut">上一轮结果</span><b style="font-size:15px">扫描登记 ${nfmt(lr.adopted)} · 缺失回队 ${nfmt(lr.requeued)} · 重试 ${nfmt(lr.retried)}</b><span class="mut">入队 ${nfmt(lr.enqueued)} · 完成 ${nfmt(lr.done)} · 失败 ${nfmt(lr.failed)}${lr.drain_timeout?' · 等待排空超时':''}${lr.scan_error?' · <span style="color:var(--err)">扫描失败</span>':''}${lr.retry_error?' · <span style="color:var(--err)">重试失败</span>':''}</span></div>
<div class="card"><span class="mut">歌曲表进度(已下 / 全部)</span><b>${pct.toFixed(2)}%</b><span class="mut">待下 ${nfmt(want)} · 已下 ${nfmt(done)} · 失败 ${nfmt(failed)}(共 ${nfmt(total)})</span></div>
<div class="card"><span class="mut">本批速度</span><b>${rate?rate.toFixed(0):'-'} <span class="mut">首/小时</span></b><span class="mut">已完成 ${nfmt(gained)} 首</span></div>
<div class="card"><span class="mut">预计剩余(按歌曲表剩余 ${nfmt(d.keeps_remaining)} 首)</span><b>${etaH>=0?(etaH<1?'不到 1 小时':(etaH/24).toFixed(1)+' 天'):'—'}</b></div>
<div class="card"><span class="mut">队列</span><b>${nfmt(d.pending)} <span class="mut">待下载</span></b><span class="mut">失败 ${nfmt(d.failed)}|曲库 ${nfmt(d.library)} 首(只补队歌曲表里的歌)</span></div>
</div>
<div class="bar"><i style="width:${Math.min(100,pct).toFixed(2)}%"></i></div>`);
}
async function ovStorage(){
const d = await api('/api/storage', {}, true);
const s=d.storage||{}, p=d.pool||{}, c=d.config||{};
const gb = n => (n<0? '未知' : (n/1e9).toFixed(1)+'GB');
patch('ovStorageBody', JSON.stringify([s.ok,s.reason,s.local_path,s.local_free,s.media_free,
p.rate_limit_mb_s,p.paused_by_space,s.checked_at,c.min_free_local_bytes,c.min_free_media_bytes]),
`<div class="grid" style="margin-bottom:8px">
<div class="card"><span class="mut">本地盘剩余(${esc(s.local_path||'/')})</span><b style="color:${s.ok?'var(--ok)':'var(--err)'}">${gb(s.local_free)}</b><span class="mut">下限 ${gb(c.min_free_local_bytes)}</span></div>
<div class="card"><span class="mut">云盘剩余(媒体所在)</span><b style="color:${s.ok?'var(--ok)':'var(--err)'}">${gb(s.media_free)}</b><span class="mut">下限 ${gb(c.min_free_media_bytes)}</span></div>
<div class="card"><span class="mut">媒体下载限速</span><b>${p.rate_limit_mb_s||'不限'} <span class="mut">MB/s</span></b><span class="mut">云盘上传 ${c.cloud_upload_mbps} Mbps</span></div>
<div class="card"><span class="mut">守卫状态</span><b style="font-size:15px;color:${s.ok?'var(--ok)':'var(--err)'}">${s.ok?'正常':'已暂停下载'}</b><span class="mut">${esc(s.reason||'')}${p.paused_by_space?'(空间原因暂停)':''}</span></div>
</div>
<div class="mut">检查时间:${s.checked_at?new Date(s.checked_at*1000).toLocaleTimeString():'-'} 
±提示:媒体会先写本地缓冲再由云盘客户端上传,限速应 ≤ 上传速度(50Mbps≈6.25MB/s,建议填 5)</div>`);
}
/** 合并卡片:一次拉两个接口(身份 + 心跳)、渲染进同一个块(局部刷新,不整页重绘)。 */
async function ovDeviceHeartbeat(){
const [w, d] = await Promise.all([api('/api/whoami', {}, true), api('/api/device', {}, true)]);
const id = d.identity||{}, ef = d.effective||{};
const ORIGIN_CN = {loaded:'复用已有身份文件', generated:'本次自动生成',
explicit:'配置里显式写死了 mac/sn(身份不生效)', disabled:'未启用自动身份(上报真实本机机型)',
invalid:'身份文件不自洽且未重建', none:'尚未生成'};
const srcCn = s => s==='explicit' ? '<span style="color:var(--warn)">配置覆盖</span>' : '自动身份';
// 心跳状态:是否在跑 + 上次上报时间/结果
const hbText = !w.enabled ? '<span style="color:var(--warn)">已关闭(whoami_enabled=false)</span>'
: (w.running ? '<span style="color:var(--ok)">运行中</span>' : '未运行(有歌要下时才启动)');
const when = w.last_at ? new Date(w.last_at*1000).toLocaleString() : '尚未上报';
const hbResult = w.last_at ? (w.last_ok ? '<span style="color:var(--ok)">上次成功</span>'
: '<span style="color:var(--err)">上次失败</span>')
: '';
const resp = String(w.last_response||'').slice(0,200);
const err = String(w.last_error||'');
patch('ovDeviceBody', JSON.stringify([w.enabled,w.running,w.sent,w.failed,w.last_at,w.last_ok,
w.last_http,w.last_elapsed_ms,w.last_next_interval,w.url,resp,err,
d.ok,d.origin,d.file,d.exists,id.mac,id.sn,id._m,id._manuf,
id._api,id.d_release,id.ktv_id,id.androidid,id._cid,
d.preset,d.label,ef,d.problems]),
`<div class="grid" style="margin-bottom:8px">
<div class="card"><span class="mut">MAC(上报值)</span><b style="font-size:15px"><code>${esc(ef.upstream_mac||id.mac||'')}</code></b><span class="mut">${srcCn(ef.mac_source)}</span></div>
<div class="card"><span class="mut">SN(上报值)</span><b style="font-size:15px"><code>${esc(ef.upstream_sn||id.sn||'')}</code></b><span class="mut">${srcCn(ef.sn_source)}</span></div>
<div class="card"><span class="mut">机型 / 厂商</span><b style="font-size:15px">${esc(id._m||'—')}</b><span class="mut">${esc(id._manuf||'')} · Android ${esc(id.d_release||'')}(_api=${esc(id._api||'')})</span></div>
<div class="card"><span class="mut">模板</span><b style="font-size:14px">${esc(d.label||'—')}</b><span class="mut">${esc(d.preset||'')}</span></div>
</div>
<div class="grid" style="margin-bottom:8px">
<div class="card"><span class="mut">心跳状态</span><b style="font-size:15px">${hbText}</b><span class="mut">${esc(when)}${hbResult?(' · '+hbResult):''}</span></div>
<div class="card"><span class="mut">成功 / 失败</span><b><span style="color:var(--ok)">${nfmt(w.sent)}</span> / <span style="color:var(--err)">${nfmt(w.failed)}</span></b><span class="mut">HTTP ${w.last_http||'-'} · 用时 ${w.last_elapsed_ms||0}ms</span></div>
<div class="card"><span class="mut">下次间隔</span><b>${w.last_next_interval||w.interval_sec||3600} <span class="mut">秒</span></b><span class="mut">取响应 next_interval(下限 60s / 上限 24h)</span></div>
<div class="card"><span class="mut">上报地址</span><b style="font-size:13px"><code>${esc(w.url||'')}</code></b></div>
</div>
<div class="mut">身份来源:${ORIGIN_CN[d.origin]||esc(d.origin||'—')} 文件:<code>${esc(d.file||'')}</code>${d.exists?'(已存在)':'(尚未创建)'}
| ktv_id=${esc(id.ktv_id||'')}|androidid=${esc(id.androidid||'')}|_cid=${esc(id._cid||'')}</div>
${err?`<div class="mut" style="color:var(--err)">最近错误:${esc(err)}</div>`:''}
${resp?`<div class="mut">最近响应:<code>${esc(resp)}</code></div>`:''}
${(d.problems&&d.problems.length)?`<div class="hint">⚠️ 自洽性问题:${esc(d.problems.join(';'))}</div>`:''}`);
}
/** 「重新生成」:可选模板(弹窗里列出内置模板),生成后由统一轮询把卡片刷新成新身份。 */
async function regenDevice(btn){
const d = await api('/api/device', {}, true).catch(()=>({}));
const list = (d.presets||[]).map(x=>`${x.preset} ${x.label}`).join('\n');
const preset = prompt('要使用的盒子模板(留空 = 随机):\n' + list, '');
if(preset===null) return;
return act(btn, '/api/device/regenerate', {preset:(preset||'').trim()}, {
loading:'重新生成中…', hint:'正在重新生成设备身份(mac/sn 会变,whoami 下一轮生效)',
done:r=>{ const i=(r&&r.identity)||{};
return `设备身份已重新生成:模板=${i.preset||''} mac=${i.mac||''} sn=${i.sn||''}`; }});
}
async function ovUpstream(){
const d = await api('/api/upstream/hosts', {}, true);
const cooling = (d.items||[]).map((x,i)=>[x.cooling?1:0,-x.score,i,x])
.sort((a,b)=>a[0]-b[0]||a[1]-b[1]||a[2]-b[2]).map(x=>x[3]);
const html = cooling.map((x,i)=>`<tr>
<td>${i+1}</td><td><code>${esc(x.host)}</code>${x.host===d.last_ok_host?' <span class="tag done">上次成功</span>':''}</td>
<td style="color:var(--ok)">${x.ok}</td><td style="color:var(--err)">${x.fail}</td><td>${x.score}</td>
<td>${x.cooldown_left?('<span class="tag failed">'+Math.ceil(x.cooldown_left/60)+' 分</span>'):'—'}</td>
<td class="mut">${esc(x.last_error).slice(0,40)}</td></tr>`).join('')
|| '<tr><td colspan=7 class="mut">未配置上游源(到设置页添加)</td></tr>';
patch('upstream', JSON.stringify([d.items,d.last_ok_host]), html);
}
// ── 曲库清单(只读参考:数据库同步出来的全量表,不能增删)──
let page=1, pageSize=50, libItems=[], libTotal=0;
function renderSongs(){
$('#tab-songs').innerHTML = `
<div class="card" style="margin-bottom:12px"><div class="mut">曲库清单由数据库同步自动更新,<b>只读参考</b>(不能在这里增删);要下载的歌请先「加入歌曲表」。</div></div>
<div class="row">
<input id="kw" placeholder="搜索 歌名 / 歌手 / 编号" style="min-width:240px">
<select id="lang"><option value="">全部语言</option></select>
<select id="state">
${['','none','pending','downloading','done','failed','missing'].map(s=>`<option value="${s}">${{'':'全部状态',none:'未入队',pending:'待下载',downloading:'下载中',done:'已就绪',failed:'失败',missing:'文件缺失'}[s]}</option>`).join('')}
</select>
<select id="order">${['hot','tid','name','lang'].map(o=>`<option value="${o}">按${orderName(o)}</option>`).join('')}</select>
<button class="btn" onclick="page=1;songsBlock()">查询</button>
<a class="btn ghost" href="/api/library/export.csv?token=${encodeURIComponent(token())}" style="text-decoration:none">导出 CSV</a>
<span class="mut" id="sTotal">加载中…</span>
<button class="btn ghost" onclick="goPage(-1)">上一页</button>
<button class="btn ghost" onclick="goPage(1)">下一页</button>
</div>
<div class="row">
<button class="btn" onclick="addChecked(this)">加入歌曲表(选中)</button>
<button class="btn ghost" onclick="addPage(this)">加入本页全部</button>
<button class="btn ghost" onclick="addFilter(this)">按当前筛选条件批量加入(可填数量)</button>
<span class="mut">加入后到「歌曲表」查看;服务器只按歌曲表下载/删文件</span>
</div>
<div class="tbl"><table><thead><tr>
<th style="width:36px"><input type="checkbox" onchange="document.querySelectorAll('.ck').forEach(c=>c.checked=this.checked)"></th>
<th>编号</th><th>歌名</th><th>歌手</th><th>语言</th><th>专辑</th><th>文件名</th><th>热度</th><th>状态</th><th>大小</th><th>错误</th>
</tr></thead><tbody id="sRows"><tr><td colspan=11 class="mut">加载中…</td></tr></tbody></table></div>`;
}
function goPage(d){ const np=page+d; if(np<1||(libTotal && np>Math.ceil(libTotal/pageSize))) return; page=np; songsBlock(); }
async function songsBlock(){
const kw=$('#kw')?.value||'', lang=$('#lang')?.value||'', state=$('#state')?.value||'', order=$('#order')?.value||'hot';
const [d, langs] = await Promise.all([
api(`/api/songs?page=${page}&page_size=${pageSize}&keyword=${encodeURIComponent(kw)}&lang=${encodeURIComponent(lang)}&state=${state}&order=${order}`, {}, true),
api('/api/languages', {}, true),
]);
libTotal = d.total||0;
libItems = (d.items||[]).map(x=>x.tid);
const pages = Math.max(1, Math.ceil(libTotal/pageSize));
if(page>pages){ page = pages; }
patch('sTotal', `${libTotal}|${d.page}|${pages}|${pageSize}`,
`共 ${nfmt(libTotal)} 首 · 第 ${d.page} / ${pages} 页`);
patch('lang', JSON.stringify(langs.items||[]),
['<option value="">全部语言</option>'].concat((langs.items||[]).map(x=>
`<option value="${esc(x.lang)}" ${x.lang===lang?'selected':''}>${esc(x.lang)} (${x.count})</option>`)).join(''));
if(document.querySelector('.ck:checked')) return; // 有勾选就不重画表格(免得把用户的选择冲掉)
const rows = (d.items||[]).map(it=>`<tr>
<td><input class="ck" type="checkbox" value="${it.tid}"></td>
<td>${it.tid}</td><td>${esc(it.name)}</td><td>${esc(it.singers)}</td><td>${esc(it.lang)}</td>
<td class="mut">${esc(it.album)}</td><td class="mut">${esc(it.filename)}</td>
<td>${it.hot_score}</td>
<td><span class="tag ${it.state}">${DL_CN[it.state]||it.state}</span></td>
<td>${it.bytes?bytes(it.bytes):''}</td><td class="mut">${esc(it.last_error).slice(0,40)}</td></tr>`).join('')
|| '<tr><td colspan=11 class="mut">没有符合条件的歌曲</td></tr>';
patch('sRows', JSON.stringify(d.items||[]), rows);
}
function checked(){ return [...document.querySelectorAll('.ck:checked')].map(c=>Number(c.value)); }
async function addChecked(btn){
const t=checked(); if(!t.length) return hint('先勾选歌曲', 'err');
const r = await act(btn, '/api/keeps/add', {tids:t}, {loading:'加入中…', hint:'正在加入歌曲表…',
okText:`已加入歌曲表 ${t.length} 首`});
if(r && r.ok !== false) hint(`已加入 ${r.added} 首(已在表内 ${r.skipped_existing},曲库可补全 ${r.matched},曲库暂无此编号 ${r.unmatched})`, 'ok');
}
async function addPage(btn){
if(!libItems.length) return hint('本页没有歌曲', 'err');
const r = await act(btn, '/api/keeps/add', {tids:libItems}, {loading:'加入中…', hint:'正在加入本页歌曲…'});
if(r && r.ok !== false) hint(`已加入歌曲表 ${r.added} 首(本页)`, 'ok');
}
async function addFilter(btn){
const kw=$('#kw')?.value||'', lang=$('#lang')?.value||'';
const n = prompt('按当前筛选条件加入歌曲表的数量(0 = 全部,69 万首请量力而行)', '0');
if(n===null) return;
const r = await act(btn, '/api/keeps/add', {keyword:kw,lang:lang,limit:Number(n)||0},
{loading:'加入中…', hint:'正在按筛选条件加入歌曲表…', timeout:20000});
if(r && r.ok !== false) hint(`已加入歌曲表 ${r.added} 首`, 'ok');
}
// ── 歌曲表(keeps:服务器只按它下载/删文件)──
let kPage=1, kPageSize=50, kItems=[], kTotal=0;
function renderKeeps(){
$('#tab-keeps').innerHTML = `
<div id="kCards"><div class="grid"><div class="card mut">加载中…</div></div></div>
<div class="row">
<input id="kpKw" placeholder="搜索 歌名 / 歌手 / 编号" style="min-width:240px">
<select id="kpLang"><option value="">全部语言</option></select>
<select id="kpState">${['','want','downloading','done','failed','unmatched','matched'].map(s=>`<option value="${s}">${s?(KEEP_CN[s]?('状态:'+KEEP_CN[s]):KEEP_FILTER_CN[s]):'全部状态'}</option>`).join('')}</select>
<select id="kpOrder">${['hot','tid','name','lang'].map(o=>`<option value="${o}">按${orderName(o)}</option>`).join('')}</select>
<button class="btn" onclick="kPage=1;keepsBlock()">查询</button>
<a class="btn ghost" href="/api/keeps/export.csv?token=${encodeURIComponent(token())}" style="text-decoration:none">导出 CSV</a>
<label class="btn ghost filebtn">导入 CSV<input type="file" accept=".csv,text/csv" style="display:none" onchange="importKeeps(this)"></label>
<select id="kpMode"><option value="merge">导入=合并</option><option value="replace">导入=整表替换</option></select>
<span class="mut">支持 UTF-8/GBK 等编码,逗号/分号/TAB 分隔均可(Excel 另存后可直接导入)</span>
<button class="btn ghost" onclick="showUnmatched()">查看曲库未匹配</button>
<button class="btn ghost" onclick="scanMedia(this)">扫描媒体目录</button>
<span class="mut" id="kPageInfo">加载中…</span>
<button class="btn ghost" onclick="kGoPage(-1)">上一页</button>
<button class="btn ghost" onclick="kGoPage(1)">下一页</button>
</div>
<div class="row" style="margin-top:0"><span class="mut" id="kScanLine">上次媒体扫描:加载中…</span></div>
<div class="row">
<input id="kpAdd" placeholder="编号(逗号/空格分隔)" style="min-width:220px">
<button class="btn" onclick="keepAddTids(this)">加入歌曲表</button>
<button class="btn ghost" onclick="keepRemoveChecked(this)">移除选中(连文件一起删)</button>
<button class="btn ghost" onclick="keepRemovePage(this)">移除本页全部</button>
<span class="mut">移除会删除本地 <code>&lt;编号&gt;.ts</code> 并清掉下载登记;媒体目录里发现的未知文件由「扫描媒体目录」<b>登记进歌曲表(绝不删除文件)</b>,文件缺失/过小的已下条目也会被它放回待下载</span>
</div>
<div class="tbl"><table><thead><tr>
<th style="width:36px"><input type="checkbox" onchange="document.querySelectorAll('.kck').forEach(c=>c.checked=this.checked)"></th>
<th>编号</th><th>歌名</th><th>歌手</th><th>语言</th><th>专辑</th><th>文件名</th><th>热度</th>
<th>状态</th><th>大小</th><th>尝试</th><th>备注</th><th>错误</th><th>操作</th>
</tr></thead><tbody id="kRows"><tr><td colspan=14 class="mut">加载中…</td></tr></tbody></table></div>`;
}
function kGoPage(d){ const np=kPage+d; if(np<1||(kTotal && np>Math.ceil(kTotal/kPageSize))) return; kPage=np; keepsBlock(); }
async function keepsBlock(){
const kw=$('#kpKw')?.value||'', lang=$('#kpLang')?.value||'', state=$('#kpState')?.value||'', order=$('#kpOrder')?.value||'hot';
const [d, langs, sc] = await Promise.all([
api(`/api/keeps?page=${kPage}&page_size=${kPageSize}&keyword=${encodeURIComponent(kw)}&lang=${encodeURIComponent(lang)}&state=${encodeURIComponent(state)}&order=${order}`, {}, true),
api('/api/languages', {}, true),
api('/api/keeps/scan/status', {}, true).catch(()=>({scan:{}})),
]);
const st = d.counts||{};
kTotal = d.total||0; kItems = (d.items||[]).map(x=>x.tid);
const pages = Math.max(1, Math.ceil(kTotal/kPageSize));
patch('kCards', JSON.stringify(st), `
<div class="grid">
<div class="card"><span class="mut">歌曲表总数</span><b>${nfmt(st.total)}</b><span class="mut">用户指定要维护的名单</span></div>
<div class="card"><span class="mut">已下</span><b style="color:var(--ok)">${nfmt(st.done)}</b><span class="mut">本地已有文件</span></div>
<div class="card"><span class="mut">待下</span><b style="color:var(--warn)">${nfmt(st.want)}</b><span class="mut">下载中 ${nfmt(st.downloading)}</span></div>
<div class="card"><span class="mut">失败</span><b style="color:var(--err)">${nfmt(st.failed)}</b><span class="mut">${nfmt(st.remaining)} 首未完成</span></div>
<div class="card"><span class="mut">曲库未匹配</span><b style="color:${st.unmatched?'var(--warn)':'inherit'}">${nfmt(st.unmatched)}</b><span class="mut">曲库同步后自动补全(已匹配 ${nfmt(st.matched)})</span></div>
<div class="card"><span class="mut">占用空间</span><b>${bytes(st.bytes)}</b><span class="mut">已下文件字节合计</span></div>
</div>`);
patch('kPageInfo', `${kTotal}|${d.page}|${pages}`, `共 ${nfmt(kTotal)} 首 · 第 ${d.page} / ${pages} 页`);
// 上次媒体扫描结果(需求:歌曲表页签显示一行:时间/新增/更新/回队/耗时/错误)
const s = sc.scan||{};
const sWhen = s.last_at ? new Date(s.last_at*1000).toLocaleString() : '尚未扫描';
let sline;
if(s.ok === false) sline = `<span class="hint">上次媒体扫描失败:${esc(s.error||'未知错误')}(${sWhen},未做任何改动)</span>`;
else if(s.ok === true) sline = `上次媒体扫描:${esc(sWhen)} · 新增登记 ${nfmt(s.adopted)} 首 · 更新 ${nfmt(s.updated)} 首 · 缺失回队 ${nfmt(s.requeued)} 首 · 跳过 ${nfmt(s.skipped)} 个 · 用时 ${nfmt(s.elapsed_ms)}ms`
+ ((s.adopted_sample&&s.adopted_sample.length)?` · 样例 ${s.adopted_sample.slice(0,5).join(', ')}`:'');
else sline = `上次媒体扫描:${s.enabled===false?'已关闭(media_scan_enabled=false)':`尚未扫描(每 ${nfmt(s.interval_sec)} 秒自动扫描)`}`;
patch('kScanLine', JSON.stringify(s), sline);
patch('kpLang', JSON.stringify(langs.items||[]),
['<option value="">全部语言</option>'].concat((langs.items||[]).map(x=>
`<option value="${esc(x.lang)}" ${x.lang===lang?'selected':''}>${esc(x.lang)} (${x.count})</option>`)).join(''));
if(document.querySelector('.kck:checked')) return; // 有勾选就不重画表格
const rows = (d.items||[]).map(it=>`<tr>
<td><input class="kck" type="checkbox" value="${it.tid}"></td>
<td>${it.tid}</td>
<td>${it.matched?esc(it.name):'<span class="tag" title="曲库里暂时没有这个编号,曲库同步后会自动补全">曲库外</span>'}</td>
<td>${esc(it.singers)}</td><td>${esc(it.lang)}</td>
<td class="mut">${esc(it.album)}</td><td class="mut">${esc(it.filename)}</td><td>${it.matched?it.hot_score:''}</td>
<td><span class="tag ${{want:'pending',downloading:'downloading',done:'done',failed:'failed'}[it.state]||''}">${KEEP_CN[it.state]||it.state}</span></td>
<td>${it.bytes?bytes(it.bytes):''}</td><td>${it.attempts||0}</td>
<td class="mut">${esc(it.note).slice(0,20)}${it.user?` <span class="mut">@${esc(it.user)}</span>`:''}</td>
<td class="mut">${esc(it.last_error).slice(0,40)}</td>
<td><button class="btn ghost mini" onclick="keepRemove(this,[${it.tid}])">移除</button></td></tr>`).join('')
|| '<tr><td colspan=14 class="mut">歌曲表还是空的:到「曲库清单」勾选后「加入歌曲表」,或直接导入只含「编号」的 CSV</td></tr>';
patch('kRows', JSON.stringify(d.items||[]), rows);
}
function kChecked(){ return [...document.querySelectorAll('.kck:checked')].map(c=>Number(c.value)); }
async function showUnmatched(){
const d = await api('/api/keeps/unmatched?limit=200', {}, true); // 只取前 200 个,避免大表把页面弹死
if(!d.total){ return alert('太好了,歌曲表里的编号都能在曲库匹配到'); }
const head = d.items.map(x=>x.tid).join(', ');
alert(`曲库里匹配不到的编号 ${d.total} 个(曲库同步后会自动补全)` + (d.total>200?`,下面只列前 200 个`:'') + `:\n` + head);
}
async function keepAddTids(btn){
const raw = ($('#kpAdd')?.value||'').split(/[,\s,、]+/).filter(Boolean).map(Number).filter(n=>Number.isFinite(n));
if(!raw.length) return hint('请填编号', 'err');
const r = await act(btn, '/api/keeps/add', {tids:raw}, {loading:'加入中…', hint:'正在加入歌曲表…'});
if(r && r.ok !== false){
if($('#kpAdd')) $('#kpAdd').value='';
hint(`已加入 ${r.added} 首(已在表内 ${r.skipped_existing},曲库可补全 ${r.matched},曲库暂无此编号 ${r.unmatched})`, 'ok');
}
}
async function keepRemove(btn, tids){
if(!tids || !tids.length) return hint('先勾选歌曲', 'err');
let st = {done:0, bytes:0};
try{ st = await api('/api/keeps/stats', {}, true); }catch(e){}
if(!confirm(`从歌曲表移除 ${tids.length} 首?\n本地已有的媒体文件会一起删除(当前已下 ${nfmt(st.done)} 首、占用 ${bytes(st.bytes)})。`)) return;
const r = await act(btn, '/api/keeps/remove', {tids:tids, delete_files:true},
{loading:'移除中…', hint:'正在移除并删除媒体文件…', timeout:20000});
if(r && r.ok !== false){
hint(`已移除 ${r.removed} 首,删除文件 ${r.deleted_files} 个,释放 ${bytes(r.freed_bytes)}`
+ (((r.errors&&r.errors.length))?(';问题:'+r.errors.join(';')):''), 'ok');
}
}
function keepRemoveChecked(btn){ return keepRemove(btn, kChecked()); }
function keepRemovePage(btn){ return keepRemove(btn, kItems); }
// 扫描媒体目录:登记/更新进歌曲表 + 把"done 但文件缺失/过小"回队(**绝不删除文件**)
async function scanMedia(btn){
return act(btn, '/api/keeps/scan', {}, {
loading:'扫描中…', hint:'正在扫描媒体目录(云盘/FUSE 上可能较慢,超时会自动放弃且不改状态)…',
timeout:20000,
done:x=> `媒体扫描完成:新增登记 ${x.adopted_count||((x.adopted||[]).length)||0} 首、更新 ${x.updated||0} 首、`
+ `缺失回队 ${x.requeued||0} 首、跳过 ${x.skipped||0} 个(用时 ${x.elapsed_ms||0}ms,绝不删除文件)`});
}
async function importKeeps(el){
const f = el.files && el.files[0]; if(!f) return;
const mode = $('#kpMode')?.value||'merge';
try{
// ⚠️ 必须读**原始字节**(arrayBuffer)直接 POST:Excel 中文版另存的 CSV 常是 GBK/GB18030,
// 用 f.text() 会按 UTF-8 解码成乱码(坏字节被替换掉就永久丢失),服务端再也认不出来。
const buf = await f.arrayBuffer();
if(!buf.byteLength){ hint('文件是空的,没有可导入的内容', 'err'); el.value=''; return; }
if(mode==='replace' && !confirm('整表替换:当前歌曲表会被清空(媒体文件不在这里删;要删本地文件请到歌曲表用「移除(连文件一起删)」;空 CSV 会被拒绝执行)。继续?')){ el.value=''; return; }
hint('正在导入 CSV…');
const r = await api(`/api/keeps/import?mode=${encodeURIComponent(mode)}`,{method:'POST',body:buf,raw:true}, true);
el.value='';
if(!r.ok){ hint('导入失败:'+(r.error||''), 'err'); return; }
const d = r.detected||{};
const dtxt = d.encoding ? `;识别到编码 ${d.encoding}${d.delimiter?('、分隔符 '+(d.delimiter==='\t'?'TAB':d.delimiter)):''}` : '';
hint(`导入完成(${r.mode}):写入 ${r.updated} 首、替换掉 ${r.removed} 首、跳过 ${r.skipped} 行、曲库可补全 ${r.matched}、曲库暂无此编号 ${r.unmatched}${dtxt}`
+ (((r.errors&&r.errors.length))?(';问题:'+r.errors.join(';')):''), r.replace_cancelled?'err':'ok');
kPage=1; tick(true);
}catch(e){ hint('导入失败:'+e.message, 'err'); el.value=''; }
}
// ── 下载队列 ──
async function loadTasks(){
$('#tab-tasks').innerHTML = `
<div class="card">
<div class="row" style="margin:0 0 8px"><b style="font-size:14px">曲库同步任务清单(每次同步的清单比对)</b></div>
<div class="tbl" style="max-height:26vh"><table><thead><tr><th>ID</th><th>状态</th><th>旧版本</th><th>新版本</th><th>总数</th><th>新增</th><th>上游删除</th><th>变更</th><th>耗时</th><th>时间</th></tr></thead>
<tbody id="syncTasksAll"><tr><td colspan=10 class="mut">加载中…</td></tr></tbody></table></div>
</div>
<div class="card" style="margin-top:12px">
<div class="row" style="margin:0 0 8px">
<button class="btn ghost" onclick="tick(true)">刷新</button>
<button class="btn ghost" onclick="act(this,'/api/download/retry_failed',{},{loading:'重试中…',hint:'已发起:失败任务重试',okText:'失败任务已重置为待下载'})">失败任务重试</button>
<button class="btn ghost" onclick="retryNow(this)">立即自动重试一次</button>
<button class="btn ghost" onclick="act(this,'/api/download/stop',{},{loading:'停止中…',hint:'已发起:停止下载池',okText:'下载池已停止'})">停止池</button>
<button class="btn ghost" onclick="act(this,'/api/download/start',{},{loading:'启动中…',hint:'已发起:启动下载池',okText:'下载池已启动'})">启动池</button>
<button class="btn ghost" onclick="act(this,'/api/library/sync',{},{loading:'同步已开始…',hint:'同步已开始,进度见概览与下方任务列表',okText:'同步已开始'})">同步曲库</button>
</div>
<div class="mut" id="retryLine" style="margin:0 0 8px">加载中…</div>
<div id="taskCards"><div class="grid"><div class="card mut">加载中…</div></div></div>
<div class="mut" style="margin:10px 0 6px">下载任务清单</div>
<div class="row" style="margin-top:0">
<select id="dtState" onchange="dlTasksBlock()">
${['','pending','downloading','done','failed','missing'].map(v=>`<option value="${v}">${{'':'全部','pending':'待下载','downloading':'下载中','done':'已就绪','failed':'失败','missing':'缺失'}[v]}</option>`).join('')}
</select>
</div>
<div class="tbl" style="max-height:30vh"><table><thead><tr><th>编号</th><th>歌名</th><th>状态</th><th>大小</th><th>尝试</th><th>源</th><th>错误</th></tr></thead>
<tbody id="dlTasks"><tr><td colspan=7 class="mut">加载中…</td></tr></tbody></table></div>
</div>
<div class="card" style="margin-top:12px"><div class="mut">正在下载</div>
<div class="tbl" style="max-height:34vh"><table><thead><tr><th>编号</th><th>歌名</th><th>进度</th></tr></thead>
<tbody id="activeRows"><tr><td colspan=3 class="mut">加载中…</td></tr></tbody></table></div></div>`;
loadSyncTasks('syncTasksAll'); // 模板就绪后再填一次数据(之后交给统一轮询)
}
// 兼容旧名:同步任务表的填充函数(必须先渲染模板,否则占位会被数据覆盖)
async function loadSyncTasks(target){ return syncTasksBlock(target); }
async function taskCards(){
const d = await api('/api/download/status', {}, true);
const s=d.stats||{}, p=d.pool||{};
patch('taskCards', JSON.stringify([p.active_count,p.concurrency,p.paused,s.pending,s.done,s.failed,s.missing,p.paused_by_space]),
`<div class="grid">
<div class="card"><span class="mut">活动下载</span><b>${nfmt(p.active_count)} / ${nfmt(p.concurrency)}</b><span class="mut">${p.paused?('已暂停'+(p.paused_by_space?'(空间原因)':'')):'运行中'}</span></div>
<div class="card"><span class="mut">待下载</span><b>${nfmt(s.pending)}</b></div>
<div class="card"><span class="mut">已就绪</span><b style="color:var(--ok)">${nfmt(s.done)}</b></div>
<div class="card"><span class="mut">失败 / 缺失</span><b style="color:var(--err)">${nfmt(s.failed)} / ${nfmt(s.missing)}</b></div>
</div>`);
const act_rows = (d.active||[]).map(a=>{const pct=a.expected_bytes?Math.min(100,Math.round(a.bytes*100/a.expected_bytes)):0;
return `<tr><td>${a.tid}</td><td>${esc(a.name)}</td><td>${bytes(a.bytes)} / ${bytes(a.expected_bytes)} <span class="mut">${pct}%</span></td></tr>`;}).join('')
|| '<tr><td colspan=3 class="mut">空闲</td></tr>';
patch('activeRows', JSON.stringify(d.active||[]), act_rows);
// 失败任务自动重试:一行摘要(需求:下载队列附近显示"失败任务每 10 分钟自动重试(上次重置 3 首)")
const ar = d.auto_retry||{};
const iv = Number(ar.interval_sec||0);
const per = (iv>=60 && iv%60===0) ? (iv/60)+' 分钟' : (iv>=60 ? (iv/60).toFixed(1)+' 分钟' : iv+' 秒');
const when = ar.last_at ? new Date(ar.last_at*1000).toLocaleString() : '尚未执行';
let rline;
if(ar.enabled===false) rline = '失败任务自动重试:<b>已关闭</b>(可在设置页打开 retry_failed_enabled)';
else rline = `失败任务每 ${per}自动重试(上次重置 ${nfmt(ar.last_count)} 首,累计 ${nfmt(ar.total_count)} 首;上次执行 ${esc(when)})`;
if(ar.skipped_reason) rline += ` <span class="hint">本次跳过:${esc(ar.skipped_reason)}</span>`;
patch('retryLine', JSON.stringify([ar.enabled,ar.interval_sec,ar.last_at,ar.last_count,ar.total_count,ar.skipped_reason]), rline);
}
// 立刻执行一次"失败任务自动重试"(遵守暂停/水位跳过条件;force=true 可强制)
function retryNow(btn){
return act(btn, '/api/download/retry_failed_now', {}, {
loading:'重试中…', hint:'已发起:立刻执行一次失败任务自动重试…',
done:r=> (r && r.skipped) ? ('本次跳过(未重置):'+(r.skipped_reason||''))
: ('已重置 '+((r&&r.reset)||0)+' 首失败任务为待下载')});
}
async function dlTasksBlock(){
const st = document.querySelector('#dtState')?.value ?? '';
const d = await api(`/api/download/tasks?state=${st}&page=1&page_size=100`, {}, true);
const rows = (d.items||[]).map(t=>`<tr><td>${t.tid}</td><td>${esc(t.name)}</td>
<td><span class="tag ${{done:'done',failed:'failed',pending:'pending',downloading:'downloading',missing:'missing'}[t.state]||''}">${({done:'已就绪',failed:'失败',pending:'待下载',downloading:'下载中',missing:'缺失'})[t.state]||t.state}</span></td>
<td>${bytes(t.bytes)}</td><td>${t.attempts}</td><td class="mut">${esc(t.source_host)}</td>
<td class="mut">${esc(t.last_error).slice(0,40)}</td></tr>`).join('')
|| '<tr><td colspan=7 class="mut">无任务</td></tr>';
patch('dlTasks', JSON.stringify([st,d.items||[]]), rows);
}
// ── 设置 ──
function renderSettings(){ loadSettings(); }
/** 上一次 /api/settings 的返回值:保存时用它判断"用户到底改没改"(见 saveSettings)。 */
let settingsCache = null;
async function loadSettings(){
const [c, dev] = await Promise.all([
api('/api/settings', {}, true),
api('/api/device', {}, true).catch(()=>({presets:[]})),
]);
settingsCache = c;
const presets = dev.presets||[];
const f = (k,label,hint='') => `<label class="f">${label}${hint?` <span class="mut">${hint}</span>`:''}</label><input id="s_${k}" value="${esc(Array.isArray(c[k])?c[k].join(','):c[k])}" style="width:100%">`;
$('#tab-settings').innerHTML = `
<div class="card" style="max-width:900px">
<div class="mut">改完点保存即生效(写入 server-config.json)。<b>并发数上限硬编码为 10。</b></div>
${f('upstream_hosts','上游歌曲源(逗号分隔,按序尝试)','形如 http://zk.ktv1.top:6677')}
${f('public_media_base','对 App 暴露的媒体地址前缀','必须是域名!如 http://ktv.example.com:6677/media')}
${f('upstream_mac','上报给上游的 MAC','留空 = 跟随自动生成的「安卓盒子身份」;填了就是显式配置,优先级最高')}
${f('upstream_sn','上报给上游的 SN','留空 = 跟随自动生成的「安卓盒子身份」')}
<label class="f">设备身份(伪装为安卓盒子):自动生成/载入</label>
<select id="s_device_identity_auto" style="width:100%">
<option value="true" ${c.device_identity_auto?'selected':''}>开启(默认,首次自动生成并落盘,之后固定不变)</option>
<option value="false" ${!c.device_identity_auto?'selected':''}>关闭(上报真实本机机型,仅排错用)</option>
</select>
${f('device_identity_file','设备身份文件','留空 = &lt;数据目录&gt;/device-identity.json;内容含 mac/sn,按凭据对待(0600)')}
<label class="f">设备身份模板 <span class="mut">改这里会**立刻**按新模板重新生成一份身份(mac/sn 随之变化)</span></label>
<select id="s_device_identity_preset" style="width:100%">
<option value="">随机模板(默认)</option>
${presets.map(x=>`<option value="${esc(x.preset)}" ${x.preset===c.device_identity_preset?'selected':''}>${esc(x.preset)} — ${esc(x.label)}</option>`).join('')}
</select>
${f('local_space_path','本地盘路径(上传缓冲所在)','默认 /')}
${f('min_free_local_bytes','本地剩余下限(字节)','低于则暂停,默认 20GiB')}
${f('min_free_media_bytes','云盘剩余下限(字节)','默认 20GiB')}
${f('resume_free_margin_bytes','恢复余量(字节)','迟滞,默认 5GiB')}
${f('media_rate_limit_mb_s','媒体下载限速(MB/s)','务必 ≤ 云盘上传速度;50Mbps≈5')}
${f('cloud_upload_mbps','云盘上传(Mbps)','仅提示用')}
${f('upstream_resolution','请求分辨率','720')}
${f('upstream_interval_min_ms','取链最小间隔(ms)','默认 5000。目的:<b>避免被上游判定为攻击</b>(风控/封禁),建议 5000~10000')}
${f('upstream_interval_max_ms','取链最大间隔(ms)','与最小值之间随机取值,避免节奏固定;默认 10000(全局串行,10 并发也不叠加)')}
${f('concurrency','下载并发数','1~10')}
<label class="f">下载排序</label><select id="s_download_order" style="width:100%">
${['hot','tid','name','lang'].map(o=>`<option value="${o}" ${o===c.download_order?'selected':''}>按${orderName(o)}</option>`).join('')}</select>
<label class="f">排序方向</label><select id="s_download_order_desc" style="width:100%">
<option value="true" ${c.download_order_desc?'selected':''}>降序(热度高的先下)</option>
<option value="false" ${!c.download_order_desc?'selected':''}>升序</option></select>
${f('media_dir','媒体落地目录')}${f('data_dir','数据目录')}${f('muse_device_id','曲库同步设备号')}
${f('muse_auto_sync_hours','定时同步曲库(小时,0=关)')}
${f('stats_cache_sec','概览统计缓存(秒)','0=不缓存;默认 4,库很大时让 /api/stats 保持 <300ms')}
<label class="f">媒体目录扫描:开关 <span class="mut">发现 <code>&lt;编号&gt;.ts</code> 就登记进歌曲表(<b>只增不删</b>),并把"done 但文件缺失/过小"的条目放回待下载</span></label>
<select id="s_media_scan_enabled" style="width:100%">
<option value="true" ${c.media_scan_enabled?'selected':''}>开启(默认,守护线程每 10 分钟扫一次)</option>
<option value="false" ${!c.media_scan_enabled?'selected':''}>关闭</option></select>
${f('media_scan_interval_sec','媒体扫描间隔(秒)','0=关闭;默认 600')}
${f('media_scan_min_bytes','媒体扫描最小字节数','默认沿用 min_file_bytes;填 0 = 跟随 min_file_bytes')}
${f('media_scan_timeout_sec','媒体扫描超时(秒)','默认 30;云盘/FUSE 列举超时就放弃本次且不改状态')}
<label class="f">下载队列:失败任务自动重试 <span class="mut">到点把 failed/missing 重置为待下载;人工暂停或水位不足时跳过</span></label>
<select id="s_retry_failed_enabled" style="width:100%">
<option value="true" ${c.retry_failed_enabled?'selected':''}>开启(默认,每 10 分钟)</option>
<option value="false" ${!c.retry_failed_enabled?'selected':''}>关闭</option></select>
${f('retry_failed_interval_sec','自动重试间隔(秒)','默认 600(10 分钟);0=关闭')}
${f('retry_failed_max_attempts','自动重试上限','默认沿用 max_attempts;填 0 = 跟随 max_attempts')}
<label class="f">App 点歌推送 <span class="mut">App 点歌拿到下载地址后 POST <code>/push_download</code>:服务器先校验闸门、再去重,无重复才入队(详情见 README §5.9)</span></label>
${f('push_token','推送鉴权串','默认 local;App 侧 pushToken 必须一致。留空 = 回落到 local(不会变成谁都能推)')}
${f('push_rate_limit_per_min','推送限流(条/分钟/来源IP)','默认 60;0 = 关闭限流')}
${f('push_dedupe_sec','推送去重窗口(秒)','默认 300;同一编号在该窗口内重复推送直接跳过(不重复下载)')}
<label class="f">歌曲表自动维护:服务启动时自动运行 <span class="mut">默认否(要人在概览页点「启动」);开启后启动即开始维护</span></label>
<select id="s_maintain_enabled" style="width:100%">
<option value="false" ${!c.maintain_enabled?'selected':''}>不自动(默认,界面点「启动」)</option>
<option value="true" ${c.maintain_enabled?'selected':''}>自动(服务启动即维护)</option></select>
${f('maintain_interval_sec','维护间隔(秒)','默认 600(10 分钟)一轮;下限 30')}
<label class="f">每轮扫描媒体目录并登记新文件 <span class="mut">同时把已下但文件缺失/过小的条目放回待下载;云盘/FUSE 上列举超时会放弃本次且不改状态</span></label>
<select id="s_maintain_scan_media" style="width:100%">
<option value="true" ${c.maintain_scan_media?'selected':''}>开启(默认)</option>
<option value="false" ${!c.maintain_scan_media?'selected':''}>关闭</option></select>
<label class="f">每轮重试失败任务 <span class="mut">failed/missing 重置为待下载</span></label>
<select id="s_maintain_retry_failed" style="width:100%">
<option value="true" ${c.maintain_retry_failed?'selected':''}>开启(默认)</option>
<option value="false" ${!c.maintain_retry_failed?'selected':''}>关闭</option></select>
<label class="f">有歌要下时启停心跳上报 <span class="mut">排空后自动停;<code>whoami_enabled=false</code> 时任何情况都不发</span></label>
<select id="s_maintain_heartbeat" style="width:100%">
<option value="true" ${c.maintain_heartbeat?'selected':''}>开启(默认)</option>
<option value="false" ${!c.maintain_heartbeat?'selected':''}>关闭</option></select>
${f('maintain_drain_timeout_sec','单轮等待排空超时(秒)','默认 3600;超时记入「上一轮结果」并进入下一轮')}
${f('device_identity_ttl_hours','盒子身份有效期(小时)','默认 24;超期由维护任务自动重新生成身份(mac/sn 会变);填 0 = 不检查')}
<label class="f">反向代理(IIS/ARR):采信 X-Forwarded-* 头 <span class="mut">反代部署时必须打开(否则推送限流会让所有设备共用代理地址的一份额度、日志里看不到设备 IP、HTTPS 下 do.php 会返回 http 内网地址)。<b>只有直连对端命中下面列表时才采信</b>;默认关闭。完整交付物见 <code>deploy/iis/</code></span></label>
<select id="s_trust_proxy_headers" style="width:100%">
<option value="false" ${!c.trust_proxy_headers?'selected':''}>关闭(默认,一律用 TCP 对端地址)</option>
<option value="true" ${c.trust_proxy_headers?'selected':''}>开启(按可信代理列表采信代理头)</option></select>
${f('trusted_proxies','可信代理(IP/CIDR,逗号分隔)','如 127.0.0.1,192.168.100.0/24;留空 = 一个都不信(即使开关打开)')}
${f('http_backlog','监听队列长度(http_backlog)','默认 128;IIS 探活 + 设备突发请求时太小会直接拒连')}
${f('max_body_bytes','请求体上限(字节)','默认 8388608(8MiB);超限返回 413。IIS 侧 maxAllowedContentLength 要 ≥ 它')}
<label class="f">双站点反代:媒体站域名 <span class="mut">如 <code>md.kasugano.cn</code>(填域名即可,<b>端口不用写</b>:<code>X-Forwarded-Host</code> 可能带 <code>:4433</code>,判定对端口容错)。<b>留空 = 不校验宿主</b>(单站点部署行为不变);填了之后:媒体宿主只能下媒体文件、其它路径 404</span></label>
${f('media_hosts','媒体宿主(逗号分隔)','如 md.kasugano.cn;非空才启用宿主隔离(详见 README「双站点反代」)')}
${f('api_hosts','API 宿主(逗号分隔)','如 mdmuse.kasugano.cn;填了之后非媒体宿主请求 /media/* 一律 404')}
<label class="f">App 令牌(<code>GET /token</code>)<span class="mut">未来的自有 App:先取令牌 → 带请求头 <code>X-App-Token</code> 调 do.php / push_download。<b>默认不强制</b>:已发布的官方 App 不带令牌,照常可用(红线)</span></label>
${f('app_token_ttl_sec','令牌有效期(秒)','默认 600(10 分钟);钳制 30~86400')}
${f('app_token_paths','受管路径前缀(逗号分隔)','默认 /music/do.php,/push_download;/i.php 默认只"带了就校验"、不强制')}
${f('token_rate_limit_per_min','令牌签发限流(个/分钟/来源IP)','默认 60;0 = 关闭限流')}
<label class="f">令牌绑定签发时的客户端 IP <span class="mut">换 IP 用同一令牌一律 401;反代场景靠 <code>trusted_proxies</code> 取真实 IP</span></label>
<select id="s_app_token_bind_ip" style="width:100%">
<option value="true" ${c.app_token_bind_ip?'selected':''}>开启(默认)</option>
<option value="false" ${!c.app_token_bind_ip?'selected':''}>关闭(不绑 IP)</option></select>
<label class="f">令牌单次有效 <span class="mut">用过即失效;<b>媒体地址是多次调用,默认必须关</b></span></label>
<select id="s_app_token_single_use" style="width:100%">
<option value="false" ${!c.app_token_single_use?'selected':''}>关闭(默认,可多次使用)</option>
<option value="true" ${c.app_token_single_use?'selected':''}>开启(用过即失效)</option></select>
<label class="f">令牌落库(<code>meta</code> 表)<span class="mut">开启后重启仍有效(整表一行,不是每个令牌写一次库);默认纯内存</span></label>
<select id="s_app_token_persist" style="width:100%">
<option value="false" ${!c.app_token_persist?'selected':''}>关闭(默认,纯内存、重启失效)</option>
<option value="true" ${c.app_token_persist?'selected':''}>开启(落 meta 表)</option></select>
<label class="f">强制令牌(<code>app_token_enforce</code>) <span class="mut"><b>先保持关闭</b>;自有 App 上线且确认所有客户端都会先取令牌后再打开 —— 打开后受管路径没令牌一律 401(含 <code>/push_download/status</code>)</span></label>
<select id="s_app_token_enforce" style="width:100%">
<option value="false" ${!c.app_token_enforce?'selected':''}>关闭(默认:老 App 不带令牌照常可用;带了错令牌仍会 401)</option>
<option value="true" ${c.app_token_enforce?'selected':''}>开启(受管路径必须带令牌)</option></select>
${f('admin_token','管理令牌(非空则接口需令牌)')}
${f('service_port','服务端口')}${f('admin_port','管理端口')}
<div class="row"><button class="btn" onclick="saveSettings(this)">保存</button></div>
<div id="setmsg" class="mut"></div>
</div>`;
}
async function saveSettings(btn){
const keys=['upstream_hosts','public_media_base','upstream_mac','upstream_sn','upstream_resolution',
'device_identity_auto','device_identity_file','device_identity_preset',
'local_space_path','min_free_local_bytes','min_free_media_bytes','resume_free_margin_bytes',
'media_rate_limit_mb_s','cloud_upload_mbps',
'upstream_interval_min_ms','upstream_interval_max_ms',
'concurrency','download_order','download_order_desc',
'media_dir','data_dir','muse_device_id','muse_auto_sync_hours','admin_token','service_port','admin_port',
'media_scan_enabled','media_scan_interval_sec','media_scan_min_bytes','media_scan_timeout_sec',
'retry_failed_enabled','retry_failed_interval_sec','retry_failed_max_attempts',
'push_token','push_rate_limit_per_min','push_dedupe_sec',
'maintain_enabled','maintain_interval_sec','maintain_scan_media','maintain_retry_failed',
'maintain_heartbeat','maintain_drain_timeout_sec','device_identity_ttl_hours',
'trust_proxy_headers','trusted_proxies','http_backlog','max_body_bytes',
'media_hosts','api_hosts',
'app_token_ttl_sec','app_token_paths','token_rate_limit_per_min',
'app_token_bind_ip','app_token_single_use','app_token_persist','app_token_enforce',
'stats_cache_sec'];
const body={};
keys.forEach(k=>{ const el=$('#s_'+k); if(!el) return; let v=el.value;
if(k==='upstream_hosts') v=v.split(',').map(s=>s.trim()).filter(Boolean);
else if(k==='trusted_proxies') v=v.split(',').map(s=>s.trim()).filter(Boolean);
else if(k==='media_hosts') v=v.split(',').map(s=>s.trim()).filter(Boolean);
else if(k==='api_hosts') v=v.split(',').map(s=>s.trim()).filter(Boolean);
else if(k==='app_token_paths') v=v.split(',').map(s=>s.trim()).filter(Boolean);
else if(['concurrency','upstream_interval_min_ms','upstream_interval_max_ms','muse_auto_sync_hours','service_port','admin_port','min_free_local_bytes','min_free_media_bytes','resume_free_margin_bytes','cloud_upload_mbps','stats_cache_sec','media_scan_interval_sec','media_scan_min_bytes','retry_failed_interval_sec','retry_failed_max_attempts','push_rate_limit_per_min','push_dedupe_sec','maintain_interval_sec','maintain_drain_timeout_sec','device_identity_ttl_hours','http_backlog','max_body_bytes','app_token_ttl_sec','token_rate_limit_per_min'].includes(k)) v=Number(v);
else if(k==='download_order_desc'||k==='device_identity_auto'||k==='media_scan_enabled'||k==='retry_failed_enabled'||k==='maintain_enabled'||k==='maintain_scan_media'||k==='maintain_retry_failed'||k==='maintain_heartbeat'||k==='trust_proxy_headers'||k==='app_token_bind_ip'||k==='app_token_single_use'||k==='app_token_persist'||k==='app_token_enforce') v=(v==='true');
else if(k==='media_rate_limit_mb_s'||k==='media_scan_timeout_sec') v=Number(v);
body[k]=v; });
// ⚠️ upstream_mac/upstream_sn 的输入框显示的是**运行期生效值**(可能是自动身份填进去的)。
// 用户没改动就不要把这两个键发上去 —— 一发就会被当成"用户显式配置"落盘,
// 之后「重新生成设备身份」反而覆盖不掉它(自动生成的值把自己锁死了)。
['upstream_mac','upstream_sn'].forEach(k=>{
const el=$('#s_'+k); if(el && settingsCache && el.value===String(settingsCache[k]||'')) delete body[k];
});
if(btn){ btn.disabled=true; }
try{
const r = await api('/api/settings',{method:'POST',body});
$('#setmsg').innerHTML = (r.warnings||[]).map(w=>`<div class="hint">⚠️ ${esc(w)}</div>`).join('') || '✅ 已保存';
hint('设置已保存', 'ok');
}catch(e){ hint('保存失败:'+e.message, 'err'); }
finally{ if(btn) btn.disabled=false; }
}
// ── 日志 ──
function renderLogs(){
$('#tab-logs').innerHTML = `<div class="row"><button class="btn ghost" onclick="tick(true)">刷新</button>
<span class="mut" id="logCount"></span></div><pre id="logPre">加载中…</pre>`;
}
async function logsBlock(){
const d = await api('/api/events?limit=300', {}, true);
const items = d.items||[];
patch('logCount', String(items.length), `最近 ${items.length} 条`);
patch('logPre', JSON.stringify(items), items.map(e=>
`${new Date(e.ts*1000).toLocaleString()} [${e.level}] ${esc(e.text)}`).join('\n') || '(暂无日志)');
}
// ── 其它动作 ──
function cancelSync(btn){
return act(btn, '/api/library/cancel', {}, {loading:'取消中…', hint:'已请求取消(当前阶段结束后停止)',
okText:'已请求取消(当前阶段结束后停止)'});
}
function maintainAction(btn, a){
const load={start:'启动中…',stop:'停止中…',restart:'重启中…',run_now:'已发起…'}[a]||'执行中…';
const ok={start:'自动维护已启动(立即开始第一轮)',stop:'自动维护已停止(心跳也停了)',
restart:'已重启:立即开始新一轮',run_now:'已发起:立即维护一次(不影响排程)'}[a]||'已下发';
return act(btn, '/api/maintain/'+a, {}, {loading:load, hint:'已发起:歌曲表自动维护', okText:ok});
}
function probeUpstream(btn){
return act(btn, '/api/upstream/probe', {}, {loading:'探测中…', timeout:30000,
hint:'正在探测上游源(要联网,稍等)…',
done:r=>'探测完成:'+(r.items||[]).map(x=>`${x.host} token=${x.token} api.php=${x.has_api_php?'有':'无'} ${x.ms}ms`).join(';')});
}
// 首屏:概览骨架 + 一次刷新(之后交给统一轮询)
renderOverview();
$('#autoRefresh').onchange = ()=>{ if($('#autoRefresh').checked) tick(true); };
tick(true);
</script>
</body></html>
"""
def build_router() -> web.Router:
r = web.Router()
@r.get("/")
def index(req: web.Request):
return web.html_response(PAGE)
@r.get("/ui")
def ui(req: web.Request):
return web.html_response(PAGE)
return r