Files
StarlightRadio/src/livestream.js
T

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