v2: SQLite + 多路流 + 鉴权 + 导入/删除 + 资源本地化 + 模块化重构
This commit is contained in:
1 parent
fa6eeb25b9
commit
65b6f8cf62
30 files changed
+3506
-2231
No files matched your search
+110
@@ -0,0 +1,110 @@
|
||||
"use strict";
|
||||
const crypto = require("crypto");
|
||||
const config = require("./config");
|
||||
const logger = require("./logger");
|
||||
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;
|
||||
}
|
||||
|
||||
function getPassword() {
|
||||
return panelPassword;
|
||||
}
|
||||
function isGenerated() {
|
||||
return generated;
|
||||
}
|
||||
|
||||
function parseCookies(header) {
|
||||
const out = {};
|
||||
if (!header) return out;
|
||||
for (const part of header.split(";")) {
|
||||
const idx = part.indexOf("=");
|
||||
if (idx > -1) out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function createSession() {
|
||||
const token = crypto.randomBytes(24).toString("hex");
|
||||
const expiresAt = Date.now() + config.SESSION_TTL_MS;
|
||||
store.createSession(token, expiresAt);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 从请求里取有效会话
|
||||
function getValidSession(req) {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
const token = cookies[COOKIE_NAME];
|
||||
if (!token) return null;
|
||||
return store.getSession(token);
|
||||
}
|
||||
|
||||
function isAuthed(req) {
|
||||
return !!getValidSession(req);
|
||||
}
|
||||
|
||||
function setSessionCookie(res, token, expiresAt) {
|
||||
const maxAge = Math.floor((expiresAt - Date.now()) / 1000);
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
`${COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`
|
||||
);
|
||||
}
|
||||
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/");
|
||||
if (isApi) return res.status(401).json({ ok: false, error: "未登录" });
|
||||
return res.redirect("/login");
|
||||
}
|
||||
|
||||
function logout(req) {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
const token = cookies[COOKIE_NAME];
|
||||
if (token) store.deleteSession(token);
|
||||
}
|
||||
|
||||
function initPasswordLog() {
|
||||
if (generated) {
|
||||
logger.warn("========================================");
|
||||
logger.warn(`[AUTH] 未设置 PANEL_PASSWORD,已生成随机登录密码: ${panelPassword}`);
|
||||
logger.warn("[AUTH] 请用此密码登录面板,或在环境变量中设置 PANEL_PASSWORD 固定密码");
|
||||
logger.warn("========================================");
|
||||
} else {
|
||||
logger.info("[AUTH] 已启用面板鉴权(PANEL_PASSWORD 已设置)");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COOKIE_NAME,
|
||||
getPassword,
|
||||
isGenerated,
|
||||
createSession,
|
||||
verifyLogin,
|
||||
getValidSession,
|
||||
isAuthed,
|
||||
setSessionCookie,
|
||||
clearSessionCookie,
|
||||
requireAuth,
|
||||
logout,
|
||||
initPasswordLog,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
|
||||
function envInt(name, fallback) {
|
||||
const v = parseInt(process.env[name], 10);
|
||||
return Number.isFinite(v) && v > 0 ? v : fallback;
|
||||
}
|
||||
function envStr(name, fallback) {
|
||||
const v = process.env[name];
|
||||
return v === undefined || v === "" ? fallback : v;
|
||||
}
|
||||
function envBool(name, fallback) {
|
||||
const v = process.env[name];
|
||||
if (v === undefined) return fallback;
|
||||
return v === "true" || v === "1";
|
||||
}
|
||||
|
||||
const config = {
|
||||
ROOT,
|
||||
AUDIO_DIR: path.join(ROOT, "audio"),
|
||||
HLS_CACHE_DIR: path.join(ROOT, "hls_cache"),
|
||||
HLS_OUTPUT_DIR: path.join(ROOT, "hls"),
|
||||
PUBLIC_DIR: path.join(ROOT, "public"),
|
||||
COVERS_DIR: path.join(ROOT, "public", "covers"),
|
||||
VENDOR_DIR: path.join(ROOT, "public", "vendor"),
|
||||
DB_FILE: path.join(ROOT, "data.db"),
|
||||
UPLOAD_TMP_DIR: path.join(ROOT, "tmp", "uploads"),
|
||||
|
||||
HTTP_PORT: envInt("PORT", 3000),
|
||||
HLS_TIME: envInt("HLS_TIME", 5),
|
||||
SLIDE_WINDOW_SIZE: envInt("SLIDE_WINDOW", 15),
|
||||
MAX_CACHE_SIZE: envInt("MAX_CACHE_MB", 1000) * 1024 * 1024,
|
||||
MAX_FFMPEG_CONCURRENT: envInt("FFMPEG_CONCURRENCY", 3),
|
||||
MAX_RETRIES: envInt("MAX_RETRIES", 3),
|
||||
FFMPEG_TIMEOUT: envInt("FFMPEG_TIMEOUT_MS", 5 * 60 * 1000),
|
||||
USE_DISCONTINUITY: envBool("USE_DISCONTINUITY", false),
|
||||
|
||||
LOG_LEVEL: envStr("LOG_LEVEL", "info"),
|
||||
LOG_FILE: path.join(ROOT, "server.log"),
|
||||
|
||||
PANEL_PASSWORD: envStr("PANEL_PASSWORD", ""),
|
||||
SESSION_TTL_MS: envInt("SESSION_TTL_HOURS", 72) * 3600 * 1000,
|
||||
};
|
||||
|
||||
config.LIVE_TICK_INTERVAL = config.HLS_TIME * 1000;
|
||||
|
||||
module.exports = config;
|
||||
@@ -0,0 +1,91 @@
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const Database = require("better-sqlite3");
|
||||
const config = require("./config");
|
||||
const logger = require("./logger");
|
||||
|
||||
let db = null;
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS songs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
artist TEXT NOT NULL DEFAULT '',
|
||||
cover TEXT NOT NULL DEFAULT '',
|
||||
duration REAL,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
heat INTEGER NOT NULL DEFAULT 0,
|
||||
cache_status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS streams (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
current_pos INTEGER NOT NULL DEFAULT 0,
|
||||
media_seq INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stream_playlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stream_id TEXT NOT NULL,
|
||||
song_id INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
FOREIGN KEY(stream_id) REFERENCES streams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(song_id) REFERENCES songs(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sp_stream ON stream_playlist(stream_id, position);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_exp ON sessions(expires_at);
|
||||
`;
|
||||
|
||||
function init() {
|
||||
fs.mkdirSync(path.dirname(config.DB_FILE), { recursive: true });
|
||||
db = new Database(config.DB_FILE);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("synchronous = NORMAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
db.pragma("busy_timeout = 5000");
|
||||
db.exec(SCHEMA);
|
||||
logger.info(`[DB] 已打开 ${config.DB_FILE} (WAL)`);
|
||||
|
||||
ensureDefaultStream();
|
||||
purgeExpiredSessions();
|
||||
return db;
|
||||
}
|
||||
|
||||
function getDb() {
|
||||
if (!db) throw new Error("DB 未初始化,请先调用 db.init()");
|
||||
return db;
|
||||
}
|
||||
|
||||
function ensureDefaultStream() {
|
||||
const row = db.prepare("SELECT id FROM streams WHERE id = ?").get("main");
|
||||
if (!row) {
|
||||
db.prepare(
|
||||
"INSERT INTO streams (id, name, enabled, created_at) VALUES (?, ?, 1, ?)"
|
||||
).run("main", "主电台", Date.now());
|
||||
logger.info("[DB] 创建默认流 main");
|
||||
}
|
||||
}
|
||||
|
||||
function purgeExpiredSessions() {
|
||||
const r = db.prepare("DELETE FROM sessions WHERE expires_at < ?").run(Date.now());
|
||||
if (r.changes > 0) logger.info(`[DB] 清理过期会话 ${r.changes} 条`);
|
||||
}
|
||||
|
||||
module.exports = { init, getDb };
|
||||
@@ -0,0 +1,447 @@
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const config = require("./config");
|
||||
const logger = require("./logger");
|
||||
const store = require("./store");
|
||||
const media = require("./media");
|
||||
const { fileHash, atomicWriteSync } = require("./util");
|
||||
|
||||
/* ==================== 缓存状态管理(全局共享,因为切片只依赖歌曲) ==================== */
|
||||
const cacheStatus = new Map(); // file -> 'pending'|'ready'|'error'
|
||||
const retryCount = new Map(); // file -> number
|
||||
const segmentsCache = new Map(); // file -> segments[]
|
||||
const inFlight = new Set(); // 正在转码中的 file(避免重复入队)
|
||||
|
||||
let cacheTotalSize = 0;
|
||||
let onCacheChange = null; // 回调:cache-status 变化广播
|
||||
|
||||
function setCacheChangeListener(fn) {
|
||||
onCacheChange = fn;
|
||||
}
|
||||
function getCacheTotalSize() {
|
||||
return cacheTotalSize;
|
||||
}
|
||||
function addCacheSize(n) {
|
||||
cacheTotalSize += n;
|
||||
}
|
||||
|
||||
function setStatus(file, status) {
|
||||
cacheStatus.set(file, status);
|
||||
store.setCacheStatus(file, status);
|
||||
if (onCacheChange) onCacheChange(file, status);
|
||||
}
|
||||
function getStatus(file) {
|
||||
return cacheStatus.get(file) || "unknown";
|
||||
}
|
||||
|
||||
function getSegments(file) {
|
||||
if (segmentsCache.has(file)) return segmentsCache.get(file);
|
||||
const segs = media.loadSongSegments(file);
|
||||
if (segs) segmentsCache.set(file, segs);
|
||||
return segs;
|
||||
}
|
||||
function invalidateSegments(file) {
|
||||
segmentsCache.delete(file);
|
||||
}
|
||||
|
||||
// 触发预生成(带去重与重试上限)
|
||||
// 说明:pending 表示“待生成”,不阻止入队;用 inFlight 防止重复入队
|
||||
function triggerPreGenerate(file) {
|
||||
if (cacheStatus.get(file) === "ready") return;
|
||||
if (inFlight.has(file)) return;
|
||||
const retries = retryCount.get(file) || 0;
|
||||
if (retries >= config.MAX_RETRIES) {
|
||||
logger.warn(`[PRESEG] 跳过(已重试 ${config.MAX_RETRIES} 次): ${file}`);
|
||||
setStatus(file, "error");
|
||||
return;
|
||||
}
|
||||
retryCount.set(file, retries + 1);
|
||||
inFlight.add(file);
|
||||
setStatus(file, "pending");
|
||||
|
||||
const filePath = path.join(config.AUDIO_DIR, file);
|
||||
media
|
||||
.triggerPreGenerate(filePath, file)
|
||||
.then((res) => {
|
||||
inFlight.delete(file);
|
||||
if (res && res.size) cacheTotalSize += res.size;
|
||||
invalidateSegments(file);
|
||||
retryCount.delete(file);
|
||||
setStatus(file, "ready");
|
||||
if (onCacheSizeCheck) onCacheSizeCheck();
|
||||
})
|
||||
.catch(() => {
|
||||
inFlight.delete(file);
|
||||
setStatus(file, "error");
|
||||
});
|
||||
}
|
||||
|
||||
let onCacheSizeCheck = null;
|
||||
function setCacheSizeCheck(fn) {
|
||||
onCacheSizeCheck = fn;
|
||||
}
|
||||
|
||||
// 启动时扫描已有缓存
|
||||
function initCaches(songs) {
|
||||
cacheTotalSize = media.scanExistingCaches(songs);
|
||||
for (const song of songs) {
|
||||
const dir = media.getCacheDir(song.file);
|
||||
if (fs.existsSync(dir)) {
|
||||
const segs = media.loadSongSegments(song.file);
|
||||
if (segs) {
|
||||
cacheStatus.set(song.file, "ready");
|
||||
store.setCacheStatus(song.file, "ready");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
cacheStatus.set(song.file, "pending");
|
||||
store.setCacheStatus(song.file, "pending");
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 缓存清理 ==================== */
|
||||
function enforceCacheSizeLimit(allFiles) {
|
||||
if (cacheTotalSize <= config.MAX_CACHE_SIZE) return;
|
||||
logger.info(`[CACHE] ${(cacheTotalSize / 1024 / 1024).toFixed(1)}MB 超限,开始清理`);
|
||||
|
||||
// 收集所有流正在播放/窗口内的歌曲,作为保护集
|
||||
const protectedFiles = new Set();
|
||||
for (const s of streams.values()) {
|
||||
for (const f of s.protectedFiles()) protectedFiles.add(f);
|
||||
}
|
||||
|
||||
const items = [];
|
||||
const dirs = fs.readdirSync(config.HLS_CACHE_DIR);
|
||||
for (const entry of dirs) {
|
||||
const dir = path.join(config.HLS_CACHE_DIR, entry);
|
||||
try {
|
||||
if (!fs.statSync(dir).isDirectory()) continue;
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
let origFile = allFiles.find((f) => fileHash(f) === entry);
|
||||
const size = require("./util").getDirSize(dir);
|
||||
const song = origFile ? store.getSongByFile(origFile) : null;
|
||||
items.push({
|
||||
dir,
|
||||
size,
|
||||
origFile,
|
||||
isProtected: origFile ? protectedFiles.has(origFile) : false,
|
||||
heat: song ? song.heat || 0 : 0,
|
||||
});
|
||||
}
|
||||
items.sort((a, b) => {
|
||||
if (a.isProtected !== b.isProtected) return a.isProtected ? 1 : -1;
|
||||
return a.heat - b.heat;
|
||||
});
|
||||
for (const item of items) {
|
||||
if (cacheTotalSize <= config.MAX_CACHE_SIZE) break;
|
||||
logger.info(`[CACHE] 清理: ${path.basename(item.dir)} (${(item.size / 1024 / 1024).toFixed(1)}MB)`);
|
||||
fs.rmSync(item.dir, { recursive: true, force: true });
|
||||
cacheTotalSize = Math.max(0, cacheTotalSize - item.size);
|
||||
if (item.origFile) {
|
||||
invalidateSegments(item.origFile);
|
||||
setStatus(item.origFile, "pending");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== LiveStream ==================== */
|
||||
const streams = new Map(); // id -> LiveStream
|
||||
|
||||
class LiveStream {
|
||||
constructor(id, name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.liveSegments = []; // { duration, path, songId(file), songFile }
|
||||
this.mediaSequence = 0;
|
||||
this.currentIdx = 0; // 在播放列表行中的索引
|
||||
this.currentSongSegmentIdx = 0;
|
||||
this.timer = null;
|
||||
this.outDir = path.join(config.HLS_OUTPUT_DIR, id);
|
||||
fs.mkdirSync(this.outDir, { recursive: true });
|
||||
}
|
||||
|
||||
get m3u8Path() {
|
||||
return path.join(this.outDir, "index.m3u8");
|
||||
}
|
||||
|
||||
// 当前受保护的歌曲文件(窗口内 + 接下来2首)
|
||||
protectedFiles() {
|
||||
const set = new Set();
|
||||
for (const seg of this.liveSegments) set.add(seg.songFile);
|
||||
const rows = store.getPlaylistRows(this.id);
|
||||
for (let i = 0; i < Math.min(3, rows.length); i++) {
|
||||
const idx = (this.currentIdx + i) % rows.length;
|
||||
if (rows[idx]) set.add(rows[idx].file);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
playlistRows() {
|
||||
return store.getPlaylistRows(this.id);
|
||||
}
|
||||
|
||||
ensureNotEmpty() {
|
||||
let rows = this.playlistRows();
|
||||
if (rows.length === 0) {
|
||||
const songs = store.getAllSongs();
|
||||
if (songs.length > 0) {
|
||||
const pick = songs[Math.floor(Math.random() * songs.length)];
|
||||
store.addToPlaylist(this.id, pick.id);
|
||||
this.currentIdx = 0;
|
||||
this.currentSongSegmentIdx = 0;
|
||||
rows = this.playlistRows();
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
clampIdx(rows) {
|
||||
if (rows.length === 0) {
|
||||
this.currentIdx = 0;
|
||||
this.currentSongSegmentIdx = 0;
|
||||
return;
|
||||
}
|
||||
if (this.currentIdx < 0) this.currentIdx = 0;
|
||||
if (this.currentIdx >= rows.length) {
|
||||
this.currentIdx = 0;
|
||||
this.currentSongSegmentIdx = 0;
|
||||
}
|
||||
}
|
||||
|
||||
advance() {
|
||||
const allSongs = store.getAllSongs();
|
||||
if (allSongs.length === 0) return;
|
||||
|
||||
let rows = this.ensureNotEmpty();
|
||||
if (rows.length === 0) return;
|
||||
|
||||
let safety = rows.length + 2;
|
||||
while (safety-- > 0) {
|
||||
this.clampIdx(rows);
|
||||
const row = rows[this.currentIdx];
|
||||
if (!row) return;
|
||||
const file = row.file;
|
||||
|
||||
const status = getStatus(file);
|
||||
if (status !== "ready") {
|
||||
if (status !== "pending") triggerPreGenerate(file);
|
||||
this.currentIdx = (this.currentIdx + 1) % rows.length;
|
||||
this.currentSongSegmentIdx = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const segments = getSegments(file);
|
||||
if (!segments || segments.length === 0) {
|
||||
this.currentIdx = (this.currentIdx + 1) % rows.length;
|
||||
this.currentSongSegmentIdx = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 歌曲播完 → 从播放列表移除该条目
|
||||
if (this.currentSongSegmentIdx >= segments.length) {
|
||||
const finishedName = file;
|
||||
store.removePlaylistEntry(this.id, row.entry_id);
|
||||
invalidateSegments(file);
|
||||
this.currentSongSegmentIdx = 0;
|
||||
|
||||
rows = this.ensureNotEmpty();
|
||||
if (rows.length === 0) return;
|
||||
this.clampIdx(rows);
|
||||
if (onPlaylistChange) onPlaylistChange(this.id);
|
||||
if (onSongFinished) onSongFinished(this.id, finishedName);
|
||||
|
||||
const nf = rows[this.currentIdx] && rows[this.currentIdx].file;
|
||||
if (nf && getStatus(nf) !== "ready") triggerPreGenerate(nf);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 正常追加切片
|
||||
const seg = segments[this.currentSongSegmentIdx];
|
||||
const songId = fileHash(file);
|
||||
this.liveSegments.push({
|
||||
duration: seg.duration,
|
||||
path: path.posix.join("/hls_cache", songId, seg.fileName),
|
||||
songId,
|
||||
songFile: file,
|
||||
});
|
||||
while (this.liveSegments.length > config.SLIDE_WINDOW_SIZE) {
|
||||
this.liveSegments.shift();
|
||||
this.mediaSequence++;
|
||||
}
|
||||
this.currentSongSegmentIdx++;
|
||||
this.writePlaylist();
|
||||
store.setStreamState(this.id, { media_seq: this.mediaSequence });
|
||||
return;
|
||||
}
|
||||
logger.warn(`[LIVE:${this.id}] 所有歌曲均不可用`);
|
||||
}
|
||||
|
||||
writePlaylist() {
|
||||
if (this.liveSegments.length === 0) {
|
||||
atomicWriteSync(
|
||||
this.m3u8Path,
|
||||
`#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:${config.HLS_TIME + 1}\n` +
|
||||
`#EXT-X-INDEPENDENT-SEGMENTS\n#EXT-X-MEDIA-SEQUENCE:0\n`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const maxDur = this.liveSegments.reduce((m, s) => Math.max(m, s.duration), config.HLS_TIME);
|
||||
const targetDur = Math.max(config.HLS_TIME + 1, Math.ceil(maxDur) + 1);
|
||||
let content = "#EXTM3U\n#EXT-X-VERSION:3\n";
|
||||
content += `#EXT-X-TARGETDURATION:${targetDur}\n`;
|
||||
content += "#EXT-X-INDEPENDENT-SEGMENTS\n";
|
||||
content += `#EXT-X-MEDIA-SEQUENCE:${this.mediaSequence}\n`;
|
||||
for (let i = 0; i < this.liveSegments.length; i++) {
|
||||
if (config.USE_DISCONTINUITY && i > 0 && this.liveSegments[i].songId !== this.liveSegments[i - 1].songId) {
|
||||
content += "#EXT-X-DISCONTINUITY\n";
|
||||
}
|
||||
content += `#EXTINF:${this.liveSegments[i].duration.toFixed(3)},\n${this.liveSegments[i].path}\n`;
|
||||
}
|
||||
atomicWriteSync(this.m3u8Path, content);
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
let attempts = 0;
|
||||
const target = Math.floor(config.SLIDE_WINDOW_SIZE / 2);
|
||||
while (this.liveSegments.length < target && attempts < config.SLIDE_WINDOW_SIZE * 2) {
|
||||
const prev = this.liveSegments.length;
|
||||
this.advance();
|
||||
attempts++;
|
||||
if (this.liveSegments.length === prev && this.liveSegments.length === 0) break;
|
||||
}
|
||||
this.timer = setInterval(() => this.advance(), config.LIVE_TICK_INTERVAL);
|
||||
logger.info(
|
||||
`[LIVE:${this.id}] 已启动 | 切片 ${config.HLS_TIME}s | 窗口 ${config.SLIDE_WINDOW_SIZE} 段 | 预填充 ${this.liveSegments.length} 段`
|
||||
);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
logger.info(`[LIVE:${this.id}] 已停止`);
|
||||
}
|
||||
|
||||
resetCursor() {
|
||||
this.currentSongSegmentIdx = 0;
|
||||
}
|
||||
|
||||
next() {
|
||||
const rows = this.playlistRows();
|
||||
if (rows.length === 0) return null;
|
||||
this.currentIdx = (this.currentIdx + 1) % rows.length;
|
||||
this.currentSongSegmentIdx = 0;
|
||||
const f = rows[this.currentIdx].file;
|
||||
if (getStatus(f) !== "ready") triggerPreGenerate(f);
|
||||
return f;
|
||||
}
|
||||
prev() {
|
||||
const rows = this.playlistRows();
|
||||
if (rows.length === 0) return null;
|
||||
this.currentIdx = (this.currentIdx - 1 + rows.length) % rows.length;
|
||||
this.currentSongSegmentIdx = 0;
|
||||
const f = rows[this.currentIdx].file;
|
||||
if (getStatus(f) !== "ready") triggerPreGenerate(f);
|
||||
return f;
|
||||
}
|
||||
jump(index) {
|
||||
const rows = this.playlistRows();
|
||||
if (index < 0 || index >= rows.length) return null;
|
||||
this.currentIdx = index;
|
||||
this.currentSongSegmentIdx = 0;
|
||||
const f = rows[this.currentIdx].file;
|
||||
if (getStatus(f) !== "ready") triggerPreGenerate(f);
|
||||
return f;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 回调 ==================== */
|
||||
let onPlaylistChange = null;
|
||||
let onSongFinished = null;
|
||||
function setStreamCallbacks({ playlistChange, songFinished }) {
|
||||
onPlaylistChange = playlistChange;
|
||||
onSongFinished = songFinished;
|
||||
}
|
||||
|
||||
/* ==================== 管理器 ==================== */
|
||||
function loadStreams() {
|
||||
const rows = store.listStreams();
|
||||
for (const r of rows) {
|
||||
const s = new LiveStream(r.id, r.name);
|
||||
s.currentIdx = r.current_pos || 0;
|
||||
s.mediaSequence = r.media_seq || 0;
|
||||
streams.set(r.id, s);
|
||||
}
|
||||
logger.info(`[LIVE] 载入 ${streams.size} 路流: ${[...streams.keys()].join(", ")}`);
|
||||
}
|
||||
|
||||
function createStream(id, name) {
|
||||
if (streams.has(id)) throw new Error("流已存在");
|
||||
store.createStream(id, name);
|
||||
const s = new LiveStream(id, name);
|
||||
streams.set(id, s);
|
||||
s.start();
|
||||
return s;
|
||||
}
|
||||
|
||||
function deleteStream(id) {
|
||||
if (id === "main") throw new Error("默认流不可删除");
|
||||
const s = streams.get(id);
|
||||
if (s) {
|
||||
s.stop();
|
||||
streams.delete(id);
|
||||
}
|
||||
store.deleteStream(id);
|
||||
// 删输出目录
|
||||
const dir = path.join(config.HLS_OUTPUT_DIR, id);
|
||||
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function getStream(id) {
|
||||
return streams.get(id);
|
||||
}
|
||||
function listStreams() {
|
||||
return [...streams.values()].map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
running: !!s.timer,
|
||||
m3u8: `/hls/${s.id}/index.m3u8`,
|
||||
}));
|
||||
}
|
||||
|
||||
function startAll() {
|
||||
for (const s of streams.values()) s.start();
|
||||
}
|
||||
function stopAll() {
|
||||
for (const s of streams.values()) s.stop();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LiveStream,
|
||||
streams,
|
||||
loadStreams,
|
||||
createStream,
|
||||
deleteStream,
|
||||
getStream,
|
||||
listStreams,
|
||||
startAll,
|
||||
stopAll,
|
||||
// cache API
|
||||
getStatus,
|
||||
setStatus,
|
||||
getSegments,
|
||||
invalidateSegments,
|
||||
triggerPreGenerate,
|
||||
initCaches,
|
||||
enforceCacheSizeLimit,
|
||||
getCacheTotalSize,
|
||||
addCacheSize,
|
||||
setCacheChangeListener,
|
||||
setCacheSizeCheck,
|
||||
setStreamCallbacks,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const config = require("./config");
|
||||
|
||||
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
|
||||
const levelNum = LEVELS[config.LOG_LEVEL] || LEVELS.info;
|
||||
|
||||
let stream = null;
|
||||
try {
|
||||
stream = fs.createWriteStream(config.LOG_FILE, { flags: "a" });
|
||||
} catch (_) {}
|
||||
|
||||
const ring = [];
|
||||
const RING_MAX = 500;
|
||||
|
||||
function fmt(level, args) {
|
||||
const ts = new Date().toISOString();
|
||||
const msg = args
|
||||
.map((a) => {
|
||||
if (typeof a === "string") return a;
|
||||
if (a instanceof Error) return a.stack || a.message;
|
||||
try {
|
||||
return JSON.stringify(a);
|
||||
} catch (_) {
|
||||
return String(a);
|
||||
}
|
||||
})
|
||||
.join(" ");
|
||||
return `[${ts}] [${level.toUpperCase()}] ${msg}`;
|
||||
}
|
||||
|
||||
function emit(level, args) {
|
||||
if (LEVELS[level] < levelNum) return;
|
||||
const line = fmt(level, args);
|
||||
const fn =
|
||||
level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
||||
fn(line);
|
||||
if (stream) stream.write(line + "\n");
|
||||
ring.push(line);
|
||||
if (ring.length > RING_MAX) ring.shift();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
debug: (...a) => emit("debug", a),
|
||||
info: (...a) => emit("info", a),
|
||||
warn: (...a) => emit("warn", a),
|
||||
error: (...a) => emit("error", a),
|
||||
tail: (n = 100) => ring.slice(-n),
|
||||
};
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const config = require("./config");
|
||||
const logger = require("./logger");
|
||||
const { fileHash, getDirSize } = require("./util");
|
||||
|
||||
// 追踪活跃的 FFmpeg/ffprobe 子进程,便于优雅退出时统一 kill
|
||||
const activeProcs = new Set();
|
||||
function trackProc(proc) {
|
||||
activeProcs.add(proc);
|
||||
proc.on("close", () => activeProcs.delete(proc));
|
||||
proc.on("error", () => activeProcs.delete(proc));
|
||||
return proc;
|
||||
}
|
||||
function killAllProcs() {
|
||||
let n = 0;
|
||||
for (const p of activeProcs) {
|
||||
try {
|
||||
p.kill("SIGKILL");
|
||||
n++;
|
||||
} catch (_) {}
|
||||
}
|
||||
activeProcs.clear();
|
||||
if (n) logger.warn(`[MEDIA] 已终止 ${n} 个残留 FFmpeg 进程`);
|
||||
}
|
||||
class TaskQueue {
|
||||
constructor(max) {
|
||||
this.max = max;
|
||||
this.running = 0;
|
||||
this.queue = [];
|
||||
}
|
||||
push(fn) {
|
||||
this.queue.push(fn);
|
||||
this._drain();
|
||||
}
|
||||
_drain() {
|
||||
while (this.running < this.max && this.queue.length > 0) {
|
||||
this.running++;
|
||||
const task = this.queue.shift();
|
||||
task()
|
||||
.catch((e) => logger.error("[QUEUE]", e.message))
|
||||
.finally(() => {
|
||||
this.running--;
|
||||
this._drain();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const ffmpegQueue = new TaskQueue(config.MAX_FFMPEG_CONCURRENT);
|
||||
|
||||
/* ==================== 缓存目录 ==================== */
|
||||
function getCacheDir(file) {
|
||||
return path.join(config.HLS_CACHE_DIR, fileHash(file));
|
||||
}
|
||||
|
||||
/* ==================== ffprobe ==================== */
|
||||
function probeAudio(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = trackProc(spawn("ffprobe", [
|
||||
"-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=codec_name,sample_rate,channels:format=duration",
|
||||
"-of", "json",
|
||||
filePath,
|
||||
]));
|
||||
let out = "", err = "";
|
||||
proc.stdout.on("data", (d) => (out += d));
|
||||
proc.stderr.on("data", (d) => (err += d));
|
||||
proc.on("close", (code) => {
|
||||
if (code !== 0) return reject(new Error(err));
|
||||
try {
|
||||
const data = JSON.parse(out);
|
||||
const s = data.streams?.[0];
|
||||
if (!s) return reject(new Error("无音频流"));
|
||||
const dur =
|
||||
data.format && data.format.duration ? parseFloat(data.format.duration) : null;
|
||||
resolve({
|
||||
codec: s.codec_name,
|
||||
sampleRate: parseInt(s.sample_rate, 10),
|
||||
channels: s.channels,
|
||||
duration: dur,
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
proc.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/* ==================== 解析切片 m3u8 ==================== */
|
||||
function loadSongSegments(file) {
|
||||
const m3u8 = path.join(getCacheDir(file), "index.m3u8");
|
||||
if (!fs.existsSync(m3u8)) return null;
|
||||
try {
|
||||
const lines = fs
|
||||
.readFileSync(m3u8, "utf8")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const segments = [];
|
||||
let dur = config.HLS_TIME;
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("#EXTINF:")) {
|
||||
dur = parseFloat(line.split(":")[1].replace(",", ""));
|
||||
} else if (!line.startsWith("#") && line.endsWith(".ts")) {
|
||||
segments.push({ duration: dur, fileName: path.basename(line) });
|
||||
}
|
||||
}
|
||||
return segments.length ? segments : null;
|
||||
} catch (e) {
|
||||
logger.error("[SEG]", file, e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 预生成切片 ==================== */
|
||||
let onCacheReady = null;
|
||||
let onCacheError = null;
|
||||
function setCacheCallbacks({ ready, error }) {
|
||||
onCacheReady = ready;
|
||||
onCacheError = error;
|
||||
}
|
||||
|
||||
async function buildAudioArgs(filePath) {
|
||||
try {
|
||||
const probe = await probeAudio(filePath);
|
||||
if (probe.codec === "aac" && probe.sampleRate === 44100 && probe.channels <= 2) {
|
||||
logger.info(`[PRESEG] copy: ${path.basename(filePath)}`);
|
||||
return { args: ["-c:a", "copy"], duration: probe.duration };
|
||||
}
|
||||
logger.info(
|
||||
`[PRESEG] 转码: ${path.basename(filePath)} (${probe.codec} ${probe.sampleRate}Hz)`
|
||||
);
|
||||
return {
|
||||
args: ["-c:a", "aac", "-b:a", "192k", "-ar", "44100", "-ac", "2", "-profile:a", "aac_low"],
|
||||
duration: probe.duration,
|
||||
};
|
||||
} catch (_) {
|
||||
return {
|
||||
args: ["-c:a", "aac", "-b:a", "192k", "-ar", "44100", "-ac", "2", "-profile:a", "aac_low"],
|
||||
duration: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 真正执行一次转码(内部用,返回 Promise<{size}>);调用方负责队列并发
|
||||
function doGenerate(filePath, file) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const cacheDir = getCacheDir(file);
|
||||
if (fs.existsSync(cacheDir)) fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
|
||||
const { args: audioArgs } = await buildAudioArgs(filePath);
|
||||
const outputM3u8 = path.join(cacheDir, "index.m3u8");
|
||||
const args = [
|
||||
"-y",
|
||||
"-fflags", "+genpts+discardcorrupt",
|
||||
"-i", filePath,
|
||||
...audioArgs,
|
||||
"-vn",
|
||||
"-avoid_negative_ts", "make_zero",
|
||||
"-hls_time", String(config.HLS_TIME),
|
||||
"-hls_list_size", "0",
|
||||
"-hls_segment_type", "mpegts",
|
||||
"-hls_flags", "independent_segments",
|
||||
"-max_muxing_queue_size", "2048",
|
||||
"-f", "hls",
|
||||
outputM3u8,
|
||||
];
|
||||
const proc = trackProc(spawn("ffmpeg", args));
|
||||
let stderrBuf = "";
|
||||
proc.stderr.on("data", (d) => {
|
||||
stderrBuf += d.toString();
|
||||
if (stderrBuf.length > 4096) stderrBuf = stderrBuf.slice(-4096);
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill("SIGKILL");
|
||||
logger.error(`[PRESEG] 超时终止: ${path.basename(filePath)}`);
|
||||
}, config.FFMPEG_TIMEOUT);
|
||||
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
const size = getDirSize(cacheDir);
|
||||
logger.info(`[PRESEG] 完成: ${path.basename(filePath)}`);
|
||||
if (onCacheReady) onCacheReady(file, size);
|
||||
resolve({ size });
|
||||
} else {
|
||||
if (fs.existsSync(cacheDir)) fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
logger.error(`[PRESEG] 失败 code=${code}: ${path.basename(filePath)}`);
|
||||
if (onCacheError) onCacheError(file, stderrBuf);
|
||||
reject(new Error(`FFmpeg exit ${code}`));
|
||||
}
|
||||
});
|
||||
proc.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
if (onCacheError) onCacheError(file, err.message);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 外部调用:把 doGenerate 丢进并发队列,返回真正在完成后 resolve 的 Promise
|
||||
function triggerPreGenerate(filePath, file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpegQueue.push(() => doGenerate(filePath, file).then(resolve, reject));
|
||||
});
|
||||
}
|
||||
|
||||
/* ==================== 启动扫描 ==================== */
|
||||
function scanExistingCaches(songs) {
|
||||
let total = 0;
|
||||
let valid = 0;
|
||||
for (const song of songs) {
|
||||
const dir = getCacheDir(song.file);
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
const segs = loadSongSegments(song.file);
|
||||
if (segs && segs.length > 0) {
|
||||
const allValid = segs.every((s) => {
|
||||
const p = path.join(dir, s.fileName);
|
||||
try {
|
||||
return fs.existsSync(p) && fs.statSync(p).size > 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (allValid) {
|
||||
total += getDirSize(dir);
|
||||
valid++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
logger.info(`[CACHE] 启动扫描: ${valid}/${songs.length} 个缓存有效,总 ${(total / 1024 / 1024).toFixed(1)}MB`);
|
||||
return total;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ffmpegQueue,
|
||||
getCacheDir,
|
||||
probeAudio,
|
||||
loadSongSegments,
|
||||
triggerPreGenerate,
|
||||
scanExistingCaches,
|
||||
setCacheCallbacks,
|
||||
killAllProcs,
|
||||
};
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const config = require("./config");
|
||||
const logger = require("./logger");
|
||||
const store = require("./store");
|
||||
const media = require("./media");
|
||||
const { fileHash, sanitizeFileName, parseFileName, isAudioFile } = require("./util");
|
||||
|
||||
const NETEASE_UA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36";
|
||||
const NETEASE_REFERER = "http://music.163.com/";
|
||||
|
||||
/* ==================== HTTP 工具 ==================== */
|
||||
function request(url, redirects = 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const lib = url.startsWith("https:") ? https : http;
|
||||
const req = lib.get(
|
||||
url,
|
||||
{ headers: { "User-Agent": NETEASE_UA, Referer: NETEASE_REFERER } },
|
||||
(res) => {
|
||||
const code = res.statusCode;
|
||||
if ([301, 302, 303, 307, 308].includes(code) && res.headers.location) {
|
||||
res.resume();
|
||||
const loc = res.headers.location;
|
||||
if (loc.includes("/404")) return reject(new Error("该歌曲无版权或需 VIP,无法下载"));
|
||||
if (redirects >= 5) return reject(new Error("重定向次数过多"));
|
||||
return resolve(request(new URL(loc, url).toString(), redirects + 1));
|
||||
}
|
||||
if (code !== 200) {
|
||||
res.resume();
|
||||
return reject(new Error(`HTTP ${code}`));
|
||||
}
|
||||
resolve(res);
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.setTimeout(15000, () => req.destroy(new Error("请求超时")));
|
||||
});
|
||||
}
|
||||
|
||||
function readBody(res) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (d) => (body += d));
|
||||
res.on("end", () => resolve(body));
|
||||
res.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/* ==================== 网易云 API ==================== */
|
||||
async function fetchNetEaseDetail(id) {
|
||||
const url = `http://music.163.com/api/song/detail/?id=${id}&ids=[${id}]`;
|
||||
const res = await request(url);
|
||||
const json = JSON.parse((await readBody(res)).replace(/^/, ""));
|
||||
const song = json.songs && json.songs[0];
|
||||
if (!song) throw new Error("未找到歌曲(可能是无版权/灰色歌曲)");
|
||||
return {
|
||||
name: song.name,
|
||||
artist: (song.artists && song.artists[0] && song.artists[0].name) || "",
|
||||
albumImage: (song.album && song.album.blurPicUrl) || "",
|
||||
};
|
||||
}
|
||||
|
||||
async function searchNetEase(keyword, offset = 0, limit = 20) {
|
||||
const url =
|
||||
"http://music.163.com/api/search/get/web?csrf_token=hlpretag=&hlposttag=" +
|
||||
"&s=" + encodeURIComponent(keyword) +
|
||||
"&type=1&offset=" + offset + "&total=true&limit=" + limit;
|
||||
const res = await request(url);
|
||||
const json = JSON.parse((await readBody(res)).replace(/^/, ""));
|
||||
const songs = (json.result && json.result.songs) || [];
|
||||
const list = songs
|
||||
.filter((s) => Number(s.fee) !== 1)
|
||||
.map((s) => {
|
||||
const item = {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
artists: (s.artists || []).map((a) => a.name).filter(Boolean),
|
||||
duration: s.duration,
|
||||
};
|
||||
if (s.alias && s.alias.length) item.alias = s.alias;
|
||||
return item;
|
||||
});
|
||||
return { total: list.length, list };
|
||||
}
|
||||
|
||||
// 用 歌名(+作者) 匹配网易云,返回 {artist, albumImage, name}
|
||||
async function matchNetEase(name, guessArtist) {
|
||||
const q = guessArtist ? `${name} ${guessArtist}` : name;
|
||||
try {
|
||||
const r = await searchNetEase(q, 0, 10);
|
||||
if (!r.list.length) return null;
|
||||
const hit = r.list[0];
|
||||
const artist = (hit.artists && hit.artists[0]) || guessArtist || "";
|
||||
// 用详情拿封面
|
||||
let coverUrl = "";
|
||||
try {
|
||||
const d = await fetchNetEaseDetail(hit.id);
|
||||
coverUrl = d.albumImage || "";
|
||||
return { name: hit.name, artist: d.artist || artist, albumImage: coverUrl };
|
||||
} catch (_) {
|
||||
return { name: hit.name, artist, albumImage: "" };
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn("[MATCH] 网易云匹配失败:", e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 封面本地化 ==================== */
|
||||
function extFromContentType(ct) {
|
||||
if (!ct) return ".jpg";
|
||||
if (ct.includes("png")) return ".png";
|
||||
if (ct.includes("webp")) return ".webp";
|
||||
return ".jpg";
|
||||
}
|
||||
|
||||
// 下载远程图片到 public/covers/{key}{ext},返回 web 路径 /covers/xxx
|
||||
function downloadCover(url, key) {
|
||||
return new Promise((resolve) => {
|
||||
if (!url) return resolve("");
|
||||
fs.mkdirSync(config.COVERS_DIR, { recursive: true });
|
||||
const lib = url.startsWith("https:") ? https : http;
|
||||
const req = lib.get(
|
||||
url,
|
||||
{ headers: { "User-Agent": NETEASE_UA, Referer: NETEASE_REFERER } },
|
||||
(res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
return resolve("");
|
||||
}
|
||||
const ext = extFromContentType(res.headers["content-type"]);
|
||||
const fname = `${key}${ext}`;
|
||||
const dest = path.join(config.COVERS_DIR, fname);
|
||||
const ws = fs.createWriteStream(dest);
|
||||
res.pipe(ws);
|
||||
ws.on("finish", () => resolve(`/covers/${fname}`));
|
||||
ws.on("error", () => resolve(""));
|
||||
}
|
||||
);
|
||||
req.on("error", () => resolve(""));
|
||||
req.setTimeout(15000, () => req.destroy());
|
||||
});
|
||||
}
|
||||
|
||||
/* ==================== 元数据补全 ==================== */
|
||||
// 为某个已入库音频补全元数据(歌名/作者/封面/时长)
|
||||
// 策略:文件名解析打底 → 用户提供优先 → 缺作者/封面时匹配网易云
|
||||
async function enrichSong(file, opts = {}) {
|
||||
let song = store.getSongByFile(file);
|
||||
const filePath = path.join(config.AUDIO_DIR, file);
|
||||
const parsed = parseFileName(file);
|
||||
const fields = {};
|
||||
|
||||
// 歌名:用户提供 > 已存 > 文件名解析
|
||||
if (opts.name) fields.name = opts.name;
|
||||
else if (!song || !song.name) fields.name = parsed.name;
|
||||
|
||||
// 作者:用户提供 > 文件名解析 > (稍后匹配)
|
||||
if (opts.artist) fields.artist = opts.artist;
|
||||
else if (!song || !song.artist) {
|
||||
if (parsed.artist) fields.artist = parsed.artist;
|
||||
}
|
||||
|
||||
// 时长
|
||||
try {
|
||||
const probe = await media.probeAudio(filePath);
|
||||
if (probe.duration != null) fields.duration = probe.duration;
|
||||
} catch (_) {}
|
||||
try {
|
||||
fields.file_size = fs.statSync(filePath).size;
|
||||
} catch (_) {}
|
||||
|
||||
// 若缺作者或缺封面,尝试网易云匹配
|
||||
const needArtist = !(fields.artist || (song && song.artist));
|
||||
const needCover = !(song && song.cover) && !opts.cover;
|
||||
if ((needArtist || needCover) && !opts.skipMatch) {
|
||||
const guessArtist = fields.artist || (song && song.artist) || parsed.artist || "";
|
||||
const hit = await matchNetEase(fields.name || (song && song.name) || parsed.name, guessArtist);
|
||||
if (hit) {
|
||||
if (needArtist && hit.artist) fields.artist = hit.artist;
|
||||
if (needCover && hit.albumImage) {
|
||||
const cover = await downloadCover(hit.albumImage, fileHash(file));
|
||||
if (cover) fields.cover = cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (song) {
|
||||
store.updateSong(file, fields);
|
||||
return store.getSongByFile(file);
|
||||
} else {
|
||||
return store.upsertSong({ file, ...fields });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
request,
|
||||
fetchNetEaseDetail,
|
||||
searchNetEase,
|
||||
matchNetEase,
|
||||
downloadCover,
|
||||
enrichSong,
|
||||
sanitizeFileName,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
"use strict";
|
||||
const express = require("express");
|
||||
const auth = require("../auth");
|
||||
const logger = require("../logger");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/login", (req, res) => {
|
||||
const { password } = req.body || {};
|
||||
if (!password) return res.status(400).json({ ok: false, error: "请输入密码" });
|
||||
if (!auth.verifyLogin(password)) {
|
||||
logger.warn(`[AUTH] 登录失败 from ${req.ip}`);
|
||||
return res.status(401).json({ ok: false, error: "密码错误" });
|
||||
}
|
||||
const { token, expiresAt } = auth.createSession();
|
||||
auth.setSessionCookie(res, token, expiresAt);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post("/logout", (req, res) => {
|
||||
auth.logout(req);
|
||||
auth.clearSessionCookie(res);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get("/me", (req, res) => {
|
||||
res.json({ ok: true, authed: auth.isAuthed(req) });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,233 @@
|
||||
"use strict";
|
||||
const express = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const multer = require("multer");
|
||||
const config = require("../config");
|
||||
const logger = require("../logger");
|
||||
const store = require("../store");
|
||||
const media = require("../media");
|
||||
const live = require("../livestream");
|
||||
const meta = require("../metadata");
|
||||
const { sanitizeFileName, isValidFileName, isAudioFile, fileHash, getDirSize } = require("../util");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/* ==================== 网易云搜索/详情 ==================== */
|
||||
router.get("/search", async (req, res) => {
|
||||
const { keyword } = req.query;
|
||||
if (!keyword || !String(keyword).trim())
|
||||
return res.status(400).json({ ok: false, error: "缺少搜索关键词" });
|
||||
try {
|
||||
const r = await meta.searchNetEase(String(keyword).trim());
|
||||
res.json({ ok: true, ...r });
|
||||
} catch (e) {
|
||||
res.status(400).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/detail", async (req, res) => {
|
||||
const { id } = req.body || {};
|
||||
if (!id || !/^\d+$/.test(String(id)))
|
||||
return res.status(400).json({ ok: false, error: "无效 id" });
|
||||
try {
|
||||
res.json({ ok: true, ...(await meta.fetchNetEaseDetail(String(id))) });
|
||||
} catch (e) {
|
||||
res.status(400).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ==================== 网易云下载入库 ==================== */
|
||||
async function downloadFromNetEase(id, meta_in) {
|
||||
let m = {
|
||||
name: meta_in.name,
|
||||
artist: meta_in.artist || "",
|
||||
albumImage: meta_in.albumImage || "",
|
||||
};
|
||||
if (!m.name) {
|
||||
const info = await meta.fetchNetEaseDetail(String(id));
|
||||
m = { name: info.name, artist: info.artist || "", albumImage: info.albumImage || "" };
|
||||
}
|
||||
const filename = sanitizeFileName(m.artist ? `${m.artist} - ${m.name}` : m.name);
|
||||
if (!filename) throw new Error("缺少歌名");
|
||||
const file = filename + ".mp3";
|
||||
if (fs.existsSync(path.join(config.AUDIO_DIR, file))) throw new Error(`已存在: ${file}`);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const url = `http://music.163.com/song/media/outer/url?id=${id}.mp3`;
|
||||
meta
|
||||
.request(url)
|
||||
.then((res) => {
|
||||
const filePath = path.join(config.AUDIO_DIR, file);
|
||||
const ws = fs.createWriteStream(filePath);
|
||||
ws.on("finish", () => {
|
||||
const ctype = res.headers["content-type"] || "";
|
||||
if (!ctype.startsWith("audio")) {
|
||||
fs.unlinkSync(filePath);
|
||||
return reject(new Error(`返回非音频内容 (${ctype}),歌曲可能无版权`));
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
ws.on("error", reject);
|
||||
res.on("error", reject);
|
||||
res.pipe(ws);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
|
||||
// 封面本地化
|
||||
let cover = "";
|
||||
if (m.albumImage) cover = await meta.downloadCover(m.albumImage, fileHash(file));
|
||||
store.upsertSong({ file, name: m.name, artist: m.artist, cover });
|
||||
logger.info(`[IMPORT] 网易云下载完成: ${file}`);
|
||||
return file;
|
||||
}
|
||||
|
||||
router.post("/download", async (req, res) => {
|
||||
const { id, name, artist, albumImage } = req.body || {};
|
||||
if (!id || !/^\d+$/.test(String(id)))
|
||||
return res.status(400).json({ ok: false, error: "无效 id" });
|
||||
try {
|
||||
const file = await downloadFromNetEase(String(id), { name, artist, albumImage });
|
||||
res.json({ ok: true, file });
|
||||
} catch (e) {
|
||||
res.status(400).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ==================== 本地音频导入(上传) ==================== */
|
||||
fs.mkdirSync(config.UPLOAD_TMP_DIR, { recursive: true });
|
||||
const upload = multer({
|
||||
dest: config.UPLOAD_TMP_DIR,
|
||||
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ok = isAudioFile(file.originalname) || file.mimetype.startsWith("audio");
|
||||
cb(ok ? null : new Error("仅支持音频文件"), ok);
|
||||
},
|
||||
});
|
||||
|
||||
router.post("/import", upload.single("audio"), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ ok: false, error: "未收到文件" });
|
||||
const tmpPath = req.file.path;
|
||||
try {
|
||||
const userTitle = (req.body.title || "").trim();
|
||||
const userArtist = (req.body.artist || "").trim();
|
||||
const origName = Buffer.from(req.file.originalname, "latin1").toString("utf8");
|
||||
const ext = (path.extname(origName) || ".mp3").toLowerCase();
|
||||
|
||||
// 目标文件名:作者 - 歌名(若有),否则用原始名
|
||||
let baseName;
|
||||
if (userTitle) {
|
||||
baseName = sanitizeFileName(userArtist ? `${userArtist} - ${userTitle}` : userTitle);
|
||||
} else {
|
||||
baseName = sanitizeFileName(path.basename(origName, path.extname(origName)));
|
||||
}
|
||||
let finalFile = baseName + ext;
|
||||
// 重名加序号
|
||||
let n = 1;
|
||||
while (fs.existsSync(path.join(config.AUDIO_DIR, finalFile))) {
|
||||
finalFile = `${baseName} (${n++})${ext}`;
|
||||
}
|
||||
|
||||
const dest = path.join(config.AUDIO_DIR, finalFile);
|
||||
fs.renameSync(tmpPath, dest);
|
||||
logger.info(`[IMPORT] 本地导入: ${finalFile}`);
|
||||
|
||||
// 元数据补全(用户歌名优先,缺作者/封面则匹配网易云)
|
||||
const song = await meta.enrichSong(finalFile, {
|
||||
name: userTitle || undefined,
|
||||
artist: userArtist || undefined,
|
||||
});
|
||||
res.json({ ok: true, file: finalFile, song });
|
||||
} catch (e) {
|
||||
try {
|
||||
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
|
||||
} catch (_) {}
|
||||
logger.error("[IMPORT] 失败:", e.message);
|
||||
res.status(400).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ==================== 删除音频(级联清理) ==================== */
|
||||
router.post("/delete", (req, res) => {
|
||||
const { file } = req.body || {};
|
||||
if (!isValidFileName(file)) return res.status(400).json({ ok: false, error: "无效文件名" });
|
||||
|
||||
const song = store.getSongByFile(file);
|
||||
const audioPath = path.join(config.AUDIO_DIR, file);
|
||||
|
||||
// 1. 删音频本体
|
||||
try {
|
||||
if (fs.existsSync(audioPath)) fs.unlinkSync(audioPath);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ ok: false, error: "删除文件失败: " + e.message });
|
||||
}
|
||||
|
||||
// 2. 删切片缓存目录
|
||||
const cacheDir = media.getCacheDir(file);
|
||||
if (fs.existsSync(cacheDir)) {
|
||||
const sz = getDirSize(cacheDir);
|
||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
live.addCacheSize(-sz);
|
||||
}
|
||||
live.invalidateSegments(file);
|
||||
|
||||
// 3. 删 DB 记录(外键 CASCADE 会清掉 stream_playlist 引用)
|
||||
store.deleteSongByFile(file);
|
||||
|
||||
// 4. 各流若播放列表被清空,补一首
|
||||
for (const s of live.streams.values()) {
|
||||
s.ensureNotEmpty();
|
||||
s.clampIdx(s.playlistRows());
|
||||
}
|
||||
|
||||
logger.info(`[DELETE] 已删除: ${file}`);
|
||||
res.json({ ok: true, file });
|
||||
});
|
||||
|
||||
/* ==================== 批量删除 ==================== */
|
||||
router.post("/delete-batch", (req, res) => {
|
||||
const { files } = req.body || {};
|
||||
if (!Array.isArray(files)) return res.status(400).json({ ok: false, error: "无效数据" });
|
||||
const results = [];
|
||||
for (const file of files) {
|
||||
if (!isValidFileName(file)) {
|
||||
results.push({ file, ok: false, error: "无效文件名" });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const audioPath = path.join(config.AUDIO_DIR, file);
|
||||
if (fs.existsSync(audioPath)) fs.unlinkSync(audioPath);
|
||||
const cacheDir = media.getCacheDir(file);
|
||||
if (fs.existsSync(cacheDir)) {
|
||||
live.addCacheSize(-getDirSize(cacheDir));
|
||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
}
|
||||
live.invalidateSegments(file);
|
||||
store.deleteSongByFile(file);
|
||||
results.push({ file, ok: true });
|
||||
} catch (e) {
|
||||
results.push({ file, ok: false, error: e.message });
|
||||
}
|
||||
}
|
||||
for (const s of live.streams.values()) {
|
||||
s.ensureNotEmpty();
|
||||
s.clampIdx(s.playlistRows());
|
||||
}
|
||||
res.json({ ok: true, results });
|
||||
});
|
||||
|
||||
/* ==================== 元数据重匹配(补封面/作者) ==================== */
|
||||
router.post("/enrich", async (req, res) => {
|
||||
const { file, skipMatch } = req.body || {};
|
||||
if (!isValidFileName(file)) return res.status(400).json({ ok: false, error: "无效文件名" });
|
||||
if (!store.getSongByFile(file)) return res.status(404).json({ ok: false, error: "歌曲不存在" });
|
||||
try {
|
||||
const song = await meta.enrichSong(file, { skipMatch: !!skipMatch });
|
||||
res.json({ ok: true, song });
|
||||
} catch (e) {
|
||||
res.status(400).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,34 @@
|
||||
"use strict";
|
||||
const express = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const config = require("../config");
|
||||
const store = require("../store");
|
||||
const live = require("../livestream");
|
||||
const { getDirSize } = require("../util");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
const songs = store.getAllSongs();
|
||||
res.json({
|
||||
ok: true,
|
||||
list: songs.map((s) => ({
|
||||
id: s.id,
|
||||
file: s.file,
|
||||
title: s.name || s.file,
|
||||
artist: s.artist || "",
|
||||
albumImage: s.cover || "",
|
||||
duration: s.duration || null,
|
||||
fileSize: s.file_size || 0,
|
||||
heat: s.heat || 0,
|
||||
cacheStatus: live.getStatus(s.file),
|
||||
cacheSize: (() => {
|
||||
const d = path.join(config.HLS_CACHE_DIR, require("../util").fileHash(s.file));
|
||||
return fs.existsSync(d) ? getDirSize(d) : 0;
|
||||
})(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,161 @@
|
||||
"use strict";
|
||||
const express = require("express");
|
||||
const store = require("../store");
|
||||
const live = require("../livestream");
|
||||
const logger = require("../logger");
|
||||
const sse = require("../sse");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/* ==================== 流管理 ==================== */
|
||||
router.get("/", (req, res) => {
|
||||
res.json({ ok: true, list: live.listStreams() });
|
||||
});
|
||||
|
||||
function validStreamId(id) {
|
||||
return typeof id === "string" && /^[a-zA-Z0-9_-]{1,32}$/.test(id);
|
||||
}
|
||||
|
||||
router.post("/", (req, res) => {
|
||||
const { id, name } = req.body || {};
|
||||
if (!validStreamId(id))
|
||||
return res.status(400).json({ ok: false, error: "流 ID 仅允许字母数字_-,1~32位" });
|
||||
if (live.getStream(id)) return res.status(409).json({ ok: false, error: "流已存在" });
|
||||
try {
|
||||
const s = live.createStream(id, name || id);
|
||||
logger.info(`[STREAM] 创建流 ${id}`);
|
||||
res.json({ ok: true, stream: { id: s.id, name: s.name, m3u8: `/hls/${s.id}/index.m3u8` } });
|
||||
} catch (e) {
|
||||
res.status(400).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:id", (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (id === "main") return res.status(400).json({ ok: false, error: "默认流不可删除" });
|
||||
if (!live.getStream(id)) return res.status(404).json({ ok: false, error: "流不存在" });
|
||||
live.deleteStream(id);
|
||||
logger.info(`[STREAM] 删除流 ${id}`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post("/:id/start", (req, res) => {
|
||||
const s = live.getStream(req.params.id);
|
||||
if (!s) return res.status(404).json({ ok: false, error: "流不存在" });
|
||||
s.start();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
router.post("/:id/stop", (req, res) => {
|
||||
const s = live.getStream(req.params.id);
|
||||
if (!s) return res.status(404).json({ ok: false, error: "流不存在" });
|
||||
s.stop();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/* ==================== 播放列表(按流) ==================== */
|
||||
function streamGuard(req, res) {
|
||||
const s = live.getStream(req.params.id);
|
||||
if (!s) {
|
||||
res.status(404).json({ ok: false, error: "流不存在" });
|
||||
return null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function snapshot(streamId) {
|
||||
const rows = store.getPlaylistRows(streamId);
|
||||
const s = live.getStream(streamId);
|
||||
return rows.map((r, idx) => ({
|
||||
entryId: r.entry_id,
|
||||
position: r.position,
|
||||
file: r.file,
|
||||
title: r.name || r.file,
|
||||
artist: r.artist || "",
|
||||
albumImage: r.cover || "",
|
||||
duration: r.duration || null,
|
||||
heat: r.heat || 0,
|
||||
cacheStatus: live.getStatus(r.file),
|
||||
isCurrent: s ? idx === s.currentIdx : false,
|
||||
}));
|
||||
}
|
||||
|
||||
router.get("/:id/playlist", (req, res) => {
|
||||
const s = streamGuard(req, res);
|
||||
if (!s) return;
|
||||
res.json({ ok: true, list: snapshot(req.params.id) });
|
||||
});
|
||||
|
||||
router.post("/:id/playlist/add", (req, res) => {
|
||||
const s = streamGuard(req, res);
|
||||
if (!s) return;
|
||||
const { file } = req.body || {};
|
||||
const song = store.getSongByFile(file);
|
||||
if (!song) return res.status(404).json({ ok: false, error: "资源不存在" });
|
||||
store.addToPlaylist(req.params.id, song.id);
|
||||
store.incrementHeat(file);
|
||||
if (live.getStatus(file) !== "ready") live.triggerPreGenerate(file);
|
||||
sse.broadcast("playlist-update", { streamId: req.params.id });
|
||||
res.json({ ok: true, list: snapshot(req.params.id) });
|
||||
});
|
||||
|
||||
router.post("/:id/playlist/remove", (req, res) => {
|
||||
const s = streamGuard(req, res);
|
||||
if (!s) return;
|
||||
const { entryId } = req.body || {};
|
||||
if (typeof entryId !== "number")
|
||||
return res.status(400).json({ ok: false, error: "无效 entryId" });
|
||||
const rows = store.getPlaylistRows(req.params.id);
|
||||
const idx = rows.findIndex((r) => r.entry_id === entryId);
|
||||
if (idx === -1) return res.status(404).json({ ok: false, error: "条目不存在" });
|
||||
if (idx === s.currentIdx) s.resetCursor();
|
||||
else if (idx < s.currentIdx) s.currentIdx--;
|
||||
store.removePlaylistEntry(req.params.id, entryId);
|
||||
s.ensureNotEmpty();
|
||||
s.clampIdx(s.playlistRows());
|
||||
res.json({ ok: true, list: snapshot(req.params.id) });
|
||||
});
|
||||
|
||||
router.post("/:id/playlist/reorder", (req, res) => {
|
||||
const s = streamGuard(req, res);
|
||||
if (!s) return;
|
||||
const { order } = req.body || {};
|
||||
if (!Array.isArray(order)) return res.status(400).json({ ok: false, error: "无效数据" });
|
||||
const rows = store.getPlaylistRows(req.params.id);
|
||||
const validIds = new Set(rows.map((r) => r.entry_id));
|
||||
const filtered = order.filter((id) => validIds.has(id));
|
||||
store.setPlaylistOrder(req.params.id, filtered);
|
||||
res.json({ ok: true, list: snapshot(req.params.id) });
|
||||
});
|
||||
|
||||
router.post("/:id/playlist/clear", (req, res) => {
|
||||
const s = streamGuard(req, res);
|
||||
if (!s) return;
|
||||
store.clearPlaylist(req.params.id);
|
||||
s.currentIdx = 0;
|
||||
s.resetCursor();
|
||||
s.ensureNotEmpty();
|
||||
res.json({ ok: true, list: snapshot(req.params.id) });
|
||||
});
|
||||
|
||||
router.post("/:id/playlist/next", (req, res) => {
|
||||
const s = streamGuard(req, res);
|
||||
if (!s) return;
|
||||
const f = s.next();
|
||||
res.json({ ok: true, current: f, list: snapshot(req.params.id) });
|
||||
});
|
||||
router.post("/:id/playlist/prev", (req, res) => {
|
||||
const s = streamGuard(req, res);
|
||||
if (!s) return;
|
||||
const f = s.prev();
|
||||
res.json({ ok: true, current: f, list: snapshot(req.params.id) });
|
||||
});
|
||||
router.post("/:id/playlist/jump", (req, res) => {
|
||||
const s = streamGuard(req, res);
|
||||
if (!s) return;
|
||||
const { index } = req.body || {};
|
||||
const f = s.jump(index);
|
||||
if (f === null) return res.status(400).json({ ok: false, error: "无效索引" });
|
||||
res.json({ ok: true, current: f, list: snapshot(req.params.id) });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,52 @@
|
||||
"use strict";
|
||||
const express = require("express");
|
||||
const config = require("../config");
|
||||
const logger = require("../logger");
|
||||
const store = require("../store");
|
||||
const live = require("../livestream");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/cache", (req, res) => {
|
||||
const total = live.getCacheTotalSize();
|
||||
res.json({
|
||||
ok: true,
|
||||
total,
|
||||
limit: config.MAX_CACHE_SIZE,
|
||||
usedPercent: ((total / config.MAX_CACHE_SIZE) * 100).toFixed(2),
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/cache/cleanup", (req, res) => {
|
||||
const all = store.getAllSongs().map((s) => s.file);
|
||||
live.enforceCacheSizeLimit(all);
|
||||
res.json({ ok: true, total: live.getCacheTotalSize(), limit: config.MAX_CACHE_SIZE });
|
||||
});
|
||||
|
||||
router.get("/logs", (req, res) => {
|
||||
const tail = parseInt(req.query.tail, 10) || 100;
|
||||
res.json({ ok: true, lines: logger.tail(tail) });
|
||||
});
|
||||
|
||||
router.get("/config", (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
config: {
|
||||
HLS_TIME: config.HLS_TIME,
|
||||
SLIDE_WINDOW_SIZE: config.SLIDE_WINDOW_SIZE,
|
||||
MAX_CACHE_SIZE: config.MAX_CACHE_SIZE,
|
||||
MAX_FFMPEG_CONCURRENT: config.MAX_FFMPEG_CONCURRENT,
|
||||
HTTP_PORT: config.HTTP_PORT,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/settings", (req, res) => {
|
||||
res.json({ ok: true, settings: store.getSetting("app", {}) });
|
||||
});
|
||||
router.post("/settings", (req, res) => {
|
||||
store.setSetting("app", req.body || {});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,81 @@
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const chokidar = require("chokidar");
|
||||
const config = require("./config");
|
||||
const logger = require("./logger");
|
||||
const store = require("./store");
|
||||
const live = require("./livestream");
|
||||
const meta = require("./metadata");
|
||||
const { isAudioFile } = require("./util");
|
||||
|
||||
let watcher = null;
|
||||
let debounce = null;
|
||||
|
||||
// 扫描 audio 目录,同步 DB:新增入库、删除清理
|
||||
function scanAudioFiles() {
|
||||
const files = fs.readdirSync(config.AUDIO_DIR).filter(isAudioFile);
|
||||
const existing = new Set(store.getAllSongs().map((s) => s.file));
|
||||
|
||||
// 新增
|
||||
for (const f of files) {
|
||||
if (!existing.has(f)) {
|
||||
const parsed = meta.sanitizeFileName;
|
||||
const p = require("./util").parseFileName(f);
|
||||
store.upsertSong({ file: f, name: p.name, artist: p.artist });
|
||||
logger.info(`[SCAN] 新增资源: ${f}`);
|
||||
}
|
||||
}
|
||||
// 移除(文件已不在磁盘)
|
||||
const onDisk = new Set(files);
|
||||
for (const f of existing) {
|
||||
if (!onDisk.has(f)) {
|
||||
store.deleteSongByFile(f);
|
||||
logger.info(`[SCAN] 移除失效资源: ${f}`);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function scheduleScan() {
|
||||
if (debounce) clearTimeout(debounce);
|
||||
debounce = setTimeout(async () => {
|
||||
try {
|
||||
scanAudioFiles();
|
||||
const songs = store.getAllSongs();
|
||||
// 补元数据 + 预生成缺失缓存
|
||||
for (const s of songs) {
|
||||
if (!s.duration || !s.cover || !s.artist) {
|
||||
await meta.enrichSong(s.file).catch(() => {});
|
||||
}
|
||||
if (live.getStatus(s.file) !== "ready" && live.getStatus(s.file) !== "pending") {
|
||||
live.triggerPreGenerate(s.file);
|
||||
}
|
||||
}
|
||||
live.enforceCacheSizeLimit(songs.map((s) => s.file));
|
||||
for (const st of live.streams.values()) {
|
||||
st.ensureNotEmpty();
|
||||
st.clampIdx(st.playlistRows());
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("[SCAN] 出错:", e.message);
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function startWatcher() {
|
||||
watcher = chokidar.watch(config.AUDIO_DIR, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: { stabilityThreshold: 500 },
|
||||
});
|
||||
watcher.on("add", scheduleScan);
|
||||
watcher.on("unlink", scheduleScan);
|
||||
logger.info("[SCAN] 目录监听已启动");
|
||||
}
|
||||
|
||||
function closeWatcher() {
|
||||
if (watcher) return watcher.close();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
module.exports = { scanAudioFiles, startWatcher, closeWatcher, scheduleScan };
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
"use strict";
|
||||
const express = require("express");
|
||||
const path = require("path");
|
||||
const config = require("./config");
|
||||
const logger = require("./logger");
|
||||
const db = require("./db");
|
||||
const store = require("./store");
|
||||
const live = require("./livestream");
|
||||
const media = require("./media");
|
||||
const sse = require("./sse");
|
||||
const auth = require("./auth");
|
||||
const scanner = require("./scanner");
|
||||
|
||||
const authRoutes = require("./routes/auth");
|
||||
const musicRoutes = require("./routes/music");
|
||||
const resourceRoutes = require("./routes/resources");
|
||||
const streamRoutes = require("./routes/streams");
|
||||
const systemRoutes = require("./routes/system");
|
||||
|
||||
function bootstrap() {
|
||||
// 目录
|
||||
for (const dir of [config.AUDIO_DIR, config.HLS_CACHE_DIR, config.HLS_OUTPUT_DIR, config.PUBLIC_DIR, config.COVERS_DIR, config.UPLOAD_TMP_DIR]) {
|
||||
require("fs").mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// DB
|
||||
db.init();
|
||||
auth.initPasswordLog();
|
||||
|
||||
// 扫描资源入库
|
||||
scanner.scanAudioFiles();
|
||||
|
||||
// 初始化缓存状态
|
||||
const songs = store.getAllSongs();
|
||||
live.initCaches(songs);
|
||||
|
||||
// 缓存回调
|
||||
live.setCacheChangeListener((file, status) => {
|
||||
sse.broadcast("cache-status", { file, status });
|
||||
if (status === "ready") {
|
||||
const song = store.getSongByFile(file);
|
||||
if (song && song.duration == null) {
|
||||
media.probeAudio(path.join(config.AUDIO_DIR, file)).then((p) => {
|
||||
if (p.duration) store.updateSong(file, { duration: p.duration });
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
live.setCacheSizeCheck(() => {
|
||||
live.enforceCacheSizeLimit(store.getAllSongs().map((s) => s.file));
|
||||
});
|
||||
live.setStreamCallbacks({
|
||||
playlistChange: (streamId) => sse.broadcast("playlist-update", { streamId }),
|
||||
songFinished: (streamId, file) => sse.broadcast("song-finished", { streamId, file }),
|
||||
});
|
||||
|
||||
// 预生成缺失缓存
|
||||
for (const s of songs) {
|
||||
if (live.getStatus(s.file) !== "ready") live.triggerPreGenerate(s.file);
|
||||
}
|
||||
|
||||
// 载入并启动流
|
||||
live.loadStreams();
|
||||
live.startAll();
|
||||
|
||||
// 目录监听
|
||||
scanner.startWatcher();
|
||||
|
||||
return songs;
|
||||
}
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use((req, res, next) => {
|
||||
res.header("Access-Control-Allow-Origin", "*");
|
||||
res.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
res.header("Access-Control-Allow-Credentials", "true");
|
||||
if (req.method === "OPTIONS") return res.sendStatus(200);
|
||||
next();
|
||||
});
|
||||
app.use(express.json());
|
||||
|
||||
// 登录页(静态)
|
||||
app.get("/login", (req, res) => {
|
||||
res.sendFile(path.join(config.PUBLIC_DIR, "login.html"));
|
||||
});
|
||||
|
||||
// 登录相关 API(不需要鉴权)
|
||||
app.use("/api/auth", authRoutes);
|
||||
|
||||
// 登出后重定向
|
||||
app.get("/logout", (req, res) => {
|
||||
auth.logout(req);
|
||||
auth.clearSessionCookie(res);
|
||||
res.redirect("/login");
|
||||
});
|
||||
|
||||
// SSE(放在鉴权前,前端用 EventSource 无法带 header,靠 cookie)
|
||||
app.get("/api/events", (req, res) => {
|
||||
if (!auth.isAuthed(req)) return res.status(401).end();
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write(":ok\n\n");
|
||||
sse.addClient(res);
|
||||
});
|
||||
|
||||
// HLS 流(不锁)
|
||||
app.use("/hls", express.static(config.HLS_OUTPUT_DIR, {
|
||||
setHeaders: (res, filePath) => {
|
||||
if (filePath.endsWith(".m3u8")) {
|
||||
res.set("Content-Type", "application/vnd.apple.mpegurl; charset=utf-8");
|
||||
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
}
|
||||
},
|
||||
}));
|
||||
app.use("/hls_cache", express.static(config.HLS_CACHE_DIR));
|
||||
|
||||
// 封面/静态资源(不锁,浏览器加载图片)
|
||||
// 但 index.html 需要鉴权,避免未登录直接拿到面板
|
||||
app.get(["/", "/index.html"], (req, res) => {
|
||||
if (!auth.isAuthed(req)) return res.redirect("/login");
|
||||
res.sendFile(path.join(config.PUBLIC_DIR, "index.html"));
|
||||
});
|
||||
app.use(express.static(config.PUBLIC_DIR, { index: false }));
|
||||
|
||||
// 受保护的 API
|
||||
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);
|
||||
|
||||
// 其余 /api 需要登录
|
||||
app.use("/api", auth.requireAuth);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function start() {
|
||||
bootstrap();
|
||||
const app = createApp();
|
||||
const server = app.listen(config.HTTP_PORT, () => {
|
||||
logger.info(`[HTTP] 面板: http://127.0.0.1:${config.HTTP_PORT}`);
|
||||
for (const s of live.listStreams()) {
|
||||
logger.info(`[HTTP] 直播[${s.id}]: http://127.0.0.1:${config.HTTP_PORT}${s.m3u8}`);
|
||||
}
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
logger.info("[EXIT] 正在关闭...");
|
||||
live.stopAll();
|
||||
try { media.killAllProcs(); } catch (_) {}
|
||||
sse.closeAll();
|
||||
scanner.closeWatcher().then(() => {
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
setTimeout(() => process.exit(0), 3000);
|
||||
}
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("uncaughtException", (err) => logger.error("[FATAL]", err.message, err.stack));
|
||||
process.on("unhandledRejection", (r) => logger.warn("[WARN]", r));
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
module.exports = { bootstrap, createApp, start };
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
"use strict";
|
||||
const clients = new Set();
|
||||
|
||||
function addClient(res) {
|
||||
clients.add(res);
|
||||
res.on("close", () => clients.delete(res));
|
||||
}
|
||||
|
||||
function broadcast(event, data) {
|
||||
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
for (const c of clients) {
|
||||
try {
|
||||
c.write(msg);
|
||||
} catch (_) {
|
||||
clients.delete(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
for (const c of clients) {
|
||||
try {
|
||||
c.end();
|
||||
} catch (_) {}
|
||||
}
|
||||
clients.clear();
|
||||
}
|
||||
|
||||
module.exports = { addClient, broadcast, closeAll };
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
"use strict";
|
||||
const { getDb } = require("./db");
|
||||
const { naturalCompare } = require("./util");
|
||||
|
||||
/* ==================== songs ==================== */
|
||||
function getSongByFile(file) {
|
||||
return getDb().prepare("SELECT * FROM songs WHERE file = ?").get(file);
|
||||
}
|
||||
function getSongById(id) {
|
||||
return getDb().prepare("SELECT * FROM songs WHERE id = ?").get(id);
|
||||
}
|
||||
function getAllSongs() {
|
||||
const rows = getDb().prepare("SELECT * FROM songs").all();
|
||||
return rows.sort((a, b) => naturalCompare(a.file, b.file));
|
||||
}
|
||||
function upsertSong(s) {
|
||||
const d = getDb();
|
||||
const ex = getSongByFile(s.file);
|
||||
if (ex) {
|
||||
d.prepare(
|
||||
`UPDATE songs SET name=?, artist=?, cover=?, 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.duration != null ? s.duration : ex.duration,
|
||||
s.file_size != null ? s.file_size : ex.file_size,
|
||||
s.file
|
||||
);
|
||||
return getSongByFile(s.file);
|
||||
}
|
||||
d.prepare(
|
||||
`INSERT INTO songs (file, name, artist, cover, duration, file_size, heat, cache_status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, 'pending', ?)`
|
||||
).run(
|
||||
s.file,
|
||||
s.name || "",
|
||||
s.artist || "",
|
||||
s.cover || "",
|
||||
s.duration != null ? s.duration : null,
|
||||
s.file_size || 0,
|
||||
Date.now()
|
||||
);
|
||||
return getSongByFile(s.file);
|
||||
}
|
||||
function updateSong(file, fields) {
|
||||
const keys = Object.keys(fields);
|
||||
if (!keys.length) return;
|
||||
const set = keys.map((k) => `${k}=?`).join(", ");
|
||||
getDb()
|
||||
.prepare(`UPDATE songs SET ${set} WHERE file=?`)
|
||||
.run(...keys.map((k) => fields[k]), file);
|
||||
}
|
||||
function deleteSongByFile(file) {
|
||||
return getDb().prepare("DELETE FROM songs WHERE file = ?").run(file);
|
||||
}
|
||||
function setCacheStatus(file, status) {
|
||||
getDb().prepare("UPDATE songs SET cache_status=? WHERE file=?").run(status, file);
|
||||
}
|
||||
function incrementHeat(file) {
|
||||
getDb().prepare("UPDATE songs SET heat = heat + 1 WHERE file=?").run(file);
|
||||
return getSongByFile(file)?.heat || 0;
|
||||
}
|
||||
|
||||
/* ==================== streams ==================== */
|
||||
function createStream(id, name) {
|
||||
getDb()
|
||||
.prepare(
|
||||
"INSERT INTO streams (id, name, enabled, current_pos, media_seq, created_at) VALUES (?, ?, 1, 0, 0, ?)"
|
||||
)
|
||||
.run(id, name || id, Date.now());
|
||||
return getStream(id);
|
||||
}
|
||||
function listStreams() {
|
||||
return getDb().prepare("SELECT * FROM streams ORDER BY created_at ASC").all();
|
||||
}
|
||||
function getStream(id) {
|
||||
return getDb().prepare("SELECT * FROM streams WHERE id = ?").get(id);
|
||||
}
|
||||
function deleteStream(id) {
|
||||
return getDb().prepare("DELETE FROM streams WHERE id = ?").run(id);
|
||||
}
|
||||
function setStreamState(id, fields) {
|
||||
const keys = Object.keys(fields);
|
||||
if (!keys.length) return;
|
||||
const set = keys.map((k) => `${k}=?`).join(", ");
|
||||
getDb()
|
||||
.prepare(`UPDATE streams SET ${set} WHERE id=?`)
|
||||
.run(...keys.map((k) => fields[k]), id);
|
||||
}
|
||||
function setStreamEnabled(id, enabled) {
|
||||
setStreamState(id, { enabled: enabled ? 1 : 0 });
|
||||
}
|
||||
|
||||
/* ==================== stream_playlist ==================== */
|
||||
function getPlaylistRows(streamId) {
|
||||
return getDb()
|
||||
.prepare(
|
||||
`SELECT sp.id AS entry_id, sp.position, s.*
|
||||
FROM stream_playlist sp JOIN songs s ON s.id = sp.song_id
|
||||
WHERE sp.stream_id = ? ORDER BY sp.position ASC`
|
||||
)
|
||||
.all(streamId);
|
||||
}
|
||||
function playlistCount(streamId) {
|
||||
return getDb()
|
||||
.prepare("SELECT COUNT(*) c FROM stream_playlist WHERE stream_id=?")
|
||||
.get(streamId).c;
|
||||
}
|
||||
function addToPlaylist(streamId, songId) {
|
||||
const d = getDb();
|
||||
const max =
|
||||
d.prepare("SELECT COALESCE(MAX(position),-1) m FROM stream_playlist WHERE stream_id=?")
|
||||
.get(streamId).m + 1;
|
||||
d.prepare(
|
||||
"INSERT INTO stream_playlist (stream_id, song_id, position) VALUES (?, ?, ?)"
|
||||
).run(streamId, songId, max);
|
||||
}
|
||||
function removePlaylistEntry(streamId, entryId) {
|
||||
const d = getDb();
|
||||
const row = d.prepare("SELECT position FROM stream_playlist WHERE id=? AND stream_id=?").get(entryId, streamId);
|
||||
if (!row) return false;
|
||||
d.prepare("DELETE FROM stream_playlist WHERE id=?").run(entryId);
|
||||
d.prepare("UPDATE stream_playlist SET position = position - 1 WHERE stream_id=? AND position > ?")
|
||||
.run(streamId, row.position);
|
||||
return true;
|
||||
}
|
||||
function setPlaylistOrder(streamId, entryIds) {
|
||||
const d = getDb();
|
||||
const stmt = d.prepare("UPDATE stream_playlist SET position=? WHERE id=? AND stream_id=?");
|
||||
const tx = d.transaction((ids) => {
|
||||
ids.forEach((id, i) => stmt.run(i, id, streamId));
|
||||
});
|
||||
tx(entryIds);
|
||||
}
|
||||
function clearPlaylist(streamId) {
|
||||
getDb().prepare("DELETE FROM stream_playlist WHERE stream_id=?").run(streamId);
|
||||
}
|
||||
|
||||
/* ==================== sessions ==================== */
|
||||
function createSession(token, expiresAt) {
|
||||
getDb()
|
||||
.prepare("INSERT INTO sessions (token, created_at, expires_at) VALUES (?, ?, ?)")
|
||||
.run(token, Date.now(), expiresAt);
|
||||
}
|
||||
function getSession(token) {
|
||||
const s = getDb().prepare("SELECT * FROM sessions WHERE token=?").get(token);
|
||||
if (!s) return null;
|
||||
if (s.expires_at < Date.now()) {
|
||||
deleteSession(token);
|
||||
return null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
function deleteSession(token) {
|
||||
getDb().prepare("DELETE FROM sessions WHERE token=?").run(token);
|
||||
}
|
||||
function purgeExpiredSessions() {
|
||||
return getDb().prepare("DELETE FROM sessions WHERE expires_at < ?").run(Date.now()).changes;
|
||||
}
|
||||
|
||||
/* ==================== settings ==================== */
|
||||
function getSetting(key, def = null) {
|
||||
const r = getDb().prepare("SELECT value FROM settings WHERE key=?").get(key);
|
||||
if (!r) return def;
|
||||
try {
|
||||
return JSON.parse(r.value);
|
||||
} catch (_) {
|
||||
return r.value;
|
||||
}
|
||||
}
|
||||
function setSetting(key, value) {
|
||||
getDb()
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
|
||||
)
|
||||
.run(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSongByFile, getSongById, getAllSongs, upsertSong, updateSong,
|
||||
deleteSongByFile, setCacheStatus, incrementHeat,
|
||||
createStream, listStreams, getStream, deleteStream, setStreamState, setStreamEnabled,
|
||||
getPlaylistRows, playlistCount, addToPlaylist, removePlaylistEntry,
|
||||
setPlaylistOrder, clearPlaylist,
|
||||
createSession, getSession, deleteSession, purgeExpiredSessions,
|
||||
getSetting, setSetting,
|
||||
};
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
|
||||
function fileHash(fp) {
|
||||
return crypto.createHash("md5").update(fp).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function naturalCompare(a, b) {
|
||||
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
|
||||
function getDirSize(dirPath) {
|
||||
let size = 0;
|
||||
if (!fs.existsSync(dirPath)) return size;
|
||||
for (const entry of fs.readdirSync(dirPath)) {
|
||||
const full = path.join(dirPath, entry);
|
||||
try {
|
||||
const st = fs.statSync(full);
|
||||
size += st.isDirectory() ? getDirSize(full) : st.size;
|
||||
} catch (_) {}
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
function atomicWriteSync(filePath, data) {
|
||||
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tmp, data, "utf8");
|
||||
fs.renameSync(tmp, filePath);
|
||||
}
|
||||
|
||||
function sanitizeFileName(name) {
|
||||
return String(name)
|
||||
.replace(/[\\/:*?"<>|]/g, "_")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isValidFileName(f) {
|
||||
return (
|
||||
typeof f === "string" &&
|
||||
f.length > 0 &&
|
||||
f.length < 500 &&
|
||||
!f.includes("..") &&
|
||||
!f.includes("/") &&
|
||||
!f.includes("\\") &&
|
||||
!f.includes("\0")
|
||||
);
|
||||
}
|
||||
|
||||
function parseFileName(fileName) {
|
||||
const stem = fileName.replace(/\.[^.]+$/, "");
|
||||
const idx = stem.indexOf(" - ");
|
||||
if (idx > 0) {
|
||||
return { artist: stem.slice(0, idx).trim(), name: stem.slice(idx + 3).trim() };
|
||||
}
|
||||
return { artist: "", name: stem.trim() };
|
||||
}
|
||||
|
||||
const AUDIO_EXTS = [".mp3", ".m4a", ".flac", ".wav", ".aac", ".ogg"];
|
||||
function isAudioFile(name) {
|
||||
return AUDIO_EXTS.includes(path.extname(name).toLowerCase());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fileHash,
|
||||
naturalCompare,
|
||||
getDirSize,
|
||||
atomicWriteSync,
|
||||
sanitizeFileName,
|
||||
isValidFileName,
|
||||
parseFileName,
|
||||
isAudioFile,
|
||||
AUDIO_EXTS,
|
||||
};
|
||||
Reference in new issue
Block a user