1234 lines
38 KiB
JavaScript
1234 lines
38 KiB
JavaScript
const fs = require("fs");
|
||
const path = require("path");
|
||
const crypto = require("crypto");
|
||
const http = require("http");
|
||
const https = require("https");
|
||
const { spawn } = require("child_process");
|
||
const chokidar = require("chokidar");
|
||
const express = require("express");
|
||
|
||
// ==================== 路径常量 ====================
|
||
const AUDIO_DIR = path.join(__dirname, "audio");
|
||
const HLS_CACHE_DIR = path.join(__dirname, "hls_cache");
|
||
const HLS_OUTPUT_DIR = path.join(__dirname, "hls");
|
||
const PLAYLIST_FILE = path.join(__dirname, "playlist.json");
|
||
const HEAT_FILE = path.join(__dirname, "heat.json");
|
||
const METADATA_FILE = path.join(__dirname, "metadata.json");
|
||
const PUBLIC_DIR = path.join(__dirname, "public");
|
||
|
||
for (const dir of [AUDIO_DIR, HLS_CACHE_DIR, HLS_OUTPUT_DIR, PUBLIC_DIR]) {
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
}
|
||
|
||
// ==================== 配置 ====================
|
||
// 环境变量解析助手:非法或缺失时回退默认值
|
||
function envInt(name, fallback) {
|
||
const v = parseInt(process.env[name], 10);
|
||
return Number.isFinite(v) && v > 0 ? v : fallback;
|
||
}
|
||
|
||
const MAX_CACHE_SIZE = envInt("MAX_CACHE_MB", 1000) * 1024 * 1024;
|
||
const HLS_TIME = envInt("HLS_TIME", 5);
|
||
const SLIDE_WINDOW_SIZE = envInt("SLIDE_WINDOW", 15);
|
||
const LIVE_TICK_INTERVAL = HLS_TIME * 1000; // 与切片时长对齐,避免累积漂移
|
||
const HTTP_PORT = envInt("PORT", 3000);
|
||
const MAX_FFMPEG_CONCURRENT = envInt("FFMPEG_CONCURRENCY", 3);
|
||
const MAX_RETRIES = envInt("MAX_RETRIES", 3);
|
||
const FFMPEG_TIMEOUT = envInt("FFMPEG_TIMEOUT_MS", 5 * 60 * 1000);
|
||
const AUTH_TOKEN = process.env.AUTH_TOKEN || "";
|
||
|
||
// [FIX] DISCONTINUITY 默认关闭
|
||
// 设为 true:严格合规,但部分播放器会在标签处插入静音
|
||
// 设为 false:让播放器自然处理 PTS 跳变,兼容性更好
|
||
const USE_DISCONTINUITY = process.env.USE_DISCONTINUITY === "true";
|
||
|
||
// ==================== 全局状态 ====================
|
||
let audioFiles = [];
|
||
let playlist = [];
|
||
let heatMap = new Map();
|
||
let metadataMap = new Map();
|
||
let cacheStatus = new Map(); // 'ready' | 'pending' | 'error'
|
||
let server = null;
|
||
let cacheTotalSize = 0; // 增量追踪
|
||
|
||
let liveSegments = []; // { duration, path, songId }
|
||
let mediaSequence = 0;
|
||
let currentPlaylistIdx = 0;
|
||
let currentSongSegmentIdx = 0;
|
||
let songSegmentsCache = new Map();
|
||
let liveTimer = null;
|
||
|
||
let retryCount = new Map();
|
||
let heatSaveTimer = null;
|
||
let scanDebounceTimer = null;
|
||
const sseClients = new Set();
|
||
|
||
// ==================== 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) => console.error("[QUEUE]", e.message))
|
||
.finally(() => {
|
||
this.running--;
|
||
this._drain();
|
||
});
|
||
}
|
||
}
|
||
}
|
||
const ffmpegQueue = new TaskQueue(MAX_FFMPEG_CONCURRENT);
|
||
|
||
// ==================== 工具函数 ====================
|
||
function fileHash(fp) {
|
||
return crypto.createHash("md5").update(fp).digest("hex").slice(0, 16);
|
||
}
|
||
function getCacheDir(fp) {
|
||
return path.join(HLS_CACHE_DIR, fileHash(fp));
|
||
}
|
||
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 clampPlaylistIndex() {
|
||
if (playlist.length === 0) {
|
||
currentPlaylistIdx = 0;
|
||
currentSongSegmentIdx = 0;
|
||
return;
|
||
}
|
||
if (currentPlaylistIdx < 0) currentPlaylistIdx = 0;
|
||
if (currentPlaylistIdx >= playlist.length) {
|
||
currentPlaylistIdx = 0;
|
||
currentSongSegmentIdx = 0;
|
||
}
|
||
}
|
||
|
||
// [FIX] 原子写入:先写临时文件再 rename,播放器不会读到半截 M3U8
|
||
function atomicWriteSync(filePath, data) {
|
||
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||
fs.writeFileSync(tmp, data, "utf8");
|
||
fs.renameSync(tmp, filePath);
|
||
}
|
||
|
||
// ==================== 网易云音源导入 ====================
|
||
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/";
|
||
|
||
function neteaseRequest(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("重定向次数过多"));
|
||
const next = new URL(loc, url).toString();
|
||
return resolve(neteaseRequest(next, 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 readResponseBody(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);
|
||
});
|
||
}
|
||
|
||
function sanitizeFileName(name) {
|
||
return name.replace(/[\\/:*?"<>|]/g, "_").trim();
|
||
}
|
||
|
||
async function fetchNetEaseDetail(id) {
|
||
const url = `http://music.163.com/api/song/detail/?id=${id}&ids=[${id}]`;
|
||
const res = await neteaseRequest(url);
|
||
const body = await readResponseBody(res);
|
||
const json = JSON.parse(body.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) || "",
|
||
};
|
||
}
|
||
|
||
function downloadNetEase(id, filename) {
|
||
return new Promise((resolve, reject) => {
|
||
const url = `http://music.163.com/song/media/outer/url?id=${id}.mp3`;
|
||
neteaseRequest(url)
|
||
.then((res) => {
|
||
const filePath = path.join(AUDIO_DIR, filename + ".mp3");
|
||
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(filePath);
|
||
});
|
||
ws.on("error", reject);
|
||
res.on("error", reject);
|
||
res.pipe(ws);
|
||
})
|
||
.catch(reject);
|
||
});
|
||
}
|
||
|
||
// 网易云音源:歌名搜索(返回精简列表,过滤 fee=1)
|
||
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 neteaseRequest(url);
|
||
const body = await readResponseBody(res);
|
||
const json = JSON.parse(body.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,
|
||
};
|
||
const alias = s.alias || [];
|
||
if (alias.length) item.alias = alias;
|
||
return item;
|
||
});
|
||
return { total: list.length, list };
|
||
}
|
||
|
||
// ==================== SSE 推送 ====================
|
||
function broadcastSSE(event, data) {
|
||
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||
for (const c of sseClients) {
|
||
try {
|
||
c.write(msg);
|
||
} catch (_) {
|
||
sseClients.delete(c);
|
||
}
|
||
}
|
||
}
|
||
function getPlaylistSnapshot() {
|
||
return playlist.map((file, idx) => ({
|
||
name: path.basename(file),
|
||
path: file,
|
||
heat: heatMap.get(file) || 0,
|
||
cacheStatus: cacheStatus.get(file) || "unknown",
|
||
isCurrent: idx === currentPlaylistIdx,
|
||
}));
|
||
}
|
||
|
||
// ==================== 热度管理(防抖落盘)====================
|
||
function loadHeatMap() {
|
||
try {
|
||
heatMap = new Map(
|
||
Object.entries(JSON.parse(fs.readFileSync(HEAT_FILE, "utf8"))),
|
||
);
|
||
} catch (_) {
|
||
heatMap = new Map();
|
||
}
|
||
}
|
||
function saveHeatMap() {
|
||
try {
|
||
fs.writeFileSync(
|
||
HEAT_FILE,
|
||
JSON.stringify(Object.fromEntries(heatMap), null, 2),
|
||
);
|
||
} catch (_) {}
|
||
}
|
||
function scheduleHeatSave() {
|
||
if (heatSaveTimer) clearTimeout(heatSaveTimer);
|
||
heatSaveTimer = setTimeout(saveHeatMap, 5000);
|
||
}
|
||
function incrementHeat(fp) {
|
||
heatMap.set(fp, (heatMap.get(fp) || 0) + 1);
|
||
scheduleHeatSave();
|
||
}
|
||
|
||
// ==================== 播放列表 ====================
|
||
function loadPlaylist() {
|
||
try {
|
||
playlist = JSON.parse(fs.readFileSync(PLAYLIST_FILE, "utf8")).filter((p) =>
|
||
fs.existsSync(p),
|
||
);
|
||
} catch (_) {
|
||
playlist = [];
|
||
}
|
||
}
|
||
function savePlaylist() {
|
||
try {
|
||
fs.writeFileSync(PLAYLIST_FILE, JSON.stringify(playlist, null, 2));
|
||
} catch (_) {}
|
||
}
|
||
|
||
// ==================== 切片元数据解析 ====================
|
||
function loadSongSegments(fp) {
|
||
if (songSegmentsCache.has(fp)) return songSegmentsCache.get(fp);
|
||
const m3u8 = path.join(getCacheDir(fp), "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 = 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) });
|
||
}
|
||
}
|
||
if (segments.length === 0) return null;
|
||
songSegmentsCache.set(fp, segments);
|
||
return segments;
|
||
} catch (e) {
|
||
console.error("[SEG]", path.basename(fp), e.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ==================== 音频探测 ====================
|
||
function probeAudio(fp) {
|
||
return new Promise((resolve, reject) => {
|
||
const proc = spawn("ffprobe", [
|
||
"-v",
|
||
"error",
|
||
"-select_streams",
|
||
"a:0",
|
||
"-show_entries",
|
||
"stream=codec_name,sample_rate,channels:format=duration",
|
||
"-of",
|
||
"json",
|
||
fp,
|
||
]);
|
||
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);
|
||
});
|
||
}
|
||
|
||
// ==================== 音频元数据管理 ====================
|
||
function loadMetadata() {
|
||
try {
|
||
metadataMap = new Map(
|
||
Object.entries(JSON.parse(fs.readFileSync(METADATA_FILE, "utf8"))),
|
||
);
|
||
} catch (_) {
|
||
metadataMap = new Map();
|
||
}
|
||
}
|
||
function saveMetadata() {
|
||
try {
|
||
fs.writeFileSync(
|
||
METADATA_FILE,
|
||
JSON.stringify(Object.fromEntries(metadataMap), null, 2),
|
||
);
|
||
} catch (_) {}
|
||
}
|
||
// 从「作者 - 歌名.mp3」文件名解析出作者与歌名
|
||
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() };
|
||
}
|
||
// 补齐某音频的元数据(歌名/作者/封面/时长);已有则跳过,仅补缺失的时长
|
||
async function ensureMetadata(filePath) {
|
||
if (metadataMap.has(filePath)) {
|
||
const m = metadataMap.get(filePath);
|
||
if (m.duration == null) {
|
||
try {
|
||
m.duration = (await probeAudio(filePath)).duration;
|
||
saveMetadata();
|
||
} catch (_) {}
|
||
}
|
||
return m;
|
||
}
|
||
const parsed = parseFileName(path.basename(filePath));
|
||
const meta = {
|
||
name: parsed.name,
|
||
artist: parsed.artist,
|
||
albumImage: "",
|
||
duration: null,
|
||
};
|
||
try {
|
||
meta.duration = (await probeAudio(filePath)).duration;
|
||
} catch (_) {}
|
||
metadataMap.set(filePath, meta);
|
||
saveMetadata();
|
||
return meta;
|
||
}
|
||
|
||
// ==================== [FIX] 启动缓存扫描 ====================
|
||
// 在 preGenerateAll 之前调用,识别已有缓存,避免重复生成
|
||
function scanExistingCaches() {
|
||
const hashMap = new Map();
|
||
for (const file of audioFiles) hashMap.set(fileHash(file), file);
|
||
|
||
let validCount = 0;
|
||
for (const entry of fs.readdirSync(HLS_CACHE_DIR)) {
|
||
const dir = path.join(HLS_CACHE_DIR, entry);
|
||
try {
|
||
if (!fs.statSync(dir).isDirectory()) continue;
|
||
} catch (_) {
|
||
continue;
|
||
}
|
||
|
||
const originalFile = hashMap.get(entry);
|
||
if (!originalFile) continue; // 孤立缓存,后续由 enforceCacheSizeLimit 清理
|
||
|
||
// 检查 index.m3u8 是否存在且可解析
|
||
const segments = loadSongSegments(originalFile);
|
||
if (segments && segments.length > 0) {
|
||
// 检查所有 .ts 文件是否完整存在且非空
|
||
const allValid = segments.every((seg) => {
|
||
const segPath = path.join(dir, seg.fileName);
|
||
try {
|
||
return fs.existsSync(segPath) && fs.statSync(segPath).size > 0;
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
});
|
||
|
||
if (allValid) {
|
||
cacheStatus.set(originalFile, "ready");
|
||
cacheTotalSize += getDirSize(dir);
|
||
validCount++;
|
||
} else {
|
||
console.log(
|
||
`[CACHE] 不完整,将重新生成: ${path.basename(originalFile)}`,
|
||
);
|
||
const oldSize = getDirSize(dir);
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
cacheTotalSize = Math.max(0, cacheTotalSize - oldSize);
|
||
songSegmentsCache.delete(originalFile);
|
||
}
|
||
} else {
|
||
// index.m3u8 不存在或无法解析
|
||
const oldSize = getDirSize(dir);
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
cacheTotalSize = Math.max(0, cacheTotalSize - oldSize);
|
||
}
|
||
}
|
||
console.log(
|
||
`[CACHE] 启动扫描: ${validCount}/${audioFiles.length} 个缓存有效,` +
|
||
`总大小 ${(cacheTotalSize / 1024 / 1024).toFixed(1)}MB`,
|
||
);
|
||
}
|
||
|
||
// ==================== 缓存清理 ====================
|
||
function enforceCacheSizeLimit() {
|
||
if (cacheTotalSize <= MAX_CACHE_SIZE) return;
|
||
console.log(
|
||
`[CACHE] ${(cacheTotalSize / 1024 / 1024).toFixed(1)}MB 超限,开始清理`,
|
||
);
|
||
|
||
// 保护当前 + 接下来 2 首
|
||
const protectedFiles = new Set();
|
||
for (let i = 0; i < Math.min(3, playlist.length); i++) {
|
||
const idx = (currentPlaylistIdx + i) % playlist.length;
|
||
if (playlist[idx]) protectedFiles.add(playlist[idx]);
|
||
}
|
||
|
||
// 保护直播滑动窗口中的歌曲(即使已从播放列表移除)
|
||
const liveHashes = new Set(liveSegments.map((s) => s.songId));
|
||
|
||
const items = [];
|
||
for (const entry of fs.readdirSync(HLS_CACHE_DIR)) {
|
||
const dir = path.join(HLS_CACHE_DIR, entry);
|
||
try {
|
||
if (!fs.statSync(dir).isDirectory()) continue;
|
||
} catch (_) {
|
||
continue;
|
||
}
|
||
let origFile = null;
|
||
for (const a of audioFiles) {
|
||
if (fileHash(a) === entry) {
|
||
origFile = a;
|
||
break;
|
||
}
|
||
}
|
||
const size = getDirSize(dir);
|
||
items.push({
|
||
dir,
|
||
size,
|
||
origFile,
|
||
inLiveWindow: liveHashes.has(entry),
|
||
isProtected: origFile ? protectedFiles.has(origFile) : false,
|
||
inPlaylist: origFile ? playlist.includes(origFile) : false,
|
||
heat: origFile ? heatMap.get(origFile) || 0 : 0,
|
||
});
|
||
}
|
||
|
||
// 清理优先级:不受保护 → 不在播放列表 → 不在直播窗口 → 热度低
|
||
items.sort((a, b) => {
|
||
if (a.inLiveWindow !== b.inLiveWindow) return a.inLiveWindow ? 1 : -1;
|
||
if (a.isProtected !== b.isProtected) return a.isProtected ? 1 : -1;
|
||
if (a.inPlaylist !== b.inPlaylist) return a.inPlaylist ? 1 : -1;
|
||
return a.heat - b.heat;
|
||
});
|
||
|
||
for (const item of items) {
|
||
if (cacheTotalSize <= MAX_CACHE_SIZE) break;
|
||
console.log(
|
||
`[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) {
|
||
cacheStatus.set(item.origFile, "pending");
|
||
songSegmentsCache.delete(item.origFile);
|
||
if (!item.isProtected) triggerPreGenerate(item.origFile);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==================== HLS 预生成 ====================
|
||
function triggerPreGenerate(filePath) {
|
||
const status = cacheStatus.get(filePath);
|
||
if (status === "pending" || status === "ready") return;
|
||
|
||
const retries = retryCount.get(filePath) || 0;
|
||
if (retries >= MAX_RETRIES) {
|
||
console.warn(
|
||
`[PRESEG] 跳过(已重试 ${MAX_RETRIES} 次): ${path.basename(filePath)}`,
|
||
);
|
||
return;
|
||
}
|
||
retryCount.set(filePath, retries + 1);
|
||
cacheStatus.set(filePath, "pending");
|
||
|
||
const cacheDir = getCacheDir(filePath);
|
||
// [FIX] 仅清理残留缓存(正常路径不会走到这里,因为 scanExistingCaches 已处理)
|
||
if (fs.existsSync(cacheDir)) {
|
||
cacheTotalSize = Math.max(0, cacheTotalSize - getDirSize(cacheDir));
|
||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||
}
|
||
fs.mkdirSync(cacheDir, { recursive: true });
|
||
|
||
ffmpegQueue.push(async () => {
|
||
// 探测源格式:AAC 44.1kHz 立体声 → copy 模式避免二次压缩损耗
|
||
let audioArgs;
|
||
try {
|
||
const probe = await probeAudio(filePath);
|
||
if (
|
||
probe.codec === "aac" &&
|
||
probe.sampleRate === 44100 &&
|
||
probe.channels <= 2
|
||
) {
|
||
audioArgs = ["-c:a", "copy"];
|
||
console.log(`[PRESEG] copy: ${path.basename(filePath)}`);
|
||
} else {
|
||
audioArgs = [
|
||
"-c:a",
|
||
"aac",
|
||
"-b:a",
|
||
"192k",
|
||
"-ar",
|
||
"44100",
|
||
"-ac",
|
||
"2",
|
||
"-profile:a",
|
||
"aac_low",
|
||
];
|
||
console.log(
|
||
`[PRESEG] 转码: ${path.basename(filePath)} (${probe.codec} ${probe.sampleRate}Hz)`,
|
||
);
|
||
}
|
||
} catch (_) {
|
||
audioArgs = [
|
||
"-c:a",
|
||
"aac",
|
||
"-b:a",
|
||
"192k",
|
||
"-ar",
|
||
"44100",
|
||
"-ac",
|
||
"2",
|
||
"-profile:a",
|
||
"aac_low",
|
||
];
|
||
}
|
||
|
||
return new Promise((resolve, reject) => {
|
||
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(HLS_TIME),
|
||
"-hls_list_size",
|
||
"0",
|
||
"-hls_segment_type",
|
||
"mpegts",
|
||
"-hls_flags",
|
||
"independent_segments",
|
||
"-max_muxing_queue_size",
|
||
"2048",
|
||
"-f",
|
||
"hls",
|
||
outputM3u8,
|
||
];
|
||
|
||
const proc = spawn("ffmpeg", args);
|
||
let stderrBuf = "";
|
||
proc.stderr.on("data", (d) => {
|
||
stderrBuf += d.toString();
|
||
if (stderrBuf.length > 4096) stderrBuf = stderrBuf.slice(-4096);
|
||
});
|
||
|
||
// [FIX] 超时保护:防止 FFmpeg 挂死阻塞队列
|
||
const timer = setTimeout(() => {
|
||
proc.kill("SIGKILL");
|
||
console.error(`[PRESEG] 超时终止: ${path.basename(filePath)}`);
|
||
}, FFMPEG_TIMEOUT);
|
||
|
||
proc.on("close", (code) => {
|
||
clearTimeout(timer);
|
||
if (code === 0) {
|
||
console.log(`[PRESEG] 完成: ${path.basename(filePath)}`);
|
||
cacheStatus.set(filePath, "ready");
|
||
songSegmentsCache.delete(filePath);
|
||
retryCount.delete(filePath);
|
||
cacheTotalSize += getDirSize(cacheDir);
|
||
enforceCacheSizeLimit();
|
||
broadcastSSE("cache-status", {
|
||
file: path.basename(filePath),
|
||
status: "ready",
|
||
});
|
||
resolve();
|
||
} else {
|
||
cacheStatus.set(filePath, "error");
|
||
if (fs.existsSync(cacheDir))
|
||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||
console.error(
|
||
`[PRESEG] 失败 code=${code}: ${path.basename(filePath)}`,
|
||
);
|
||
reject(new Error(`FFmpeg exit ${code}`));
|
||
}
|
||
});
|
||
|
||
proc.on("error", (err) => {
|
||
clearTimeout(timer);
|
||
cacheStatus.set(filePath, "error");
|
||
reject(err);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
function preGenerateAll() {
|
||
for (const file of audioFiles) {
|
||
const status = cacheStatus.get(file);
|
||
if (!status || status === "error") triggerPreGenerate(file);
|
||
}
|
||
}
|
||
|
||
// ==================== 直播流核心 ====================
|
||
function generateLivePlaylist() {
|
||
if (liveSegments.length === 0) {
|
||
atomicWriteSync(
|
||
path.join(HLS_OUTPUT_DIR, "index.m3u8"),
|
||
`#EXTM3U\n#EXT-X-VERSION:3\n` +
|
||
`#EXT-X-TARGETDURATION:${HLS_TIME + 1}\n` +
|
||
`#EXT-X-INDEPENDENT-SEGMENTS\n` +
|
||
`#EXT-X-MEDIA-SEQUENCE:0\n`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
// TARGETDURATION = 所有切片最大时长向上取整 + 1(安全余量)
|
||
const maxDur = liveSegments.reduce(
|
||
(m, s) => Math.max(m, s.duration),
|
||
HLS_TIME,
|
||
);
|
||
const targetDur = Math.max(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:${mediaSequence}\n`;
|
||
|
||
for (let i = 0; i < liveSegments.length; i++) {
|
||
// 可选:仅在启用时插入不连续性标记
|
||
if (
|
||
USE_DISCONTINUITY &&
|
||
i > 0 &&
|
||
liveSegments[i].songId !== liveSegments[i - 1].songId
|
||
) {
|
||
content += "#EXT-X-DISCONTINUITY\n";
|
||
}
|
||
content += `#EXTINF:${liveSegments[i].duration.toFixed(3)},\n${liveSegments[i].path}\n`;
|
||
}
|
||
|
||
// [FIX] 原子写入
|
||
atomicWriteSync(path.join(HLS_OUTPUT_DIR, "index.m3u8"), content);
|
||
}
|
||
|
||
function advanceLiveStream() {
|
||
if (audioFiles.length === 0) return;
|
||
|
||
if (playlist.length === 0) {
|
||
playlist.push(audioFiles[Math.floor(Math.random() * audioFiles.length)]);
|
||
savePlaylist();
|
||
currentPlaylistIdx = 0;
|
||
currentSongSegmentIdx = 0;
|
||
broadcastSSE("playlist-update", getPlaylistSnapshot());
|
||
}
|
||
|
||
let safety = playlist.length + 2;
|
||
while (safety-- > 0) {
|
||
clampPlaylistIndex();
|
||
|
||
const currentSong = playlist[currentPlaylistIdx];
|
||
const status = cacheStatus.get(currentSong);
|
||
|
||
// 未就绪 → 跳下一首
|
||
if (status !== "ready") {
|
||
if (status !== "pending") triggerPreGenerate(currentSong);
|
||
currentPlaylistIdx = (currentPlaylistIdx + 1) % playlist.length;
|
||
currentSongSegmentIdx = 0;
|
||
continue;
|
||
}
|
||
|
||
const segments = loadSongSegments(currentSong);
|
||
if (!segments || segments.length === 0) {
|
||
currentPlaylistIdx = (currentPlaylistIdx + 1) % playlist.length;
|
||
currentSongSegmentIdx = 0;
|
||
continue;
|
||
}
|
||
|
||
// ===== 歌曲播放完毕 → 从列表中移除 =====
|
||
if (currentSongSegmentIdx >= segments.length) {
|
||
const finishedName = path.basename(currentSong);
|
||
// splice 只移除当前位置的一个实例,同歌其他实例保留
|
||
playlist.splice(currentPlaylistIdx, 1);
|
||
songSegmentsCache.delete(currentSong);
|
||
currentSongSegmentIdx = 0;
|
||
|
||
// 列表空了 → 自动补一首
|
||
if (playlist.length === 0 && audioFiles.length > 0) {
|
||
playlist.push(
|
||
audioFiles[Math.floor(Math.random() * audioFiles.length)],
|
||
);
|
||
currentPlaylistIdx = 0;
|
||
}
|
||
|
||
savePlaylist();
|
||
clampPlaylistIndex();
|
||
broadcastSSE("song-finished", { name: finishedName });
|
||
broadcastSSE("playlist-update", getPlaylistSnapshot());
|
||
|
||
// 确保下一首缓存就绪
|
||
if (
|
||
playlist.length > 0 &&
|
||
cacheStatus.get(playlist[currentPlaylistIdx]) !== "ready"
|
||
) {
|
||
triggerPreGenerate(playlist[currentPlaylistIdx]);
|
||
}
|
||
|
||
continue; // 立即尝试追加下一首的切片,不留间隙
|
||
}
|
||
|
||
// ===== 正常追加切片 =====
|
||
const seg = segments[currentSongSegmentIdx];
|
||
const songId = fileHash(currentSong);
|
||
liveSegments.push({
|
||
duration: seg.duration,
|
||
path: path.posix.join("/hls_cache", songId, seg.fileName),
|
||
songId,
|
||
});
|
||
|
||
while (liveSegments.length > SLIDE_WINDOW_SIZE) {
|
||
liveSegments.shift();
|
||
mediaSequence++;
|
||
}
|
||
|
||
currentSongSegmentIdx++;
|
||
generateLivePlaylist();
|
||
return; // 成功追加一个切片,等下一个 tick
|
||
}
|
||
|
||
console.warn("[LIVE] 所有歌曲均不可用");
|
||
}
|
||
|
||
function startLiveStream() {
|
||
if (liveTimer) clearInterval(liveTimer);
|
||
|
||
// 预填充窗口(以实际长度为终止条件)
|
||
let attempts = 0;
|
||
const target = Math.floor(SLIDE_WINDOW_SIZE / 2);
|
||
while (liveSegments.length < target && attempts < SLIDE_WINDOW_SIZE * 2) {
|
||
const prev = liveSegments.length;
|
||
advanceLiveStream();
|
||
attempts++;
|
||
if (liveSegments.length === prev && liveSegments.length === 0) break;
|
||
}
|
||
|
||
liveTimer = setInterval(advanceLiveStream, LIVE_TICK_INTERVAL);
|
||
console.log(
|
||
`[LIVE] 已启动 | 切片 ${HLS_TIME}s | 窗口 ${SLIDE_WINDOW_SIZE} 段 ≈${SLIDE_WINDOW_SIZE * HLS_TIME}s | 预填充 ${liveSegments.length} 段`,
|
||
);
|
||
}
|
||
|
||
// ==================== 资源扫描 ====================
|
||
function scanAudioFiles() {
|
||
const exts = [".mp3", ".m4a", ".flac", ".wav"];
|
||
audioFiles = fs
|
||
.readdirSync(AUDIO_DIR)
|
||
.filter((n) => exts.includes(path.extname(n).toLowerCase()))
|
||
.map((n) => path.join(AUDIO_DIR, n))
|
||
.sort(naturalCompare);
|
||
console.log(`[INFO] 资源: ${audioFiles.length} 个文件`);
|
||
}
|
||
|
||
function sanitizePlaylist() {
|
||
const valid = new Set(audioFiles);
|
||
for (const p of playlist) {
|
||
if (!valid.has(p)) songSegmentsCache.delete(p);
|
||
}
|
||
playlist = playlist.filter((p) => valid.has(p));
|
||
clampPlaylistIndex();
|
||
savePlaylist();
|
||
}
|
||
|
||
// ==================== [FIX] 初始化顺序 ====================
|
||
// 1. 加载持久化数据
|
||
// 2. 扫描文件列表
|
||
// 3. 验证已有缓存(关键!在 preGenerateAll 之前)
|
||
// 4. 只生成缺失的缓存
|
||
loadHeatMap();
|
||
loadMetadata();
|
||
loadPlaylist();
|
||
scanAudioFiles();
|
||
audioFiles.forEach((f) => ensureMetadata(f)); // 异步补全元数据(封面/时长等)
|
||
scanExistingCaches(); // ← 识别已有缓存,设为 ready
|
||
preGenerateAll(); // ← 只处理 status 为空或 error 的文件
|
||
enforceCacheSizeLimit();
|
||
sanitizePlaylist();
|
||
startLiveStream();
|
||
|
||
// ==================== 文件监听(防抖)====================
|
||
const watcher = chokidar.watch(AUDIO_DIR, {
|
||
ignoreInitial: true,
|
||
awaitWriteFinish: { stabilityThreshold: 500 },
|
||
});
|
||
function scheduleScan() {
|
||
if (scanDebounceTimer) clearTimeout(scanDebounceTimer);
|
||
scanDebounceTimer = setTimeout(() => {
|
||
scanAudioFiles();
|
||
audioFiles.forEach((f) => ensureMetadata(f));
|
||
preGenerateAll();
|
||
enforceCacheSizeLimit();
|
||
sanitizePlaylist();
|
||
}, 500);
|
||
}
|
||
watcher.on("add", scheduleScan);
|
||
watcher.on("unlink", scheduleScan);
|
||
|
||
// ==================== 认证 ====================
|
||
function authMiddleware(req, res, next) {
|
||
if (!AUTH_TOKEN) return next();
|
||
if (req.headers.authorization === `Bearer ${AUTH_TOKEN}`) return next();
|
||
return res.status(401).json({ error: "未授权" });
|
||
}
|
||
|
||
// ==================== HTTP API ====================
|
||
const app = express();
|
||
app.use((req, res, next) => {
|
||
res.header("Access-Control-Allow-Origin", "*");
|
||
res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||
if (req.method === "OPTIONS") return res.sendStatus(200);
|
||
next();
|
||
});
|
||
app.use(express.json());
|
||
app.use(express.static(PUBLIC_DIR));
|
||
|
||
// [FIX] M3U8 禁止浏览器缓存,确保每次都获取最新版本
|
||
app.use(
|
||
"/hls",
|
||
express.static(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(HLS_CACHE_DIR));
|
||
|
||
app.get("/api/events", (req, res) => {
|
||
res.writeHead(200, {
|
||
"Content-Type": "text/event-stream",
|
||
"Cache-Control": "no-cache",
|
||
Connection: "keep-alive",
|
||
});
|
||
res.write(":ok\n\n");
|
||
sseClients.add(res);
|
||
req.on("close", () => sseClients.delete(res));
|
||
});
|
||
|
||
app.use("/api", authMiddleware);
|
||
|
||
function isValidFileName(f) {
|
||
return (
|
||
typeof f === "string" &&
|
||
f.length > 0 &&
|
||
f.length < 500 &&
|
||
!f.includes("..") &&
|
||
!f.includes("/") &&
|
||
!f.includes("\\") &&
|
||
!f.includes("\0")
|
||
);
|
||
}
|
||
|
||
app.get("/api/resources", (req, res) => {
|
||
res.json(
|
||
audioFiles.map((f) => {
|
||
const m = metadataMap.get(f) || {
|
||
name: path.basename(f),
|
||
artist: "",
|
||
albumImage: "",
|
||
duration: null,
|
||
};
|
||
let fileSize = 0;
|
||
try {
|
||
fileSize = fs.statSync(f).size;
|
||
} catch (_) {}
|
||
return {
|
||
file: path.basename(f),
|
||
path: f,
|
||
title: m.name,
|
||
artist: m.artist || "",
|
||
albumImage: m.albumImage || "",
|
||
duration: m.duration || null,
|
||
fileSize,
|
||
heat: heatMap.get(f) || 0,
|
||
cacheStatus: cacheStatus.get(f) || "unknown",
|
||
cacheSize: (() => {
|
||
const d = getCacheDir(f);
|
||
return fs.existsSync(d) ? getDirSize(d) : 0;
|
||
})(),
|
||
};
|
||
}),
|
||
);
|
||
});
|
||
|
||
// 网易云音源:歌名搜索
|
||
app.get("/api/music/search", async (req, res) => {
|
||
const { keyword } = req.query;
|
||
if (!keyword || !String(keyword).trim())
|
||
return res.status(400).json({ error: "缺少搜索关键词" });
|
||
try {
|
||
res.json(await searchNetEase(String(keyword).trim()));
|
||
} catch (e) {
|
||
res.status(400).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
// 网易云音源:查询歌曲详情(歌名/作者/专辑图)
|
||
app.post("/api/music/detail", async (req, res) => {
|
||
const { id } = req.body;
|
||
if (!id || !/^\d+$/.test(String(id)))
|
||
return res.status(400).json({ error: "无效 id" });
|
||
try {
|
||
res.json(await fetchNetEaseDetail(String(id)));
|
||
} catch (e) {
|
||
res.status(400).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
// 网易云音源:下载入库(保存到 audio/,watcher 自动切片)
|
||
app.post("/api/music/download", async (req, res) => {
|
||
const { id, name, artist, albumImage } = req.body;
|
||
if (!id || !/^\d+$/.test(String(id)))
|
||
return res.status(400).json({ error: "无效 id" });
|
||
try {
|
||
// 缺省时重新查详情,统一拿到歌名/作者/封面
|
||
let meta = { name, artist: artist || "", albumImage: albumImage || "" };
|
||
if (!meta.name) {
|
||
const info = await fetchNetEaseDetail(String(id));
|
||
meta = {
|
||
name: info.name,
|
||
artist: info.artist || "",
|
||
albumImage: info.albumImage || "",
|
||
};
|
||
}
|
||
const filename = sanitizeFileName(
|
||
meta.artist ? `${meta.artist} - ${meta.name}` : meta.name,
|
||
);
|
||
if (!filename) return res.status(400).json({ error: "缺少歌名" });
|
||
|
||
// 防重复:同名文件已存在则不覆盖(覆盖触发 change 而非 add,不会重新切片)
|
||
if (fs.existsSync(path.join(AUDIO_DIR, filename + ".mp3")))
|
||
return res.status(409).json({ error: `已存在: ${filename}.mp3` });
|
||
|
||
const filePath = await downloadNetEase(String(id), filename);
|
||
metadataMap.set(filePath, { ...meta, duration: null }); // 时长稍后由 ensureMetadata 补齐
|
||
saveMetadata();
|
||
res.json({ success: true, file: path.basename(filePath) });
|
||
} catch (e) {
|
||
res.status(400).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
app.get("/api/playlist", (req, res) => res.json(getPlaylistSnapshot()));
|
||
|
||
app.post("/api/playlist/add", (req, res) => {
|
||
const { file } = req.body;
|
||
if (!isValidFileName(file))
|
||
return res.status(400).json({ error: "无效文件名" });
|
||
const tp = path.join(AUDIO_DIR, file);
|
||
if (!audioFiles.includes(tp))
|
||
return res.status(404).json({ error: "资源不存在" });
|
||
playlist.push(tp);
|
||
incrementHeat(tp);
|
||
savePlaylist();
|
||
if (cacheStatus.get(tp) !== "ready") triggerPreGenerate(tp);
|
||
broadcastSSE("playlist-update", getPlaylistSnapshot());
|
||
res.json({ success: true, playlist: playlist.map((p) => path.basename(p)) });
|
||
});
|
||
|
||
app.post("/api/playlist/remove", (req, res) => {
|
||
const { index } = req.body;
|
||
if (
|
||
typeof index !== "number" ||
|
||
!Number.isInteger(index) ||
|
||
index < 0 ||
|
||
index >= playlist.length
|
||
)
|
||
return res.status(400).json({ error: "无效索引" });
|
||
if (index < currentPlaylistIdx) currentPlaylistIdx--;
|
||
else if (index === currentPlaylistIdx) currentSongSegmentIdx = 0;
|
||
playlist.splice(index, 1);
|
||
if (playlist.length === 0 && audioFiles.length > 0) {
|
||
playlist.push(audioFiles[Math.floor(Math.random() * audioFiles.length)]);
|
||
currentPlaylistIdx = 0;
|
||
currentSongSegmentIdx = 0;
|
||
}
|
||
clampPlaylistIndex();
|
||
savePlaylist();
|
||
broadcastSSE("playlist-update", getPlaylistSnapshot());
|
||
res.json({ success: true, playlist: playlist.map((p) => path.basename(p)) });
|
||
});
|
||
|
||
app.post("/api/playlist/reorder", (req, res) => {
|
||
const { newOrder } = req.body;
|
||
if (!Array.isArray(newOrder))
|
||
return res.status(400).json({ error: "无效数据" });
|
||
const valid = new Set(audioFiles);
|
||
const cur = playlist[currentPlaylistIdx];
|
||
playlist = newOrder.filter((p) => typeof p === "string" && valid.has(p));
|
||
const ni = playlist.indexOf(cur);
|
||
currentPlaylistIdx = ni !== -1 ? ni : 0;
|
||
if (ni === -1) currentSongSegmentIdx = 0;
|
||
if (playlist.length === 0 && audioFiles.length > 0) {
|
||
playlist.push(audioFiles[Math.floor(Math.random() * audioFiles.length)]);
|
||
currentPlaylistIdx = 0;
|
||
currentSongSegmentIdx = 0;
|
||
}
|
||
savePlaylist();
|
||
broadcastSSE("playlist-update", getPlaylistSnapshot());
|
||
res.json({ success: true, playlist: playlist.map((p) => path.basename(p)) });
|
||
});
|
||
|
||
app.post("/api/playlist/clear", (req, res) => {
|
||
for (const f of playlist) songSegmentsCache.delete(f);
|
||
playlist = [];
|
||
if (audioFiles.length > 0)
|
||
playlist.push(audioFiles[Math.floor(Math.random() * audioFiles.length)]);
|
||
currentPlaylistIdx = 0;
|
||
currentSongSegmentIdx = 0;
|
||
savePlaylist();
|
||
broadcastSSE("playlist-update", getPlaylistSnapshot());
|
||
res.json({ success: true, playlist: playlist.map((p) => path.basename(p)) });
|
||
});
|
||
|
||
app.post("/api/playlist/next", (req, res) => {
|
||
if (playlist.length === 0)
|
||
return res.status(400).json({ error: "播放列表为空" });
|
||
currentPlaylistIdx = (currentPlaylistIdx + 1) % playlist.length;
|
||
currentSongSegmentIdx = 0;
|
||
if (cacheStatus.get(playlist[currentPlaylistIdx]) !== "ready")
|
||
triggerPreGenerate(playlist[currentPlaylistIdx]);
|
||
broadcastSSE("now-playing", {
|
||
index: currentPlaylistIdx,
|
||
name: path.basename(playlist[currentPlaylistIdx]),
|
||
});
|
||
broadcastSSE("playlist-update", getPlaylistSnapshot());
|
||
res.json({
|
||
success: true,
|
||
current: path.basename(playlist[currentPlaylistIdx]),
|
||
});
|
||
});
|
||
|
||
app.post("/api/playlist/prev", (req, res) => {
|
||
if (playlist.length === 0)
|
||
return res.status(400).json({ error: "播放列表为空" });
|
||
currentPlaylistIdx =
|
||
(currentPlaylistIdx - 1 + playlist.length) % playlist.length;
|
||
currentSongSegmentIdx = 0;
|
||
if (cacheStatus.get(playlist[currentPlaylistIdx]) !== "ready")
|
||
triggerPreGenerate(playlist[currentPlaylistIdx]);
|
||
broadcastSSE("now-playing", {
|
||
index: currentPlaylistIdx,
|
||
name: path.basename(playlist[currentPlaylistIdx]),
|
||
});
|
||
broadcastSSE("playlist-update", getPlaylistSnapshot());
|
||
res.json({
|
||
success: true,
|
||
current: path.basename(playlist[currentPlaylistIdx]),
|
||
});
|
||
});
|
||
|
||
app.post("/api/playlist/jump", (req, res) => {
|
||
const { index } = req.body;
|
||
if (
|
||
typeof index !== "number" ||
|
||
!Number.isInteger(index) ||
|
||
index < 0 ||
|
||
index >= playlist.length
|
||
)
|
||
return res.status(400).json({ error: "无效索引" });
|
||
currentPlaylistIdx = index;
|
||
currentSongSegmentIdx = 0;
|
||
if (cacheStatus.get(playlist[currentPlaylistIdx]) !== "ready")
|
||
triggerPreGenerate(playlist[currentPlaylistIdx]);
|
||
broadcastSSE("now-playing", {
|
||
index: currentPlaylistIdx,
|
||
name: path.basename(playlist[currentPlaylistIdx]),
|
||
});
|
||
broadcastSSE("playlist-update", getPlaylistSnapshot());
|
||
res.json({
|
||
success: true,
|
||
current: path.basename(playlist[currentPlaylistIdx]),
|
||
});
|
||
});
|
||
|
||
app.get("/api/cache/size", (req, res) => {
|
||
res.json({
|
||
total: cacheTotalSize,
|
||
limit: MAX_CACHE_SIZE,
|
||
usedPercent: ((cacheTotalSize / MAX_CACHE_SIZE) * 100).toFixed(2),
|
||
});
|
||
});
|
||
|
||
app.post("/api/cache/cleanup", (req, res) => {
|
||
enforceCacheSizeLimit();
|
||
res.json({ success: true, total: cacheTotalSize, limit: MAX_CACHE_SIZE });
|
||
});
|
||
|
||
// ==================== 启动 ====================
|
||
server = app.listen(HTTP_PORT, () => {
|
||
console.log(`[HTTP] 面板: http://127.0.0.1:${HTTP_PORT}`);
|
||
console.log(`[HTTP] 直播: http://127.0.0.1:${HTTP_PORT}/hls/index.m3u8`);
|
||
if (AUTH_TOKEN) console.log("[HTTP] 认证已启用");
|
||
});
|
||
|
||
process.on("uncaughtException", (err) => {
|
||
console.error("[FATAL]", err.message, err.stack);
|
||
});
|
||
process.on("unhandledRejection", (r) => console.error("[WARN]", r));
|
||
|
||
process.on("SIGINT", () => {
|
||
console.log("\n[EXIT] 正在关闭...");
|
||
if (liveTimer) clearInterval(liveTimer);
|
||
if (heatSaveTimer) clearTimeout(heatSaveTimer);
|
||
if (scanDebounceTimer) clearTimeout(scanDebounceTimer);
|
||
for (const c of sseClients)
|
||
try {
|
||
c.end();
|
||
} catch (_) {}
|
||
saveHeatMap();
|
||
watcher.close().then(() => server.close(() => process.exit(0)));
|
||
});
|