"use strict"; const fs = require("fs"); const path = require("path"); const config = require("./config"); const logger = require("./logger"); const store = require("./store"); const media = require("./media"); const { fileHash, atomicWriteSync } = require("./util"); /* ==================== 缓存状态管理(全局共享,因为切片只依赖歌曲) ==================== */ const cacheStatus = new Map(); // file -> 'pending'|'ready'|'error' const retryCount = new Map(); // file -> number const segmentsCache = new Map(); // file -> segments[] const inFlight = new Set(); // 正在转码中的 file(避免重复入队) let cacheTotalSize = 0; let onCacheChange = null; // 回调:cache-status 变化广播 function setCacheChangeListener(fn) { onCacheChange = fn; } function getCacheTotalSize() { return cacheTotalSize; } function addCacheSize(n) { cacheTotalSize += n; } function setStatus(file, status) { cacheStatus.set(file, status); store.setCacheStatus(file, status); if (onCacheChange) onCacheChange(file, status); } function getStatus(file) { return cacheStatus.get(file) || "unknown"; } function getSegments(file) { if (segmentsCache.has(file)) return segmentsCache.get(file); const segs = media.loadSongSegments(file); if (segs) segmentsCache.set(file, segs); return segs; } function invalidateSegments(file) { segmentsCache.delete(file); } // 触发预生成(带去重与重试上限) // 说明:pending 表示“待生成”,不阻止入队;用 inFlight 防止重复入队 function triggerPreGenerate(file) { if (cacheStatus.get(file) === "ready") return; if (inFlight.has(file)) return; const retries = retryCount.get(file) || 0; if (retries >= config.MAX_RETRIES) { logger.warn(`[PRESEG] 跳过(已重试 ${config.MAX_RETRIES} 次): ${file}`); setStatus(file, "error"); return; } retryCount.set(file, retries + 1); inFlight.add(file); setStatus(file, "pending"); const filePath = path.join(config.AUDIO_DIR, file); media .triggerPreGenerate(filePath, file) .then((res) => { inFlight.delete(file); if (res && res.size) cacheTotalSize += res.size; invalidateSegments(file); retryCount.delete(file); setStatus(file, "ready"); if (onCacheSizeCheck) onCacheSizeCheck(); }) .catch(() => { inFlight.delete(file); setStatus(file, "error"); }); } let onCacheSizeCheck = null; function setCacheSizeCheck(fn) { onCacheSizeCheck = fn; } // 启动时扫描已有缓存 function initCaches(songs) { cacheTotalSize = media.scanExistingCaches(songs); for (const song of songs) { const dir = media.getCacheDir(song.file); if (fs.existsSync(dir)) { const segs = media.loadSongSegments(song.file); if (segs) { cacheStatus.set(song.file, "ready"); store.setCacheStatus(song.file, "ready"); continue; } } cacheStatus.set(song.file, "pending"); store.setCacheStatus(song.file, "pending"); } } /* ==================== 缓存清理 ==================== */ function enforceCacheSizeLimit(allFiles) { if (cacheTotalSize <= config.MAX_CACHE_SIZE) return; logger.info(`[CACHE] ${(cacheTotalSize / 1024 / 1024).toFixed(1)}MB 超限,开始清理`); // 收集所有流正在播放/窗口内的歌曲,作为保护集 const protectedFiles = new Set(); for (const s of streams.values()) { for (const f of s.protectedFiles()) protectedFiles.add(f); } const items = []; const dirs = fs.readdirSync(config.HLS_CACHE_DIR); for (const entry of dirs) { const dir = path.join(config.HLS_CACHE_DIR, entry); try { if (!fs.statSync(dir).isDirectory()) continue; } catch (_) { continue; } let origFile = allFiles.find((f) => fileHash(f) === entry); const size = require("./util").getDirSize(dir); const song = origFile ? store.getSongByFile(origFile) : null; items.push({ dir, size, origFile, isProtected: origFile ? protectedFiles.has(origFile) : false, heat: song ? song.heat || 0 : 0, }); } items.sort((a, b) => { if (a.isProtected !== b.isProtected) return a.isProtected ? 1 : -1; return a.heat - b.heat; }); for (const item of items) { if (cacheTotalSize <= config.MAX_CACHE_SIZE) break; logger.info(`[CACHE] 清理: ${path.basename(item.dir)} (${(item.size / 1024 / 1024).toFixed(1)}MB)`); fs.rmSync(item.dir, { recursive: true, force: true }); cacheTotalSize = Math.max(0, cacheTotalSize - item.size); if (item.origFile) { invalidateSegments(item.origFile); setStatus(item.origFile, "pending"); } } } /* ==================== LiveStream ==================== */ const streams = new Map(); // id -> LiveStream class LiveStream { constructor(id, name) { this.id = id; this.name = name; this.liveSegments = []; // { duration, path, songId(file), songFile } this.mediaSequence = 0; this.currentIdx = 0; // 在播放列表行中的索引 this.currentSongSegmentIdx = 0; this.timer = null; this.outDir = path.join(config.HLS_OUTPUT_DIR, id); fs.mkdirSync(this.outDir, { recursive: true }); } get m3u8Path() { return path.join(this.outDir, "index.m3u8"); } // 当前受保护的歌曲文件(窗口内 + 接下来2首) protectedFiles() { const set = new Set(); for (const seg of this.liveSegments) set.add(seg.songFile); const rows = store.getPlaylistRows(this.id); for (let i = 0; i < Math.min(3, rows.length); i++) { const idx = (this.currentIdx + i) % rows.length; if (rows[idx]) set.add(rows[idx].file); } return set; } playlistRows() { return store.getPlaylistRows(this.id); } ensureNotEmpty() { let rows = this.playlistRows(); if (rows.length === 0) { const songs = store.getAllSongs(); if (songs.length > 0) { const pick = songs[Math.floor(Math.random() * songs.length)]; store.addToPlaylist(this.id, pick.id); this.currentIdx = 0; this.currentSongSegmentIdx = 0; rows = this.playlistRows(); } } return rows; } clampIdx(rows) { if (rows.length === 0) { this.currentIdx = 0; this.currentSongSegmentIdx = 0; return; } if (this.currentIdx < 0) this.currentIdx = 0; if (this.currentIdx >= rows.length) { this.currentIdx = 0; this.currentSongSegmentIdx = 0; } } advance() { const allSongs = store.getAllSongs(); if (allSongs.length === 0) return; let rows = this.ensureNotEmpty(); if (rows.length === 0) return; let safety = rows.length + 2; while (safety-- > 0) { this.clampIdx(rows); const row = rows[this.currentIdx]; if (!row) return; const file = row.file; const status = getStatus(file); if (status !== "ready") { if (status !== "pending") triggerPreGenerate(file); this.currentIdx = (this.currentIdx + 1) % rows.length; this.currentSongSegmentIdx = 0; continue; } const segments = getSegments(file); if (!segments || segments.length === 0) { this.currentIdx = (this.currentIdx + 1) % rows.length; this.currentSongSegmentIdx = 0; continue; } // 歌曲播完 → 从播放列表移除该条目 if (this.currentSongSegmentIdx >= segments.length) { const finishedName = file; store.removePlaylistEntry(this.id, row.entry_id); invalidateSegments(file); this.currentSongSegmentIdx = 0; rows = this.ensureNotEmpty(); if (rows.length === 0) return; this.clampIdx(rows); if (onPlaylistChange) onPlaylistChange(this.id); if (onSongFinished) onSongFinished(this.id, finishedName); const nf = rows[this.currentIdx] && rows[this.currentIdx].file; if (nf && getStatus(nf) !== "ready") triggerPreGenerate(nf); continue; } // 正常追加切片 const seg = segments[this.currentSongSegmentIdx]; const songId = fileHash(file); this.liveSegments.push({ duration: seg.duration, path: path.posix.join("/hls_cache", songId, seg.fileName), songId, songFile: file, }); while (this.liveSegments.length > config.SLIDE_WINDOW_SIZE) { this.liveSegments.shift(); this.mediaSequence++; } this.currentSongSegmentIdx++; this.writePlaylist(); store.setStreamState(this.id, { media_seq: this.mediaSequence }); return; } logger.warn(`[LIVE:${this.id}] 所有歌曲均不可用`); } writePlaylist() { if (this.liveSegments.length === 0) { atomicWriteSync( this.m3u8Path, `#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:${config.HLS_TIME + 1}\n` + `#EXT-X-INDEPENDENT-SEGMENTS\n#EXT-X-MEDIA-SEQUENCE:0\n` ); return; } const maxDur = this.liveSegments.reduce((m, s) => Math.max(m, s.duration), config.HLS_TIME); const targetDur = Math.max(config.HLS_TIME + 1, Math.ceil(maxDur) + 1); let content = "#EXTM3U\n#EXT-X-VERSION:3\n"; content += `#EXT-X-TARGETDURATION:${targetDur}\n`; content += "#EXT-X-INDEPENDENT-SEGMENTS\n"; content += `#EXT-X-MEDIA-SEQUENCE:${this.mediaSequence}\n`; for (let i = 0; i < this.liveSegments.length; i++) { if (config.USE_DISCONTINUITY && i > 0 && this.liveSegments[i].songId !== this.liveSegments[i - 1].songId) { content += "#EXT-X-DISCONTINUITY\n"; } content += `#EXTINF:${this.liveSegments[i].duration.toFixed(3)},\n${this.liveSegments[i].path}\n`; } atomicWriteSync(this.m3u8Path, content); } start() { if (this.timer) clearInterval(this.timer); let attempts = 0; const target = Math.floor(config.SLIDE_WINDOW_SIZE / 2); while (this.liveSegments.length < target && attempts < config.SLIDE_WINDOW_SIZE * 2) { const prev = this.liveSegments.length; this.advance(); attempts++; if (this.liveSegments.length === prev && this.liveSegments.length === 0) break; } this.timer = setInterval(() => this.advance(), config.LIVE_TICK_INTERVAL); logger.info( `[LIVE:${this.id}] 已启动 | 切片 ${config.HLS_TIME}s | 窗口 ${config.SLIDE_WINDOW_SIZE} 段 | 预填充 ${this.liveSegments.length} 段` ); } stop() { if (this.timer) { clearInterval(this.timer); this.timer = null; } logger.info(`[LIVE:${this.id}] 已停止`); } resetCursor() { this.currentSongSegmentIdx = 0; } next() { const rows = this.playlistRows(); if (rows.length === 0) return null; this.currentIdx = (this.currentIdx + 1) % rows.length; this.currentSongSegmentIdx = 0; const f = rows[this.currentIdx].file; if (getStatus(f) !== "ready") triggerPreGenerate(f); return f; } prev() { const rows = this.playlistRows(); if (rows.length === 0) return null; this.currentIdx = (this.currentIdx - 1 + rows.length) % rows.length; this.currentSongSegmentIdx = 0; const f = rows[this.currentIdx].file; if (getStatus(f) !== "ready") triggerPreGenerate(f); return f; } jump(index) { const rows = this.playlistRows(); if (index < 0 || index >= rows.length) return null; this.currentIdx = index; this.currentSongSegmentIdx = 0; const f = rows[this.currentIdx].file; if (getStatus(f) !== "ready") triggerPreGenerate(f); return f; } } /* ==================== 回调 ==================== */ let onPlaylistChange = null; let onSongFinished = null; function setStreamCallbacks({ playlistChange, songFinished }) { onPlaylistChange = playlistChange; onSongFinished = songFinished; } /* ==================== 管理器 ==================== */ function loadStreams() { const rows = store.listStreams(); for (const r of rows) { const s = new LiveStream(r.id, r.name); s.currentIdx = r.current_pos || 0; s.mediaSequence = r.media_seq || 0; streams.set(r.id, s); } logger.info(`[LIVE] 载入 ${streams.size} 路流: ${[...streams.keys()].join(", ")}`); } function createStream(id, name) { if (streams.has(id)) throw new Error("流已存在"); store.createStream(id, name); const s = new LiveStream(id, name); streams.set(id, s); s.start(); return s; } function deleteStream(id) { if (id === "main") throw new Error("默认流不可删除"); const s = streams.get(id); if (s) { s.stop(); streams.delete(id); } store.deleteStream(id); // 删输出目录 const dir = path.join(config.HLS_OUTPUT_DIR, id); if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); } function getStream(id) { return streams.get(id); } function listStreams() { return [...streams.values()].map((s) => ({ id: s.id, name: s.name, running: !!s.timer, m3u8: `/hls/${s.id}/index.m3u8`, })); } function startAll() { for (const s of streams.values()) s.start(); } function stopAll() { for (const s of streams.values()) s.stop(); } module.exports = { LiveStream, streams, loadStreams, createStream, deleteStream, getStream, listStreams, startAll, stopAll, // cache API getStatus, setStatus, getSegments, invalidateSegments, triggerPreGenerate, initCaches, enforceCacheSizeLimit, getCacheTotalSize, addCacheSize, setCacheChangeListener, setCacheSizeCheck, setStreamCallbacks, };