Initial commit — OFApp client + server

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-12 20:07:06 -06:00
commit c60de19348
43 changed files with 8679 additions and 0 deletions

116
server/hls.js Normal file
View 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;