Files
maidong-ktv/selfhost-source/server/ktv/ui.py
T
zijunchiang 87aa979abd 合并自建歌曲源支线(selfhost-source)+ 版本号按约定修正为 1.6.0
版本号:
- 1.5.11 越界(规则 3 规定 patch ≤ 10)→ 修正为 1.6.0(进位到 minor、patch 归 0)
- versionCode 43 不变:43 从未对外发布,本次修正只是把命名拉回约定

合并支线(原在工作区根目录、App 仓库之外,DESIGN.md 自称"支线"):
- selfhost-source/server/   自建歌曲源服务器(纯 Python 标准库、零 pip 依赖)
- selfhost-source/deploy/、DESIGN.md、LIBRARY.md、build_inventory.py
- selfhost-source/inventory/ 小清单(playlists.csv、song-stats.md 等)
- ⚠️ inventory 生成物(songs-*.csv / singers-top.csv,共 84MB)不入库,
  已写入 .gitignore;需要时用 build_inventory.py 依据 muse.db 重新生成

同时入库的 App 侧支线改动(此前为工作树未提交状态):
- Whoami.kt(whoami 设备心跳)、DeviceIdentity.kt(设备身份伪装)
- PushDownload.kt / PushDownloadClient.kt(点歌推送)
- AdVideoFilter 带 query/fragment 的地址闸门;KTV_BRIDGE_API 11 → 13
- 修 ktv_api.js 的 global.KtvBridgeApiVersion(12 → 13,与注释及 Kotlin 两处校验一致)
- 对应单测:WhoamiTest / DeviceIdentityTest / PushDownloadTest / AdVideoFilterTest(共 39 例)

文档:
- DESIGN.md 的"在 App 仓库之外"约定改为"已并入仓库统一版本化"
- AGENTS.md 版本号行更新为 1.6.0/43,并新增支线并入说明

验证:assembleRelease + testReleaseUnitTest 全过(39 例)
2026-10-14 02:06:28 +08:00

940 lines
73 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""管理界面:单页 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