Files
StarlightRadio/index.js
T

1234 lines
38 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)));
});