447 lines
13 KiB
JavaScript
447 lines
13 KiB
JavaScript
"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,
|
|
}; |