/* ============================================================
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 ? ' 当前' : "";
const run = s.running ? "运行中" : "已停止";
const delBtn = isMain
? ""
: '';
return `
${esc(s.name || s.id)}${cur}
ID: ${esc(s.id)} · ${run}
${delBtn}
`;
})
.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
? `
`
: `
`;
const bg = item.albumImage
? ``
: "";
const tpl = document.createElement("template");
tpl.innerHTML = `${bg}
${cover}
${item.heat}
${state.role === 'admin' ? `` : ''}
${formatDuration(item.duration)}
|
${formatBytes(item.fileSize)}
|
缓存 ${formatBytes(item.cacheSize)}
`;
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 = ``;
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 = `
${esc(item.title)}${item.isCurrent ? ' 播放中' : ""}
`;
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" ? ' 已缓存'
: st === "pending" ? ' 生成中'
: ' 未缓存';
}
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 = ``;
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 =
` ${(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 = ''; });
musicPlayer.addEventListener("pause", () => { mpPlay.innerHTML = ''; });
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 = ''; });
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 += ` ${esc(info.album)}`;
if (info.duration) tags += ` ${formatDuration(info.duration)}`;
if (id) tags += ` ${id}`;
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 = ``;
container.style.display = "block";
document.getElementById("searchTips").style.display = "none";
return;
}
container.innerHTML = `
"${esc(keyword)}" 共 ${data.total} 首相关歌曲,点击选择:
${data.list.map((item, i) => `
${esc(item.name)}
${esc((item.artists || []).join(" / ") || "未知作者")}
${item.alias && item.alias[0] ? `
${esc(item.alias[0])}
` : ""}
${formatDuration(item.duration / 1000)}
`).join("")}
`;
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 = ' 下载中';
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 = ' 下载到资源库'; }
}
// ==================== 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
? ''
: '';
}
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 =>
``).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 = '暂无登录过的用户
';
return;
}
el.innerHTML = list.map(u => {
const badge = u.isAdmin ? '管理员' : '';
const uuidTxt = u.uuid ? esc(u.uuid) : '(无 UUID,需重新登录)';
const canToggle = !!u.uuid;
const btn = canToggle
? (u.isAdmin
? ``
: ``)
: '';
return `
${esc(u.name)} ${badge}
${uuidTxt}
${btn}
`;
}).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 = '加载失败: ' + esc(e.message) + '
';
}
}
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);
})();