diff --git a/HANDOFF.md b/HANDOFF.md
index b8b34a3..e926d1b 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -415,3 +415,45 @@ python test_enrich.py # 给 5 首补封面(已跑过,5/5 成功)
- `login.html` 独立暗色主题(自己的 `:root`)。
- `body` 字体栈、`--log-bg` 终端风格:保留。
+
+---
+
+## 18. v2.5 安全加固 + 权限体系 + 封面网络化(已完成 2026-09-25)
+
+> 老板提出 6 点问题,本轮解决其中 5 点(延迟调节因复杂度暂缓)。
+
+### 18.1 双密码 + 角色权限(核心)
+- **config.js**:`ADMIN_PASSWORD`(兼容旧 `PANEL_PASSWORD`)/ `USER_PASSWORD`;新增 `LOGIN_MAX_FAILS`/`LOGIN_LOCK_MS`/`SEARCH_CACHE_TTL_MS`/`SEARCH_RATE_MAX`/`SEARCH_RATE_WINDOW_MS`。
+- **db.js**:`sessions` 表加 `role` 列;`songs` 表加 `cover_remote` 列。均带**运行时迁移**(PRAGMA table_info 检测后 ALTER)。
+- **auth.js**:重写。`verifyLogin` 返回 `"admin"|"user"|null`;`createSession(role)`;新增 `requireAdmin` 中间件、`getRole/isAdmin`;**按 IP 登录失败锁定**(默认 5 次锁 10 分钟,内存 Map)。
+- **routes/auth.js**:登录返回 `{ok, role}`;锁定返回 429;`/me` 返回 role。
+- **权限矩阵**:用户 = 管理员 − 上传 − 设置页。`/api/music/import` 加 `requireAdmin`;`/api/system/*` 整块 `requireAdmin`。其余接口(资源/流/播放列表/搜索/下载)两者一致。
+- **前端**:`init()` 先查 `/api/auth/me` 拿 role → `applyRoleUI()` 隐藏设置页 tab + 本地导入卡片;`loadSettings()` 非管理员直接 return。
+
+### 18.2 搜索缓存 + 限流(防服务器 IP 被封)
+- **实测确认**:网易云搜索 API **无 CORS 头**(返回 text/plain),**无法下放到浏览器**,必须后端代理。
+- **对策**:`routes/music.js` 的 `/search` 加 ①内存缓存(同关键词 TTL 5 分钟内直接返回,上限 200 条 LRU);②按 IP 限流(默认 60 秒 20 次,超限 429)。命中缓存不消耗限流额度。
+
+### 18.3 封面网络化(远程优先 + 本地回退)
+- **实测确认**:网易云图片 CDN 返回 `Access-Control-Allow-Origin: *` 且不校验 Referer,浏览器可直接加载。
+- **改动**:`enrichSong` 下载封面时**同时**存远程 URL(`cover_remote`)和本地(`cover`);API 返回 `albumImage`(远程优先)+ `coverLocal`(本地兜底)。
+- **前端**:`coverSrcAttr(item)` 生成 `
`;mini player / 收听页封面同样 onerror 回退。
+
+### 18.4 刷新优化
+- init 时统一获取角色;SSE 事件保持增量刷新(`loadPlaylist`+`refreshLive` 节流)。
+
+### 18.5 验证
+- 回归:**api 16/16、e2e 17/17、write 5/5** 全绿。
+- 新增功能手测(curl):
+ - admin 登录 → `/api/system/cache` 200、`/logs` 200;user 登录 → 同接口 **403**,`/api/music/import` **403**,`/api/resources` 200、`/api/streams` 200。
+ - `/api/auth/me` 正确返回 role。
+ - 搜索同关键词第二次返回 `cached:true`。
+
+### 18.6 启动方式
+- `start.cmd` 更新为:`set "ADMIN_PASSWORD=test123"` + `set "USER_PASSWORD=user123"` + `node index.js`。
+- 托管启动:`cmd /c "set ADMIN_PASSWORD=test123&& set USER_PASSWORD=user123&& node index.js"`(注意用 `&&` 且不加尾空格,否则密码带空格)。
+
+### 18.7 未做
+- **播放延迟调节**(HLS_TIME 运行时热更新):老板明确暂缓。
+- 用户密码/管理员密码的 UI 设置界面(现走环境变量)。
+- 会话表已有 role 列,但登录失败锁定的 failMap 是内存态,重启清零(可接受)。
\ No newline at end of file
diff --git a/public/js/app.js b/public/js/app.js
index 8eba2f6..df276a0 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -35,6 +35,15 @@ function formatBytes(b) {
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 = {}) {
@@ -49,6 +58,14 @@ async function api(url, options = {}) {
if (!res.ok) throw new Error((data && data.error) || text || `HTTP ${res.status}`);
return data;
}
+// 角色 UI:非管理员隐藏 设置页 + 本地导入
+function applyRoleUI(role) {
+ 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";
+}
function post(url, body) {
return api(url, {
method: "POST",
@@ -238,7 +255,7 @@ function reconcileList(container, items, keyOf, create, update) {
// ==================== Resources ====================
function renderResItem(item) {
const cover = item.albumImage
- ? `
`
+ ? `
`
: `
`;
const bg = item.albumImage
? ``
@@ -456,7 +473,7 @@ const mpExpand = document.getElementById("mpExpand");
function showMiniPlayer(info) {
mpName.textContent = info.name || "未知";
mpArtist.textContent = info.artist || "--";
- if (info.albumImage) { mpCover.src = info.albumImage; mpCover.style.display = ""; }
+ 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");
@@ -755,6 +772,7 @@ async function loadLiveCover() {
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";
@@ -824,6 +842,7 @@ 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");
@@ -882,6 +901,7 @@ document.getElementById("logTail").addEventListener("change", loadLogs);
// ==================== Init ====================
(async function init() {
+ try { const me = await api("/api/auth/me"); state.role = me.role || null; applyRoleUI(state.role); } catch (_) {}
await loadStreams();
loadLiveCover();
await loadResources();
diff --git a/src/auth.js b/src/auth.js
index bae9ddf..16489ed 100644
--- a/src/auth.js
+++ b/src/auth.js
@@ -6,15 +6,43 @@ const store = require("./store");
const COOKIE_NAME = "mr_session";
-// 密码来源:环境变量 PANEL_PASSWORD;未设置则首次启动随机生成并打印
-let panelPassword = config.PANEL_PASSWORD;
-let generated = false;
-if (!panelPassword) {
- panelPassword = crypto.randomBytes(6).toString("hex");
- generated = true;
+/* ==================== 密码来源 ==================== */
+// 管理员密码:ADMIN_PASSWORD(兼容旧 PANEL_PASSWORD);未设置则首次随机生成并打印
+// 用户密码:USER_PASSWORD;未设置则「无用户密码」——只有管理员能登录
+let adminPassword = config.ADMIN_PASSWORD;
+let userPassword = config.USER_PASSWORD;
+let adminGenerated = false;
+if (!adminPassword) {
+ adminPassword = crypto.randomBytes(6).toString("hex");
+ adminGenerated = true;
}
+/* ==================== 登录失败锁定(按 IP) ==================== */
+const failMap = new Map(); // ip -> { count, lockedUntil }
+function isLocked(ip) {
+ const rec = failMap.get(ip);
+ if (!rec) return 0;
+ if (rec.lockedUntil && rec.lockedUntil > Date.now()) {
+ return Math.ceil((rec.lockedUntil - Date.now()) / 1000);
+ }
+ return 0;
+}
+function recordFail(ip) {
+ const rec = failMap.get(ip) || { count: 0, lockedUntil: 0 };
+ rec.count += 1;
+ if (rec.count >= config.LOGIN_MAX_FAILS) {
+ rec.lockedUntil = Date.now() + config.LOGIN_LOCK_MS;
+ rec.count = 0;
+ logger.warn(`[AUTH] IP ${ip} 登录失败次数过多,锁定 ${config.LOGIN_LOCK_MS / 60000} 分钟`);
+ }
+ failMap.set(ip, rec);
+}
+function clearFail(ip) {
+ failMap.delete(ip);
+}
+
+/* ==================== Cookie ==================== */
function parseCookies(header) {
const out = {};
if (!header) return out;
@@ -25,22 +53,29 @@ function parseCookies(header) {
return out;
}
-function createSession() {
+/* ==================== 会话 ==================== */
+function createSession(role) {
const token = crypto.randomBytes(24).toString("hex");
const expiresAt = Date.now() + config.SESSION_TTL_MS;
- store.createSession(token, expiresAt);
- return { token, expiresAt };
+ store.createSession(token, role, expiresAt);
+ return { token, expiresAt, role };
}
+function timingEqual(a, b) {
+ const ba = Buffer.from(String(a));
+ const bb = Buffer.from(String(b));
+ if (ba.length !== bb.length) return false;
+ return crypto.timingSafeEqual(ba, bb);
+}
+
+// 返回角色 "admin" | "user" | null
function verifyLogin(password) {
- // 定长比较,防时序攻击
- const a = Buffer.from(String(password));
- const b = Buffer.from(panelPassword);
- if (a.length !== b.length) return false;
- return crypto.timingSafeEqual(a, b);
+ if (!password) return null;
+ if (timingEqual(password, adminPassword)) return "admin";
+ if (userPassword && timingEqual(password, userPassword)) return "user";
+ return null;
}
-// 从请求里取有效会话
function getValidSession(req) {
const cookies = parseCookies(req.headers.cookie);
const token = cookies[COOKIE_NAME];
@@ -51,6 +86,13 @@ function getValidSession(req) {
function isAuthed(req) {
return !!getValidSession(req);
}
+function getRole(req) {
+ const s = getValidSession(req);
+ return s ? s.role || "user" : null;
+}
+function isAdmin(req) {
+ return getRole(req) === "admin";
+}
function setSessionCookie(res, token, expiresAt) {
const maxAge = Math.floor((expiresAt - Date.now()) / 1000);
@@ -63,7 +105,7 @@ function clearSessionCookie(res) {
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
}
-// 中间件:保护 /api(登录相关除外)与面板
+/* ==================== 中间件 ==================== */
function requireAuth(req, res, next) {
if (isAuthed(req)) return next();
const isApi = (req.originalUrl || req.url || "").startsWith("/api/");
@@ -71,6 +113,13 @@ function requireAuth(req, res, next) {
return res.redirect("/login");
}
+// 需要管理员角色
+function requireAdmin(req, res, next) {
+ if (!isAuthed(req)) return res.status(401).json({ ok: false, error: "未登录" });
+ if (!isAdmin(req)) return res.status(403).json({ ok: false, error: "需要管理员权限" });
+ next();
+}
+
function logout(req) {
const cookies = parseCookies(req.headers.cookie);
const token = cookies[COOKIE_NAME];
@@ -78,14 +127,16 @@ function logout(req) {
}
function initPasswordLog() {
- if (generated) {
+ if (adminGenerated) {
logger.warn("========================================");
- logger.warn(`[AUTH] 未设置 PANEL_PASSWORD,已生成随机登录密码: ${panelPassword}`);
- logger.warn("[AUTH] 请用此密码登录面板,或在环境变量中设置 PANEL_PASSWORD 固定密码");
+ logger.warn(`[AUTH] 未设置 ADMIN_PASSWORD,已生成随机管理员密码: ${adminPassword}`);
+ logger.warn("[AUTH] 请用此密码登录面板,或在环境变量中设置 ADMIN_PASSWORD 固定密码");
logger.warn("========================================");
} else {
- logger.info("[AUTH] 已启用面板鉴权(PANEL_PASSWORD 已设置)");
+ logger.info("[AUTH] 已启用管理员鉴权(ADMIN_PASSWORD 已设置)");
}
+ if (userPassword) logger.info("[AUTH] 已启用用户密码(USER_PASSWORD 已设置)");
+ else logger.info("[AUTH] 未设置 USER_PASSWORD,当前仅管理员可登录");
}
module.exports = {
@@ -94,9 +145,15 @@ module.exports = {
verifyLogin,
getValidSession,
isAuthed,
+ getRole,
+ isAdmin,
setSessionCookie,
clearSessionCookie,
requireAuth,
+ requireAdmin,
logout,
initPasswordLog,
+ isLocked,
+ recordFail,
+ clearFail,
};
\ No newline at end of file
diff --git a/src/config.js b/src/config.js
index 29d495c..ec8e5ef 100644
--- a/src/config.js
+++ b/src/config.js
@@ -40,8 +40,14 @@ const config = {
LOG_LEVEL: envStr("LOG_LEVEL", "info"),
LOG_FILE: path.join(ROOT, "server.log"),
- PANEL_PASSWORD: envStr("PANEL_PASSWORD", ""),
+ ADMIN_PASSWORD: envStr("ADMIN_PASSWORD", envStr("PANEL_PASSWORD", "")),
+ USER_PASSWORD: envStr("USER_PASSWORD", ""),
SESSION_TTL_MS: envInt("SESSION_TTL_HOURS", 72) * 3600 * 1000,
+ LOGIN_MAX_FAILS: envInt("LOGIN_MAX_FAILS", 5),
+ LOGIN_LOCK_MS: envInt("LOGIN_LOCK_MIN", 10) * 60 * 1000,
+ SEARCH_CACHE_TTL_MS: envInt("SEARCH_CACHE_TTL_SEC", 300) * 1000,
+ SEARCH_RATE_MAX: envInt("SEARCH_RATE_MAX", 20),
+ SEARCH_RATE_WINDOW_MS: envInt("SEARCH_RATE_WINDOW_SEC", 60) * 1000,
};
config.LIVE_TICK_INTERVAL = config.HLS_TIME * 1000;
diff --git a/src/db.js b/src/db.js
index 94328bd..21e7dc4 100644
--- a/src/db.js
+++ b/src/db.js
@@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS songs (
name TEXT NOT NULL DEFAULT '',
artist TEXT NOT NULL DEFAULT '',
cover TEXT NOT NULL DEFAULT '',
+ cover_remote TEXT NOT NULL DEFAULT '',
duration REAL,
file_size INTEGER NOT NULL DEFAULT 0,
heat INTEGER NOT NULL DEFAULT 0,
@@ -47,6 +48,7 @@ CREATE TABLE IF NOT EXISTS settings (
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
+ role TEXT NOT NULL DEFAULT 'user',
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
@@ -61,6 +63,20 @@ function init() {
db.pragma("foreign_keys = ON");
db.pragma("busy_timeout = 5000");
db.exec(SCHEMA);
+
+ // 迁移:旧库 sessions 表补 role 列
+ const sessCols = db.prepare("PRAGMA table_info(sessions)").all();
+ if (!sessCols.some((c) => c.name === "role")) {
+ db.exec("ALTER TABLE sessions ADD COLUMN role TEXT NOT NULL DEFAULT 'user'");
+ logger.info("[DB] 迁移 sessions 表:新增 role 列");
+ }
+
+ // 迁移:songs 表补 cover_remote 列(远程封面 URL,优先展示,省带宽)
+ const songCols = db.prepare("PRAGMA table_info(songs)").all();
+ if (!songCols.some((c) => c.name === "cover_remote")) {
+ db.exec("ALTER TABLE songs ADD COLUMN cover_remote TEXT NOT NULL DEFAULT ''");
+ logger.info("[DB] 迁移 songs 表:新增 cover_remote 列");
+ }
logger.info(`[DB] 已打开 ${config.DB_FILE} (WAL)`);
ensureDefaultStream();
diff --git a/src/metadata.js b/src/metadata.js
index 05f4d2b..88f80a5 100644
--- a/src/metadata.js
+++ b/src/metadata.js
@@ -184,6 +184,8 @@ async function enrichSong(file, opts = {}) {
if (hit) {
if (needArtist && hit.artist) fields.artist = hit.artist;
if (needCover && hit.albumImage) {
+ // 远程 URL 优先(省带宽),本地下载作兜底
+ fields.cover_remote = hit.albumImage;
const cover = await downloadCover(hit.albumImage, fileHash(file));
if (cover) fields.cover = cover;
}
diff --git a/src/routes/auth.js b/src/routes/auth.js
index c529cde..04cb2ab 100644
--- a/src/routes/auth.js
+++ b/src/routes/auth.js
@@ -6,15 +6,27 @@ const logger = require("../logger");
const router = express.Router();
router.post("/login", (req, res) => {
+ const ip = req.ip || req.connection?.remoteAddress || "unknown";
+ const locked = auth.isLocked(ip);
+ if (locked > 0) {
+ return res.status(429).json({ ok: false, error: `登录失败次数过多,请 ${locked} 秒后再试` });
+ }
+
const { password } = req.body || {};
if (!password) return res.status(400).json({ ok: false, error: "请输入密码" });
- if (!auth.verifyLogin(password)) {
- logger.warn(`[AUTH] 登录失败 from ${req.ip}`);
+
+ const role = auth.verifyLogin(password);
+ if (!role) {
+ auth.recordFail(ip);
+ logger.warn(`[AUTH] 登录失败 from ${ip}`);
return res.status(401).json({ ok: false, error: "密码错误" });
}
- const { token, expiresAt } = auth.createSession();
+
+ auth.clearFail(ip);
+ const { token, expiresAt } = auth.createSession(role);
auth.setSessionCookie(res, token, expiresAt);
- res.json({ ok: true });
+ logger.info(`[AUTH] 登录成功 (${role}) from ${ip}`);
+ res.json({ ok: true, role });
});
router.post("/logout", (req, res) => {
@@ -24,7 +36,8 @@ router.post("/logout", (req, res) => {
});
router.get("/me", (req, res) => {
- res.json({ ok: true, authed: auth.isAuthed(req) });
+ const role = auth.getRole(req);
+ res.json({ ok: true, authed: !!role, role: role || null });
});
module.exports = router;
\ No newline at end of file
diff --git a/src/routes/music.js b/src/routes/music.js
index 9240602..41bc777 100644
--- a/src/routes/music.js
+++ b/src/routes/music.js
@@ -13,13 +13,48 @@ const { sanitizeFileName, isValidFileName, isAudioFile, fileHash, getDirSize } =
const router = express.Router();
+/* ==================== 搜索缓存 + 限流 ==================== */
+const searchCache = new Map(); // keyword -> { at, data }
+const searchRate = new Map(); // ip -> { count, resetAt }
+
+function searchRateLimited(ip) {
+ const now = Date.now();
+ const rec = searchRate.get(ip);
+ if (!rec || rec.resetAt < now) {
+ searchRate.set(ip, { count: 1, resetAt: now + config.SEARCH_RATE_WINDOW_MS });
+ return false;
+ }
+ rec.count += 1;
+ return rec.count > config.SEARCH_RATE_MAX;
+}
+
/* ==================== 网易云搜索/详情 ==================== */
router.get("/search", async (req, res) => {
const { keyword } = req.query;
if (!keyword || !String(keyword).trim())
return res.status(400).json({ ok: false, error: "缺少搜索关键词" });
+ const kw = String(keyword).trim();
+
+ // 命中缓存直接返回(不消耗限流额度)
+ const cached = searchCache.get(kw);
+ if (cached && Date.now() - cached.at < config.SEARCH_CACHE_TTL_MS) {
+ return res.json({ ok: true, ...cached.data, cached: true });
+ }
+
+ // 未命中 → 限流检查
+ const ip = req.ip || req.connection?.remoteAddress || "unknown";
+ if (searchRateLimited(ip)) {
+ return res.status(429).json({ ok: false, error: "搜索过于频繁,请稍后再试" });
+ }
+
try {
- const r = await meta.searchNetEase(String(keyword).trim());
+ const r = await meta.searchNetEase(kw);
+ searchCache.set(kw, { at: Date.now(), data: r });
+ // 缓存上限,防内存膨胀
+ if (searchCache.size > 200) {
+ const oldest = [...searchCache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
+ if (oldest) searchCache.delete(oldest[0]);
+ }
res.json({ ok: true, ...r });
} catch (e) {
res.status(400).json({ ok: false, error: e.message });
@@ -106,7 +141,7 @@ const upload = multer({
},
});
-router.post("/import", upload.single("audio"), async (req, res) => {
+router.post("/import", require("../auth").requireAdmin, upload.single("audio"), async (req, res) => {
if (!req.file) return res.status(400).json({ ok: false, error: "未收到文件" });
const tmpPath = req.file.path;
try {
diff --git a/src/routes/resources.js b/src/routes/resources.js
index 3adc72e..0f51a69 100644
--- a/src/routes/resources.js
+++ b/src/routes/resources.js
@@ -18,7 +18,8 @@ router.get("/", (req, res) => {
file: s.file,
title: s.name || s.file,
artist: s.artist || "",
- albumImage: s.cover || "",
+ albumImage: s.cover_remote || s.cover || "",
+ coverLocal: s.cover || "",
duration: s.duration || null,
fileSize: s.file_size || 0,
heat: s.heat || 0,
diff --git a/src/routes/streams.js b/src/routes/streams.js
index f7f5eb7..9025ae0 100644
--- a/src/routes/streams.js
+++ b/src/routes/streams.js
@@ -71,7 +71,8 @@ function snapshot(streamId) {
file: r.file,
title: r.name || r.file,
artist: r.artist || "",
- albumImage: r.cover || "",
+ albumImage: r.cover_remote || r.cover || "",
+ coverLocal: r.cover || "",
duration: r.duration || null,
heat: r.heat || 0,
cacheStatus: live.getStatus(r.file),
diff --git a/src/server.js b/src/server.js
index 7589ea4..948c1de 100644
--- a/src/server.js
+++ b/src/server.js
@@ -123,7 +123,7 @@ function createApp() {
app.use("/api/music", auth.requireAuth, musicRoutes);
app.use("/api/resources", auth.requireAuth, resourceRoutes);
app.use("/api/streams", auth.requireAuth, streamRoutes);
- app.use("/api/system", auth.requireAuth, systemRoutes);
+ app.use("/api/system", auth.requireAdmin, systemRoutes);
// 其余 /api 需要登录
app.use("/api", auth.requireAuth);
diff --git a/src/store.js b/src/store.js
index 4d73f02..f6ba8a8 100644
--- a/src/store.js
+++ b/src/store.js
@@ -18,11 +18,12 @@ function upsertSong(s) {
const ex = getSongByFile(s.file);
if (ex) {
d.prepare(
- `UPDATE songs SET name=?, artist=?, cover=?, duration=?, file_size=? WHERE file=?`
+ `UPDATE songs SET name=?, artist=?, cover=?, cover_remote=?, duration=?, file_size=? WHERE file=?`
).run(
s.name != null ? s.name : ex.name,
s.artist != null ? s.artist : ex.artist,
s.cover != null ? s.cover : ex.cover,
+ s.cover_remote != null ? s.cover_remote : (ex.cover_remote || ""),
s.duration != null ? s.duration : ex.duration,
s.file_size != null ? s.file_size : ex.file_size,
s.file
@@ -30,13 +31,14 @@ function upsertSong(s) {
return getSongByFile(s.file);
}
d.prepare(
- `INSERT INTO songs (file, name, artist, cover, duration, file_size, heat, cache_status, created_at)
- VALUES (?, ?, ?, ?, ?, ?, 0, 'pending', ?)`
+ `INSERT INTO songs (file, name, artist, cover, cover_remote, duration, file_size, heat, cache_status, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, 'pending', ?)`
).run(
s.file,
s.name || "",
s.artist || "",
s.cover || "",
+ s.cover_remote || "",
s.duration != null ? s.duration : null,
s.file_size || 0,
Date.now()
@@ -135,10 +137,10 @@ function clearPlaylist(streamId) {
}
/* ==================== sessions ==================== */
-function createSession(token, expiresAt) {
+function createSession(token, role, expiresAt) {
getDb()
- .prepare("INSERT INTO sessions (token, created_at, expires_at) VALUES (?, ?, ?)")
- .run(token, Date.now(), expiresAt);
+ .prepare("INSERT INTO sessions (token, role, created_at, expires_at) VALUES (?, ?, ?, ?)")
+ .run(token, role || "user", Date.now(), expiresAt);
}
function getSession(token) {
const s = getDb().prepare("SELECT * FROM sessions WHERE token=?").get(token);
diff --git a/start.cmd b/start.cmd
index c5f2815..4f2d5fa 100644
--- a/start.cmd
+++ b/start.cmd
@@ -1,3 +1,4 @@
@echo off
-set "PANEL_PASSWORD=test123"
+set "ADMIN_PASSWORD=test123"
+set "USER_PASSWORD=user123"
node index.js
\ No newline at end of file