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))); });