Files
StarlightRadio/public/js/app.js
T
Xiao-no-love f0fe50e484 feat: StarLight 第三方登录 + 安全加固 + 循环池 + 热配置
功能:
- StarLight Yggdrasil 第三方登录(后端中转,accessToken 不落地)
- 管理员可视化权限管理(用户列表 + 一键授予/移出)
- 设置页热配置(11 项可编辑,立即生效)
- 循环池(每流独立手动歌单,队列空时随机不重复抽取)
- 登录页统一主站浅色风格
- 全站改名「Starlight专属电台」
- 删除关于页

安全:
- 全局 CSRF 防护(双提交 Cookie)
- 登录限流防 XFF 伪造、错误信息脱敏
- 权限中间件 requireAdminForWrites(读放行/写 admin)
- 密码哈希存储(scrypt/sha256)
- SSRF 防护(封面下载域名白名单)
- 空 catch 加日志、SSE 连接上限

修复:
- 多角色账号 selectedProfile 缺失
- 普通用户越权删歌(V4)
- streams 写操作越权(V9)
- 设置页布局溢出

测试:38 项全绿(api16 / e2e17 / write5)
2026-10-06 16:37:03 +08:00

1087 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
Starlight专属电台 — app.js (v2)
多路流 + 鉴权 + 删除/导入 + 封面本地化
============================================================ */
// ==================== 全局状态 ====================
let currentStreamId = "main";
let liveStreamUrl = "/hls/main/index.m3u8";
const state = { streams: [], resources: [], playlist: [] };
// ==================== Toast ====================
function showToast(msg, type = "") {
const c = document.getElementById("toastContainer");
const el = document.createElement("div");
el.className = "toast-msg" + (type ? " " + type : "");
el.textContent = msg;
c.appendChild(el);
setTimeout(() => el.remove(), 3000);
}
// ==================== Helpers ====================
function esc(s) {
const AMP = String.fromCharCode(38);
const map = { "&": AMP + "amp;", "<": AMP + "lt;", ">": AMP + "gt;", '"': AMP + "quot;", "'": AMP + "#39;" };
return String(s ?? "").replace(/[&<>"']/g, (c) => map[c]);
}
function formatDuration(sec) {
if (!sec && sec !== 0) return "--:--";
const s = Math.round(sec), m = Math.floor(s / 60), ss = s % 60;
return m + ":" + String(ss).padStart(2, "0");
}
function formatBytes(b) {
if (!b) return "0 B";
if (b >= 1048576) return (b / 1048576).toFixed(2) + " MB";
if (b >= 1024) return (b / 1024).toFixed(1) + " KB";
return b + " B";
}
// 封面:远程 URL 优先,失败自动回退本地
function coverSrcAttr(item) {
const remote = item.albumImage || "";
const local = item.coverLocal || "";
if (remote && local && remote !== local) {
return `src="${esc(remote)}" data-local="${esc(local)}" onerror="this.onerror=null;this.src=this.dataset.local;"`;
}
return `src="${esc(remote || local)}"`;
}
// ==================== API(统一带 cookie,401 跳登录) ====================
async function api(url, options = {}) {
const opts = { credentials: "same-origin", ...options };
// 写操作带 CSRF 头(双提交 Cookie)
const method = (opts.method || "GET").toUpperCase();
if (!["GET", "HEAD", "OPTIONS"].includes(method)) {
const m = document.cookie.match(/(?:^|;\s*)mr_csrf=([^;]+)/);
if (m) {
opts.headers = { ...(opts.headers || {}), "X-CSRF-Token": decodeURIComponent(m[1]) };
}
}
const res = await fetch(url, opts);
if (res.status === 401) {
window.location.href = "/login";
throw new Error("未登录");
}
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch (_) { data = null; }
if (!res.ok) throw new Error((data && data.error) || text || `HTTP ${res.status}`);
return data;
}
// 角色 UI:非管理员隐藏 设置页 + 本地导入
function applyRoleUI(role, starlightName) {
const isAdmin = role === "admin";
const tab = document.querySelector('.nav-tab[data-page="pageSettings"]');
if (tab) tab.style.display = isAdmin ? "" : "none";
const imp = document.getElementById("importCard");
if (imp) imp.style.display = isAdmin ? "" : "none";
const un = document.getElementById("userName");
if (un) {
if (starlightName) {
un.textContent = "你好," + starlightName;
un.title = "StarLight 用户" + (isAdmin ? "(管理员)" : "");
un.style.display = "";
} else {
un.textContent = "管理员";
un.title = "应急密码登录";
un.style.display = "";
}
}
}
function post(url, body) {
return api(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
});
}
// ==================== Navigation ====================
const tabs = document.querySelectorAll(".nav-tab");
const pages = document.querySelectorAll(".page");
const pageOrder = ["pageLive", "pageSearch", "pageResources", "pagePlaylist", "pageSettings"];
function switchPage(pageId) {
pages.forEach((p) => p.classList.remove("active"));
tabs.forEach((t) => t.classList.remove("active"));
const page = document.getElementById(pageId);
if (page) page.classList.add("active");
tabs.forEach((t) => { if (t.dataset.page === pageId) t.classList.add("active"); });
if (pageId === "pageLive") { updateLiveUI(); loadLiveCover(); }
if (pageId === "pageSettings") loadSettings();
if (pageId === "pageResources") loadResources();
if (pageId === "pagePlaylist") loadPlaylist();
}
tabs.forEach((tab) => tab.addEventListener("click", () => switchPage(tab.dataset.page)));
// ==================== ID Extraction ====================
function extractMusicId(input) {
input = input.trim();
if (!input) return "";
if (/^\d+$/.test(input)) return input;
const patterns = [
/music\.163\.com\/#\/song\?.*id=(\d+)/,
/music\.163\.com\/song\?.*id=(\d+)/,
/music\.163\.com\/song\/(\d+)/,
/y\.music\.163\.com\/m\/song\?.*id=(\d+)/,
/[?&]id=(\d+)/,
/(\d{5,})/,
];
for (const p of patterns) { const m = input.match(p); if (m) return m[1]; }
return input;
}
// ==================== Streams(多路流) ====================
async function loadStreams() {
try {
const r = await api("/api/streams");
state.streams = r.list || [];
if (!state.streams.some((s) => s.id === currentStreamId)) {
currentStreamId = state.streams[0] ? state.streams[0].id : "main";
}
const cur = state.streams.find((s) => s.id === currentStreamId);
const nameEl = document.getElementById("currentChannelName");
if (nameEl) nameEl.textContent = cur ? (cur.name || cur.id) : "主电台";
liveStreamUrl = cur ? cur.m3u8 : "/hls/main/index.m3u8";
updateLiveUI();
} catch (e) { console.error("加载流失败", e); }
}
document.getElementById("liveLink").addEventListener("click", async () => {
const url = new URL(liveStreamUrl, location.origin).href;
try {
await navigator.clipboard.writeText(url);
showToast("已复制直播流链接", "success");
} catch (err) {
const ta = document.createElement("textarea");
ta.value = url;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); showToast("已复制直播流链接", "success"); }
catch (e2) { showToast("复制失败,请手动复制: " + url, "error"); }
ta.remove();
}
});
document.getElementById("btnLogout").addEventListener("click", async () => {
await post("/api/auth/logout").catch(() => {});
window.location.href = "/login";
});
// ==================== 频道管理 ====================
const streamModal = document.getElementById("streamModal");
function openStreamManager() {
renderStreamMgr();
streamModal.classList.add("show");
}
function closeStreamManager() {
streamModal.classList.remove("show");
}
function renderStreamMgr() {
const box = document.getElementById("streamMgrList");
box.innerHTML = state.streams
.map((s) => {
const isMain = s.id === "main";
const cur = s.id === currentStreamId ? ' <span class="now-badge">当前</span>' : "";
const run = s.running ? "运行中" : "已停止";
const delBtn = isMain
? ""
: '<button class="btn-icon danger js-delstream" title="删除频道"><i class="bi bi-trash3-fill"></i></button>';
return `<div class="stream-row" data-id="${esc(s.id)}">
<div class="stream-row-info">
<div class="stream-row-name">${esc(s.name || s.id)}${cur}</div>
<div class="stream-row-sub">ID: ${esc(s.id)} · ${run}</div>
</div>
<div class="stream-row-actions">
<button class="btn-icon js-switch" title="切换到该频道"><i class="bi bi-box-arrow-in-right"></i></button>
${delBtn}
</div>
</div>`;
})
.join("");
box.querySelectorAll(".js-switch").forEach((btn) => {
btn.addEventListener("click", (e) => switchStream(e.target.closest(".stream-row").dataset.id));
});
box.querySelectorAll(".js-delstream").forEach((btn) => {
btn.addEventListener("click", (e) => deleteStreamById(e.target.closest(".stream-row").dataset.id));
});
}
async function switchStream(id) {
currentStreamId = id;
if (livePlaying) stopLive();
await loadStreams();
await loadPlaylist();
await loadResources();
renderStreamMgr();
showToast("已切换到频道: " + id, "success");
}
async function createStreamFromForm() {
const idEl = document.getElementById("newStreamId");
const nameEl = document.getElementById("newStreamName");
const id = idEl.value.trim();
const name = nameEl.value.trim();
if (!id) return showToast("请输入频道 ID", "error");
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(id)) return showToast("ID 仅允许字母数字_-,1~32位", "error");
try {
await post("/api/streams", { id, name: name || id });
idEl.value = "";
nameEl.value = "";
await loadStreams();
renderStreamMgr();
showToast("已创建频道: " + id, "success");
} catch (err) {
showToast("创建失败: " + err.message, "error");
}
}
async function deleteStreamById(id) {
if (!confirm("确定删除频道「" + id + "」?\n该频道的播放列表将一并删除。")) return;
try {
await api("/api/streams/" + encodeURIComponent(id), { method: "DELETE" });
if (currentStreamId === id) currentStreamId = "main";
await loadStreams();
await loadPlaylist();
renderStreamMgr();
showToast("已删除频道: " + id, "success");
} catch (err) {
showToast("删除失败: " + err.message, "error");
}
}
document.getElementById("btnStreamMgr").addEventListener("click", openStreamManager);
document.getElementById("streamModalClose").addEventListener("click", closeStreamManager);
document.getElementById("btnCreateStream").addEventListener("click", createStreamFromForm);
streamModal.addEventListener("click", (e) => {
if (e.target === streamModal) closeStreamManager();
});
// ==================== List Diff ====================
function reconcileList(container, items, keyOf, create, update) {
const nodes = Array.from(container.querySelectorAll("[data-key]"));
const byKey = new Map(nodes.map((n) => [n.dataset.key, n]));
const seen = new Set();
items.forEach((item, idx) => {
const key = String(keyOf(item));
seen.add(key);
let el = byKey.get(key);
if (!el) { el = create(item); el.dataset.key = key; el.classList.add("enter"); }
update(el, item, idx);
if (container.children[idx] !== el)
container.insertBefore(el, container.children[idx] || null);
});
nodes.forEach((el) => {
if (!seen.has(el.dataset.key)) {
el.classList.add("leave");
el.addEventListener("animationend", () => el.remove(), { once: true });
}
});
}
// ==================== Resources ====================
function renderResItem(item) {
const cover = item.albumImage
? `<img class="res-cover" ${coverSrcAttr(item)} alt="" loading="lazy">`
: `<div class="res-cover-ph"><i class="bi bi-music-note"></i></div>`;
const bg = item.albumImage
? `<div class="res-bg" style="background-image:url('${esc(item.albumImage)}')"></div>`
: "";
const tpl = document.createElement("template");
tpl.innerHTML = `<div class="res-item">${bg}
<div class="res-row">${cover}
<div class="res-meta">
<div class="res-title" title="${esc(item.title)}">${esc(item.title)}</div>
<div class="res-artist">${esc(item.artist) || "未知作者"}</div>
</div>
<div class="res-actions">
<span class="heat-chip"><i class="bi bi-fire"></i><span class="js-heat">${item.heat}</span></span>
<button class="btn-icon js-add" title="添加到当前频道"><i class="bi bi-plus-circle-fill" style="color:var(--accent)"></i></button>
${state.role === 'admin' ? `<button class="btn-icon js-pool${item.inPool ? ' active' : ''}" title="${item.inPool ? '移出循环池' : '加入循环池'}"><i class="bi bi-${item.inPool ? 'arrow-repeat' : 'plus-circle'}"></i></button>` : ''}
<button class="btn-icon danger js-del" title="删除"><i class="bi bi-trash3-fill"></i></button>
</div>
</div>
<div class="res-detail">
<span><i class="bi bi-clock"></i> ${formatDuration(item.duration)}</span>
<span class="detail-sep">|</span>
<span><i class="bi bi-file-earmark"></i> ${formatBytes(item.fileSize)}</span>
<span class="detail-sep">|</span>
<span><i class="bi bi-database"></i> 缓存 <span class="js-cache">${formatBytes(item.cacheSize)}</span></span>
</div>
</div>`;
const el = tpl.content.firstElementChild;
el.querySelector(".js-add").addEventListener("click", () => addToPlaylist(item.file));
el.querySelector(".js-del").addEventListener("click", () => deleteResource(item.file, item.title));
const poolBtn = el.querySelector(".js-pool");
if (poolBtn) poolBtn.addEventListener("click", () => togglePool(item.file));
return el;
}
function updateResItem(el, item) {
const h = el.querySelector(".js-heat");
if (h.textContent !== String(item.heat)) h.textContent = item.heat;
const c = el.querySelector(".js-cache"), cs = formatBytes(item.cacheSize);
if (c.textContent !== cs) c.textContent = cs;
}
let resourceKeyword = "";
let resourceSort = "created_desc";
function sortResources(list) {
const arr = list.slice();
const byName = (a, b) => (a.title || a.file || "").localeCompare(b.title || b.file || "", "zh", { numeric: true });
switch (resourceSort) {
case "created_asc": arr.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0)); break;
case "created_desc": arr.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); break;
case "heat_asc": arr.sort((a, b) => (a.heat || 0) - (b.heat || 0)); break;
case "heat_desc": arr.sort((a, b) => (b.heat || 0) - (a.heat || 0)); break;
case "name_asc": arr.sort(byName); break;
case "name_desc": arr.sort((a, b) => byName(b, a)); break;
}
return arr;
}
function applyResourceFilter() {
document.getElementById("resourceList").querySelectorAll(".res-item").forEach((el) => {
const t = (el.querySelector(".res-title")?.textContent || "").toLowerCase();
const a = (el.querySelector(".res-artist")?.textContent || "").toLowerCase();
el.style.display = !resourceKeyword || t.includes(resourceKeyword) || a.includes(resourceKeyword) ? "" : "none";
});
}
async function loadResources() {
try {
const r = await api("/api/resources");
const resources = sortResources(r.list || []);
// 查当前流的循环池(仅管理员显示按钮),标记每首是否在池
if (state.role === 'admin') {
try {
const pr = await api(`/api/streams/${currentStreamId}/pool`);
const poolSet = new Set((pr.list || []).map(x => x.file));
resources.forEach(x => { x.inPool = poolSet.has(x.file); });
} catch (_) { resources.forEach(x => { x.inPool = false; }); }
} else {
resources.forEach(x => { x.inPool = false; });
}
state.resources = resources;
const container = document.getElementById("resourceList");
document.getElementById("resourceCount").textContent = resources.length;
if (resources.length === 0) {
container.innerHTML = `<div class="empty-state"><div class="empty-icon"><i class="bi bi-folder-x"></i></div><div class="empty-title">暂无资源</div><div class="empty-desc">前往搜索页导入音乐</div></div>`;
return;
}
if (!container.querySelector("[data-key]")) container.innerHTML = "";
reconcileList(container, resources, (r) => r.file, renderResItem, updateResItem);
applyResourceFilter();
} catch (err) { console.error("加载资源失败:", err); }
}
// ==================== Playlist ====================
let sortableInstance = null;
function renderPlItem(item) {
const tpl = document.createElement("template");
tpl.innerHTML = `<div class="pl-item" data-id="${item.entryId}">
<i class="bi bi-grip-vertical pl-grip"></i>
<span class="pl-num"></span>
<div class="pl-info">
<div class="pl-name" title="${esc(item.title)}">${esc(item.title)}${item.isCurrent ? ' <span class="now-badge">播放中</span>' : ""}</div>
<div class="pl-sub">
<span class="heat-chip pl-heat"><i class="bi bi-fire"></i><span class="js-heat"></span></span>
<span class="pl-cache"></span>
</div>
</div>
<button class="btn-icon danger" title="移除"><i class="bi bi-x-circle-fill"></i></button>
</div>`;
return tpl.content.firstElementChild;
}
function updatePlItem(el, item, idx) {
const numEl = el.querySelector(".pl-num");
if (numEl.textContent !== String(idx + 1)) numEl.textContent = idx + 1;
const heatEl = el.querySelector(".js-heat");
if (heatEl.textContent !== String(item.heat)) heatEl.textContent = item.heat;
const cacheEl = el.querySelector(".pl-cache");
const st = item.cacheStatus === "ready" ? "ready" : item.cacheStatus === "pending" ? "pending" : "none";
if (cacheEl.dataset.status !== st) {
cacheEl.dataset.status = st;
cacheEl.className = "pl-cache " + st;
cacheEl.innerHTML =
st === "ready" ? '<i class="bi bi-check-circle-fill"></i> 已缓存'
: st === "pending" ? '<i class="bi bi-hourglass-split"></i> 生成中'
: '<i class="bi bi-cloud"></i> 未缓存';
}
const btn = el.querySelector("button");
btn.onclick = () => removeFromPlaylist(item.entryId);
}
async function loadPlaylist() {
try {
const r = await api(`/api/streams/${currentStreamId}/playlist`);
const playlistData = r.list || [];
state.playlist = playlistData;
const container = document.getElementById("playlistList");
document.getElementById("playlistCount").textContent = playlistData.length;
if (playlistData.length === 0) {
container.innerHTML = `<div class="empty-state"><div class="empty-icon"><i class="bi bi-music-note"></i></div><div class="empty-title">播放列表为空</div><div class="empty-desc">从资源列表中添加音乐</div></div>`;
if (sortableInstance) { sortableInstance.destroy(); sortableInstance = null; }
return;
}
if (!container.querySelector("[data-key]")) container.innerHTML = "";
reconcileList(container, playlistData, (p) => p.entryId, renderPlItem, updatePlItem);
if (!sortableInstance) {
sortableInstance = new Sortable(container, {
animation: 200,
ghostClass: "sortable-ghost",
handle: ".pl-grip",
delay: 100,
delayOnTouchOnly: true,
onEnd: async () => {
const items = [...container.querySelectorAll(".pl-item")];
const order = items.map((el) => Number(el.dataset.id));
try {
await post(`/api/streams/${currentStreamId}/playlist/reorder`, { order });
items.forEach((el, i) => {
el.querySelector(".pl-num").textContent = i + 1;
});
} catch (err) {
console.error("排序更新失败:", err);
loadPlaylist();
}
},
});
}
} catch (err) { console.error("加载播放列表失败:", err); }
}
// ==================== Actions ====================
async function addToPlaylist(file) {
try {
await post(`/api/streams/${currentStreamId}/playlist/add`, { file });
showToast("已添加到播放列表", "success");
await loadPlaylist();
await loadResources();
} catch (err) { showToast("添加失败: " + err.message, "error"); }
}
async function removeFromPlaylist(entryId) {
try {
await post(`/api/streams/${currentStreamId}/playlist/remove`, { entryId });
await loadPlaylist();
} catch (err) { showToast("移除失败: " + err.message, "error"); }
}
async function clearPlaylist() {
if (!confirm("确定清空当前频道的播放列表?清空后将自动随机添加一首。")) return;
try {
await post(`/api/streams/${currentStreamId}/playlist/clear`);
await loadPlaylist();
} catch (err) { showToast("清空失败: " + err.message, "error"); }
}
async function deleteResource(file, title) {
if (!confirm(`确定删除「${title}」?\n将同时删除音频文件、切片缓存与播放列表引用。`)) return;
try {
await post("/api/music/delete", { file });
showToast("已删除: " + title, "success");
await loadResources();
await loadPlaylist();
} catch (err) { showToast("删除失败: " + err.message, "error"); }
}
async function togglePool(file) {
const item = (state.resources || []).find(x => x.file === file);
const inPool = item && item.inPool;
const action = inPool ? "remove" : "add";
try {
await post(`/api/streams/${currentStreamId}/pool/${action}`, { file });
showToast(inPool ? "已移出循环池" : "已加入循环池", "success");
await loadResources();
} catch (e) {
showToast("操作失败: " + e.message, "error");
}
}
async function enrichResource(file) {
try {
showToast("正在重新匹配...");
await post("/api/music/enrich", { file });
await loadResources();
showToast("匹配完成", "success");
} catch (err) { showToast("匹配失败: " + err.message, "error"); }
}
// ==================== Cache Info ====================
async function loadCacheInfo() {
try {
const info = await api("/api/system/cache");
document.getElementById("cacheInfo").innerHTML =
`<i class="bi bi-database"></i> ${(info.total / 1048576).toFixed(1)}/${(info.limit / 1048576).toFixed(0)}MB (${info.usedPercent}%)`;
} catch (err) { console.error("加载缓存信息失败:", err); }
}
// ==================== Mini Player ====================
const musicPlayer = document.getElementById("musicPlayer");
const miniPlayer = document.getElementById("miniPlayer");
const mpBody = document.getElementById("mpBody");
const mpProgress = document.getElementById("mpProgress");
const mpCover = document.getElementById("mpCover");
const mpName = document.getElementById("mpName");
const mpArtist = document.getElementById("mpArtist");
const mpPlay = document.getElementById("mpPlay");
const mpTime = document.getElementById("mpTime");
const mpExpand = document.getElementById("mpExpand");
function showMiniPlayer(info) {
mpName.textContent = info.name || "未知";
mpArtist.textContent = info.artist || "--";
if (info.albumImage) { mpCover.onerror = info.coverLocal ? () => { mpCover.onerror = null; mpCover.src = info.coverLocal; } : null; mpCover.src = info.albumImage; mpCover.style.display = ""; }
else mpCover.style.display = "none";
miniPlayer.classList.add("show");
document.body.classList.add("mini-player-visible");
}
function hideMiniPlayer() {
miniPlayer.classList.remove("show");
document.body.classList.remove("mini-player-visible");
}
mpPlay.addEventListener("click", () => { musicPlayer.paused ? musicPlayer.play() : musicPlayer.pause(); });
musicPlayer.addEventListener("play", () => { mpPlay.innerHTML = '<i class="bi bi-pause-fill"></i>'; });
musicPlayer.addEventListener("pause", () => { mpPlay.innerHTML = '<i class="bi bi-play-fill"></i>'; });
musicPlayer.addEventListener("timeupdate", () => {
if (!musicPlayer.duration) return;
mpProgress.style.width = (musicPlayer.currentTime / musicPlayer.duration) * 100 + "%";
mpTime.textContent = formatDuration(musicPlayer.currentTime);
});
musicPlayer.addEventListener("ended", () => { mpPlay.innerHTML = '<i class="bi bi-play-fill"></i>'; });
mpExpand.addEventListener("click", () => {
switchPage("pageSearch");
const preview = document.getElementById("musicPreview");
if (preview.style.display !== "none") preview.scrollIntoView({ behavior: "smooth", block: "center" });
});
let isSeeking = false;
function seekFromPointer(e) {
const rect = mpBody.getBoundingClientRect();
const x = e.touches ? e.touches[0].clientX : e.clientX;
const ratio = Math.max(0, Math.min(1, (x - rect.left) / rect.width));
if (musicPlayer.duration) {
musicPlayer.currentTime = ratio * musicPlayer.duration;
mpProgress.style.width = ratio * 100 + "%";
}
}
mpBody.addEventListener("mousedown", (e) => { if (e.target.closest("button")) return; isSeeking = true; seekFromPointer(e); });
mpBody.addEventListener("touchstart", (e) => { if (e.target.closest("button")) return; isSeeking = true; seekFromPointer(e); }, { passive: true });
document.addEventListener("mousemove", (e) => { if (isSeeking) seekFromPointer(e); });
document.addEventListener("touchmove", (e) => { if (isSeeking) seekFromPointer(e); }, { passive: true });
document.addEventListener("mouseup", () => { isSeeking = false; });
document.addEventListener("touchend", () => { isSeeking = false; });
// ==================== Music Search ====================
let musicInfo = null, selectedId = null;
const musicIdInput = document.getElementById("musicId");
const parseHint = document.getElementById("parseHint");
const parsedIdEl = document.getElementById("parsedId");
function updateParseHint() {
const val = musicIdInput.value.trim(), extracted = extractMusicId(val);
if (val && extracted !== val && /^\d+$/.test(extracted)) {
parsedIdEl.textContent = extracted;
parseHint.classList.add("visible");
} else parseHint.classList.remove("visible");
}
musicIdInput.addEventListener("input", updateParseHint);
musicIdInput.addEventListener("paste", () => requestAnimationFrame(updateParseHint));
async function showDetail(id) {
const info = await api("/api/music/detail", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
musicInfo = info; selectedId = id;
document.getElementById("musicName").textContent = info.name;
document.getElementById("musicArtist").textContent = info.artist || "未知作者";
document.getElementById("musicCover").src = info.albumImage || "";
let tags = "";
if (info.album) tags += `<span class="detail-tag album-tag"><i class="bi bi-disc"></i> ${esc(info.album)}</span>`;
if (info.duration) tags += `<span class="detail-tag duration-tag"><i class="bi bi-clock"></i> ${formatDuration(info.duration)}</span>`;
if (id) tags += `<span class="detail-tag"><i class="bi bi-hash"></i> ${id}</span>`;
document.getElementById("musicDetails").innerHTML = tags;
document.getElementById("musicPreview").style.display = "block";
document.getElementById("searchTips").style.display = "none";
musicPlayer.pause();
musicPlayer.src = "http://music.163.com/song/media/outer/url?id=" + id + ".mp3";
showMiniPlayer({ name: info.name, artist: info.artist, albumImage: info.albumImage });
mpProgress.style.width = "0%";
mpTime.textContent = "0:00";
}
function renderSearchResults(data, keyword) {
const container = document.getElementById("searchResults");
musicInfo = null; selectedId = null;
document.getElementById("musicPreview").style.display = "none";
if (!data.list || data.list.length === 0) {
container.innerHTML = `<div class="empty-state"><div class="empty-icon"><i class="bi bi-search"></i></div><div class="empty-title">未找到相关歌曲</div><div class="empty-desc">换个关键词试试</div></div>`;
container.style.display = "block";
document.getElementById("searchTips").style.display = "none";
return;
}
container.innerHTML = `
<div class="sr-head">"${esc(keyword)}" 共 <b>${data.total}</b> 首相关歌曲,点击选择:</div>
<div class="sr-list">${data.list.map((item, i) => `
<div class="sr-item" data-id="${item.id}" style="animation-delay:${i * 0.04}s">
<div class="sr-main">
<div class="sr-name">${esc(item.name)}</div>
<div class="sr-artist">${esc((item.artists || []).join(" / ") || "未知作者")}</div>
</div>
${item.alias && item.alias[0] ? `<div class="sr-alias" title="${esc(item.alias.join(" / "))}">${esc(item.alias[0])}</div>` : ""}
<div class="sr-duration">${formatDuration(item.duration / 1000)}</div>
</div>`).join("")}</div>`;
container.style.display = "block";
document.getElementById("searchTips").style.display = "none";
container.querySelectorAll(".sr-item").forEach((el) => {
el.addEventListener("click", () => {
container.querySelectorAll(".sr-item").forEach((x) => x.classList.remove("active"));
el.classList.add("active");
showDetail(el.dataset.id).catch((err) => showToast("查询失败: " + err.message, "error"));
});
});
}
async function searchMusic() {
const rawInput = musicIdInput.value.trim();
if (!rawInput) return showToast("请输入歌曲 ID、链接或歌名", "error");
const id = extractMusicId(rawInput);
const btn = document.getElementById("btnSearch");
btn.disabled = true; btn.textContent = "查询中...";
try {
if (id && /^\d+$/.test(id)) {
// 按 ID / 链接解析:所有登录用户可用
document.getElementById("searchResults").style.display = "none";
await showDetail(id);
} else if (state.role === "admin") {
// 关键词搜索:仅管理员
renderSearchResults(await api("/api/music/search?keyword=" + encodeURIComponent(rawInput)), rawInput);
} else {
// 普通用户:只能用 ID 或链接
showToast("普通用户请用歌曲 ID 或网易云链接解析", "error");
}
} catch (err) { showToast("查询失败: " + err.message, "error"); }
finally { btn.disabled = false; btn.textContent = "查询"; }
}
async function downloadMusic() {
if (!musicInfo || !selectedId) return;
const btn = document.getElementById("btnDownload");
btn.disabled = true; btn.innerHTML = '<span class="spinner-sm"></span> 下载中';
try {
const r = await post("/api/music/download", {
id: selectedId, name: musicInfo.name,
artist: musicInfo.artist, albumImage: musicInfo.albumImage,
});
musicIdInput.value = ""; musicInfo = null; selectedId = null;
document.getElementById("musicPreview").style.display = "none";
document.getElementById("searchResults").style.display = "none";
document.getElementById("searchTips").style.display = "block";
parseHint.classList.remove("visible");
loadResources();
showToast("已下载: " + r.file, "success");
} catch (err) { showToast("下载失败: " + err.message, "error"); }
finally { btn.disabled = false; btn.innerHTML = '<i class="bi bi-download"></i> 下载到资源库'; }
}
// ==================== Local Import ====================
// XHR 上传(带进度回调),fetch 不支持上传进度
function uploadWithProgress(url, formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.withCredentials = true;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => {
if (xhr.status === 401) { window.location.href = "/login"; return; }
let data = null;
try { data = JSON.parse(xhr.responseText); } catch (_) {}
if (xhr.status >= 200 && xhr.status < 300 && data && data.ok) resolve(data);
else reject(new Error((data && data.error) || ("HTTP " + xhr.status)));
};
xhr.onerror = () => reject(new Error("网络错误"));
xhr.send(formData);
});
}
const importFileInput = document.getElementById("importFile");
document.getElementById("btnImport").addEventListener("click", () => importFileInput.click());
importFileInput.addEventListener("change", async () => {
const f = importFileInput.files[0];
if (!f) return;
const title = document.getElementById("importTitle").value.trim();
const artist = document.getElementById("importArtist").value.trim();
const prog = document.getElementById("importProgress");
const btn = document.getElementById("btnImport");
btn.disabled = true; prog.textContent = "上传中...";
const fd = new FormData();
fd.append("audio", f);
fd.append("title", title);
fd.append("artist", artist);
try {
const data = await uploadWithProgress("/api/music/import", fd, (pct) => {
prog.textContent = "上传中... " + pct + "%";
});
prog.textContent = "上传完成,正在匹配元数据...";
showToast("已导入: " + data.file, "success");
document.getElementById("importTitle").value = "";
document.getElementById("importArtist").value = "";
importFileInput.value = "";
await loadResources();
prog.textContent = "";
} catch (err) {
prog.textContent = "";
showToast("导入失败: " + err.message, "error");
} finally { btn.disabled = false; }
});
// ==================== SSE ====================
// 节流刷新收听页(切歌/列表变化时)
let liveRefreshTimer = null;
function refreshLive() {
if (liveRefreshTimer) return;
liveRefreshTimer = setTimeout(() => {
liveRefreshTimer = null;
const pg = document.getElementById("pageLive");
if (pg && pg.classList.contains("active")) loadLiveCover();
}, 300);
}
function connectSSE() {
const es = new EventSource("/api/events");
es.addEventListener("cache-status", () => { loadResources(); loadPlaylist(); });
es.addEventListener("playlist-update", () => { loadPlaylist(); refreshLive(); });
es.addEventListener("song-finished", () => { loadPlaylist(); refreshLive(); });
es.onerror = () => { es.close(); setTimeout(connectSSE, 5000); };
}
// ==================== Event Listeners ====================
document.getElementById("btnSearch").addEventListener("click", searchMusic);
document.getElementById("btnDownload").addEventListener("click", downloadMusic);
document.getElementById("btnClear").addEventListener("click", clearPlaylist);
musicIdInput.addEventListener("keydown", (e) => { if (e.key === "Enter") searchMusic(); });
musicPlayer.addEventListener("error", () => showToast("试听失败:该歌曲可能无版权", "error"));
document.getElementById("resourceSearch").addEventListener("input", (e) => {
resourceKeyword = e.target.value.trim().toLowerCase();
applyResourceFilter();
});
document.getElementById("resourceSort").addEventListener("change", (e) => {
resourceSort = e.target.value;
loadResources();
});
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
if (e.code === "Space") { e.preventDefault(); musicPlayer.paused ? musicPlayer.play() : musicPlayer.pause(); }
if (e.code === "ArrowRight") musicPlayer.currentTime = Math.min(musicPlayer.duration || 0, musicPlayer.currentTime + 5);
if (e.code === "ArrowLeft") musicPlayer.currentTime = Math.max(0, musicPlayer.currentTime - 5);
});
// ==================== Swipe Navigation ====================
let touchStartX = 0, touchStartY = 0, touchStartTime = 0;
document.addEventListener("touchstart", (e) => {
touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; touchStartTime = Date.now();
}, { passive: true });
document.addEventListener("touchend", (e) => {
const dx = e.changedTouches[0].clientX - touchStartX;
const dy = e.changedTouches[0].clientY - touchStartY;
const dt = Date.now() - touchStartTime;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5 || dt > 500) return;
const idx = pageOrder.findIndex((id) => document.getElementById(id)?.classList.contains("active"));
if (idx < 0) return;
if (dx < 0 && idx < pageOrder.length - 1) switchPage(pageOrder[idx + 1]);
else if (dx > 0 && idx > 0) switchPage(pageOrder[idx - 1]);
}, { passive: true });
// ==================== 站内收听(HLS.js) ====================
const liveCover = document.getElementById("liveCover");
const liveCoverPh = document.getElementById("liveCoverPh");
const liveStation = document.getElementById("liveStation");
const liveNow = document.getElementById("liveNow");
const liveSub = document.getElementById("liveSub");
const liveStatus = document.getElementById("liveStatus");
const liveProgressFill = document.getElementById("liveProgressFill");
const livePlayBtn = document.getElementById("livePlayBtn");
const liveHintStation = document.getElementById("liveHintStation");
let hls = null;
let livePlaying = false;
function currentStream() {
return state.streams.find((s) => s.id === currentStreamId) || null;
}
function setLivePlaying(on) {
livePlaying = on;
livePlayBtn.innerHTML = on
? '<i class="bi bi-pause-fill"></i>'
: '<i class="bi bi-play-fill"></i>';
}
function updateLiveUI() {
const st = currentStream();
const name = st ? (st.name || st.id) : "主电台";
liveStation.textContent = name;
liveHintStation.textContent = name;
}
async function loadLiveCover() {
// 用当前频道播放列表第一首的封面作为"正在播放"占位
try {
const r = await api(`/api/streams/${currentStreamId}/playlist`);
const cur = (r.list || []).find((x) => x.isCurrent) || (r.list || [])[0];
if (cur && cur.albumImage) {
liveCover.onerror = cur.coverLocal ? () => { liveCover.onerror = null; liveCover.src = cur.coverLocal; } : null;
liveCover.src = cur.albumImage;
liveCover.style.display = "";
liveCoverPh.style.display = "none";
liveNow.textContent = cur.title || "直播中";
liveSub.textContent = cur.artist || "--";
} else {
liveCover.style.display = "none";
liveCoverPh.style.display = "";
liveNow.textContent = "直播进行中";
liveSub.textContent = "暂无曲目信息";
}
} catch (_) {}
}
function stopLive() {
if (hls) { try { hls.destroy(); } catch (_) {} hls = null; }
setLivePlaying(false);
liveStatus.textContent = "已暂停";
liveProgressFill.style.width = "0%";
}
function startLive() {
const st = currentStream();
const url = st ? st.m3u8 : "/hls/main/index.m3u8";
const audio = musicPlayer; // 复用全局 audio 元素
audio.muted = false;
stopLive();
const onPlaying = () => {
setLivePlaying(true);
liveStatus.textContent = "直播中";
liveProgressFill.style.width = "100%";
};
const onErr = () => { liveStatus.textContent = "连接失败"; setLivePlaying(false); };
if (window.Hls && window.Hls.isSupported()) {
hls = new Hls({ liveSyncDurationCount: 3, lowLatencyMode: false });
hls.loadSource(url);
hls.attachMedia(audio);
hls.on(Hls.Events.MANIFEST_PARSED, () => { audio.play().catch(onErr); });
hls.on(Hls.Events.ERROR, (e, data) => {
if (data && data.fatal) { liveStatus.textContent = "直播流错误"; setLivePlaying(false); }
});
audio.addEventListener("playing", onPlaying, { once: true });
} else if (audio.canPlayType("application/vnd.apple.mpegurl")) {
audio.src = url;
audio.addEventListener("playing", onPlaying, { once: true });
audio.play().catch(onErr);
} else {
liveStatus.textContent = "浏览器不支持 HLS";
}
}
function toggleLive() {
if (livePlaying) {
musicPlayer.pause();
setLivePlaying(false);
liveStatus.textContent = "已暂停";
} else {
startLive();
}
}
livePlayBtn.addEventListener("click", toggleLive);
// ==================== 设置页 ====================
function fmtMB(b) { return (b / 1048576).toFixed(1) + " MB"; }
async function loadSettings() {
if (state.role !== "admin") return;
// 缓存
try {
const c = await api("/api/system/cache");
document.getElementById("setCacheText").textContent =
fmtMB(c.total) + " / " + (c.limit / 1048576).toFixed(0) + " MB";
const pct = Math.min(100, parseFloat(c.usedPercent) || 0);
const bar = document.getElementById("setCacheBar");
bar.style.width = pct + "%";
bar.className = "cache-bar-fill" + (pct > 90 ? " danger" : pct > 70 ? " warn" : "");
document.getElementById("setCacheNote").textContent =
"已用 " + c.usedPercent + "%,超限时自动按热度 LRU 淘汰";
} catch (e) { console.error("缓存信息失败", e); }
// 热配置表单:填充当前值
try {
const r = await api("/api/system/config");
const cf = r.config || {};
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
set("cfgHlsTime", cf.HLS_TIME);
set("cfgSlideWindow", cf.SLIDE_WINDOW_SIZE);
set("cfgMaxCacheMB", (cf.MAX_CACHE_SIZE / 1048576).toFixed(0));
set("cfgLogLevel", cf.LOG_LEVEL || "info");
set("cfgSessionTtlHours", cf.SESSION_TTL_HOURS);
set("cfgLoginMaxFails", cf.LOGIN_MAX_FAILS);
set("cfgLoginLockMin", cf.LOGIN_LOCK_MIN);
set("cfgSearchCacheTtlSec", cf.SEARCH_CACHE_TTL_SEC);
set("cfgSearchRateMax", cf.SEARCH_RATE_MAX);
set("cfgSearchRateWindowSec", cf.SEARCH_RATE_WINDOW_SEC);
// 默认频道下拉:填选项 + 回填当前选中值
const sel = document.getElementById("cfgDefaultStream");
if (sel) {
try {
const st = await api("/api/streams");
sel.innerHTML = (st.list || []).map(s =>
`<option value="${esc(s.id)}">${esc(s.name)}</option>`).join("");
// 回填已保存的默认流
try {
const s = await api("/api/system/settings");
const def = (s.settings || {}).defaultStream;
if (def) sel.value = def;
} catch (_) {}
} catch (_) {}
}
} catch (e) { console.error("配置失败", e); }
await loadLogs();
await loadUsers();
}
// 权限管理:加载用户列表
async function loadUsers() {
const el = document.getElementById("userList");
if (!el) return;
try {
const r = await api("/api/system/users");
const list = r.list || [];
if (!list.length) {
el.innerHTML = '<div class="user-empty">暂无登录过的用户</div>';
return;
}
el.innerHTML = list.map(u => {
const badge = u.isAdmin ? '<span class="admin-badge">管理员</span>' : '';
const uuidTxt = u.uuid ? esc(u.uuid) : '(无 UUID,需重新登录)';
const canToggle = !!u.uuid;
const btn = canToggle
? (u.isAdmin
? `<button class="btn-mini danger" data-del="${esc(u.uuid)}">取消管理员</button>`
: `<button class="btn-mini" data-add="${esc(u.uuid)}" data-name="${esc(u.name)}">设为管理员</button>`)
: '';
return `<div class="user-item">
<div class="user-meta">
<span class="user-name">${esc(u.name)} ${badge}</span>
<span class="user-sub">${uuidTxt}</span>
</div>
${btn}
</div>`;
}).join("");
// 绑定按钮
el.querySelectorAll("[data-add]").forEach(b => b.addEventListener("click", async () => {
await post("/api/system/admins", { uuid: b.dataset.add, name: b.dataset.name });
showToast("已设为管理员(下次登录生效)", "success");
await loadUsers();
}));
el.querySelectorAll("[data-del]").forEach(b => b.addEventListener("click", async () => {
if (!confirm("确定取消该管理员?")) return;
await fetch("/api/system/admins/" + b.dataset.del, {
method: "DELETE",
credentials: "same-origin",
headers: { "X-CSRF-Token": (document.cookie.match(/(?:^|;\s*)mr_csrf=([^;]+)/)||[])[1] ? decodeURIComponent(document.cookie.match(/(?:^|;\s*)mr_csrf=([^;]+)/)[1]) : "" },
});
showToast("已取消管理员", "success");
await loadUsers();
}));
} catch (e) {
el.innerHTML = '<div class="user-empty">加载失败: ' + esc(e.message) + '</div>';
}
}
async function loadLogs() {
const tail = document.getElementById("logTail").value || 100;
try {
const r = await api("/api/system/logs?tail=" + tail);
const lines = r.lines || [];
const el = document.getElementById("logView");
el.textContent = lines.length ? lines.join("\n") : "(暂无日志)";
el.scrollTop = el.scrollHeight;
} catch (e) {
document.getElementById("logView").textContent = "日志加载失败: " + e.message;
}
}
document.getElementById("btnSaveSettings").addEventListener("click", async () => {
const btn = document.getElementById("btnSaveSettings");
const val = (id) => {
const v = document.getElementById(id).value;
return v === "" ? undefined : (isNaN(v) ? v : Number(v));
};
const payload = {
hlsTime: val("cfgHlsTime"),
slideWindow: val("cfgSlideWindow"),
maxCacheMB: val("cfgMaxCacheMB"),
defaultStream: document.getElementById("cfgDefaultStream").value || undefined,
logLevel: document.getElementById("cfgLogLevel").value,
sessionTtlHours: val("cfgSessionTtlHours"),
loginMaxFails: val("cfgLoginMaxFails"),
loginLockMin: val("cfgLoginLockMin"),
searchCacheTtlSec: val("cfgSearchCacheTtlSec"),
searchRateMax: val("cfgSearchRateMax"),
searchRateWindowSec: val("cfgSearchRateWindowSec"),
};
// 去掉 undefined
Object.keys(payload).forEach(k => payload[k] === undefined && delete payload[k]);
btn.disabled = true;
try {
await post("/api/system/settings", payload);
showToast("配置已保存并生效", "success");
await loadSettings();
} catch (e) {
showToast("保存失败: " + e.message, "error");
} finally { btn.disabled = false; }
});
document.getElementById("btnRefreshUsers").addEventListener("click", loadUsers);
document.getElementById("btnCleanup").addEventListener("click", async () => {
if (!confirm("确定立即清理缓存?将按热度淘汰非播放中的切片。")) return;
try {
await post("/api/system/cache/cleanup");
showToast("缓存已清理", "success");
await loadSettings();
await loadResources();
} catch (e) { showToast("清理失败: " + e.message, "error"); }
});
document.getElementById("logTail").addEventListener("change", loadLogs);
// 一键刷新缺失封面
document.getElementById("btnRefreshCovers").addEventListener("click", async () => {
const btn = document.getElementById("btnRefreshCovers");
const prog = document.getElementById("coverRefreshProgress");
if (!confirm("为所有没有封面的音乐重新匹配网易云封面?")) return;
btn.disabled = true;
prog.textContent = "正在刷新,请稍候...";
try {
const r = await post("/api/music/refresh-covers");
if (r.total === 0) {
prog.textContent = "没有缺失封面的音乐";
showToast("没有缺失封面的音乐", "success");
} else {
prog.textContent = "完成:共 " + r.total + " 首,成功 " + r.success + ",失败 " + r.fail;
showToast("封面刷新完成:成功 " + r.success + "/" + r.total, r.fail ? "error" : "success");
await loadResources();
if (typeof loadLiveCover === "function") loadLiveCover();
}
} catch (e) {
prog.textContent = "";
showToast("刷新失败: " + e.message, "error");
} finally {
btn.disabled = false;
}
});
// ==================== Init ====================
(async function init() {
try { const me = await api("/api/auth/me"); state.role = me.role || null; applyRoleUI(state.role, me.starlightName); } catch (_) {}
await loadStreams();
loadLiveCover();
await loadResources();
await loadPlaylist();
loadCacheInfo();
connectSSE();
setInterval(() => { loadResources(); loadPlaylist(); loadCacheInfo(); refreshLive(); }, 8000);
})();