Initial commit — OFApp client + server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
116
server/hls.js
Normal file
116
server/hls.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Router } from 'express';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { execFile, spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getSetting } from './db.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const router = Router();
|
||||
const MEDIA_PATH = process.env.MEDIA_PATH || './data/media';
|
||||
const SEGMENT_DURATION = 10;
|
||||
|
||||
function isHlsEnabled() {
|
||||
return (getSetting('hls_enabled') || process.env.HLS_ENABLED) === 'true';
|
||||
}
|
||||
|
||||
function validatePath(folder, filename) {
|
||||
if (folder.includes('..') || filename.includes('..')) return null;
|
||||
if (folder.includes('/') || folder.includes('\\')) return null;
|
||||
if (filename.includes('/') || filename.includes('\\')) return null;
|
||||
const filePath = join(MEDIA_PATH, folder, filename);
|
||||
if (!existsSync(filePath)) return null;
|
||||
return filePath;
|
||||
}
|
||||
|
||||
// GET /api/hls/:folder/:filename/master.m3u8
|
||||
router.get('/api/hls/:folder/:filename/master.m3u8', async (req, res) => {
|
||||
if (!isHlsEnabled()) {
|
||||
return res.status(404).json({ error: 'HLS not enabled' });
|
||||
}
|
||||
|
||||
const { folder, filename } = req.params;
|
||||
const filePath = validatePath(folder, filename);
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: 'Invalid path' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('ffprobe', [
|
||||
'-v', 'error',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'csv=p=0',
|
||||
filePath,
|
||||
]);
|
||||
|
||||
const duration = parseFloat(stdout.trim());
|
||||
if (isNaN(duration) || duration <= 0) {
|
||||
return res.status(500).json({ error: 'Could not determine video duration' });
|
||||
}
|
||||
|
||||
const segmentCount = Math.ceil(duration / SEGMENT_DURATION);
|
||||
let playlist = '#EXTM3U\n#EXT-X-VERSION:3\n';
|
||||
playlist += `#EXT-X-TARGETDURATION:${SEGMENT_DURATION}\n`;
|
||||
playlist += '#EXT-X-MEDIA-SEQUENCE:0\n';
|
||||
|
||||
for (let i = 0; i < segmentCount; i++) {
|
||||
const remaining = duration - i * SEGMENT_DURATION;
|
||||
const segDuration = Math.min(SEGMENT_DURATION, remaining);
|
||||
playlist += `#EXTINF:${segDuration.toFixed(3)},\n`;
|
||||
playlist += `segment-${i}.ts\n`;
|
||||
}
|
||||
|
||||
playlist += '#EXT-X-ENDLIST\n';
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
||||
res.send(playlist);
|
||||
} catch (err) {
|
||||
console.error('[hls] ffprobe error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to probe video' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/hls/:folder/:filename/segment-:index.ts
|
||||
router.get('/api/hls/:folder/:filename/segment-:index.ts', (req, res) => {
|
||||
if (!isHlsEnabled()) {
|
||||
return res.status(404).json({ error: 'HLS not enabled' });
|
||||
}
|
||||
|
||||
const { folder, filename, index } = req.params;
|
||||
const filePath = validatePath(folder, filename);
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: 'Invalid path' });
|
||||
}
|
||||
|
||||
const segIndex = parseInt(index, 10);
|
||||
if (isNaN(segIndex) || segIndex < 0) {
|
||||
return res.status(400).json({ error: 'Invalid segment index' });
|
||||
}
|
||||
|
||||
const offset = segIndex * SEGMENT_DURATION;
|
||||
|
||||
const ffmpeg = spawn('ffmpeg', [
|
||||
'-ss', String(offset),
|
||||
'-i', filePath,
|
||||
'-t', String(SEGMENT_DURATION),
|
||||
'-c', 'copy',
|
||||
'-f', 'mpegts',
|
||||
'pipe:1',
|
||||
], { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
|
||||
res.setHeader('Content-Type', 'video/MP2T');
|
||||
ffmpeg.stdout.pipe(res);
|
||||
|
||||
req.on('close', () => {
|
||||
ffmpeg.kill('SIGKILL');
|
||||
});
|
||||
|
||||
ffmpeg.on('error', (err) => {
|
||||
console.error('[hls] ffmpeg error:', err.message);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Transcoding failed' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user