260 lines
6.6 KiB
JavaScript
260 lines
6.6 KiB
JavaScript
// routes/hls.js
|
|
const express = require('express');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const db = require('../db/knex');
|
|
const config = require('../api_v1/config');
|
|
const { getUserAccess, filterReadableSources } = require('../api_v1/userAccess');
|
|
const { startJob, getJob } = require('../helpers/hlsManager');
|
|
|
|
const router = express.Router();
|
|
|
|
function countTsSegments(outDir) {
|
|
try {
|
|
return fs.readdirSync(outDir).filter(
|
|
name => name.toLowerCase().endsWith('.ts')
|
|
).length;
|
|
} catch (err) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
const HLS_BASE = path.resolve(process.env.HLS_BASE || path.join(process.cwd(), 'hls_output'));
|
|
const PUBLIC_BASE_URL = config.BASE_URL || null;
|
|
|
|
const PUBLIC_ROOT = path.resolve(__dirname, '..', 'public');
|
|
const PHOTOS_ROOT = path.join(PUBLIC_ROOT, 'photos');
|
|
|
|
function safeJoin(base, ...parts) {
|
|
const resolvedBase = path.resolve(base);
|
|
const resolved = path.resolve(base, ...parts);
|
|
const isInsideBase = resolved === resolvedBase || resolved.startsWith(`${resolvedBase}${path.sep}`);
|
|
if (!isInsideBase) throw new Error(`Path non sicuro: ${resolved}`);
|
|
return resolved;
|
|
}
|
|
|
|
// POST /hls/start
|
|
// body: { photoId } OR { inputPath } + outDir, callbackUrl, segmentsNeeded
|
|
router.post('/start', async (req, res) => {
|
|
try {
|
|
const caller = String(req.user?.name ?? '').trim();
|
|
if (!caller) return res.status(401).json({ error: 'Unauthorized' });
|
|
|
|
// const { photoId, inputPath: providedPath, outDir = 'fabiocast', callbackUrl, segmentsNeeded = 10 } = req.body || {};
|
|
const { photoId, inputPath: providedPath, outDir = 'fabiocast', callbackUrl } = req.body || {};
|
|
const segmentsNeeded = 3;
|
|
|
|
let inputPath = providedPath;
|
|
let photoRow = null;
|
|
|
|
if (photoId) {
|
|
photoRow = await db('photos').where({ id: photoId }).first();
|
|
|
|
if (!photoRow) {
|
|
return res.status(404).json({
|
|
error: 'Photo not found'
|
|
});
|
|
}
|
|
|
|
const access = getUserAccess(caller);
|
|
|
|
if (!access) {
|
|
return res.status(403).json({
|
|
error: 'User not configured'
|
|
});
|
|
}
|
|
|
|
const allowed = filterReadableSources(
|
|
access.name,
|
|
photoRow.user
|
|
);
|
|
|
|
if (!allowed || allowed.length === 0) {
|
|
return res.status(403).json({
|
|
error: 'Access denied to this photo'
|
|
});
|
|
}
|
|
|
|
if (
|
|
photoRow.path &&
|
|
typeof photoRow.path === 'string' &&
|
|
photoRow.path.trim()
|
|
) {
|
|
const rel =
|
|
photoRow.path.replace(/^\/+/, '');
|
|
|
|
inputPath = safeJoin(
|
|
PUBLIC_ROOT,
|
|
rel
|
|
);
|
|
} else {
|
|
inputPath = safeJoin(
|
|
PHOTOS_ROOT,
|
|
photoRow.user,
|
|
'original',
|
|
photoRow.cartella || '',
|
|
photoRow.name
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!inputPath) {
|
|
return res.status(400).json({
|
|
error: 'Missing inputPath or photoId'
|
|
});
|
|
}
|
|
|
|
let jobParentDir;
|
|
|
|
if (photoRow) {
|
|
const videoBaseName =
|
|
path.parse(photoRow.name).name;
|
|
|
|
/*
|
|
* I file HLS sono contenuti temporanei di Cast.
|
|
*
|
|
* public/cast/<user>/videos/<cartella>/<nomevideo>/
|
|
*/
|
|
jobParentDir = safeJoin(
|
|
PUBLIC_ROOT,
|
|
'cast',
|
|
photoRow.user,
|
|
'videos',
|
|
photoRow.cartella || '',
|
|
videoBaseName
|
|
);
|
|
} else {
|
|
/*
|
|
* Compatibilita con vecchie chiamate basate soltanto
|
|
* su inputPath + outDir.
|
|
*/
|
|
const safeOut =
|
|
String(outDir || 'fabiocast')
|
|
.replace(/[^a-zA-Z0-9_\-\/]/g, '_');
|
|
|
|
jobParentDir =
|
|
path.isAbsolute(safeOut)
|
|
? safeOut
|
|
: path.join(HLS_BASE, safeOut);
|
|
}
|
|
|
|
const jobId = startJob({
|
|
inputPath,
|
|
outDir: jobParentDir,
|
|
segmentsNeeded,
|
|
callbackUrl,
|
|
publicBaseUrl: PUBLIC_BASE_URL
|
|
});
|
|
|
|
return res.status(202).json({ jobId, outDir: jobParentDir, message: 'Transcode started' });
|
|
} catch (err) {
|
|
console.error('Error /hls/start:', err);
|
|
return res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// GET /hls/status/:jobId
|
|
router.get('/status/:jobId', (req, res) => {
|
|
const job = getJob(req.params.jobId);
|
|
if (!job) return res.status(404).json({ error: 'Job not found' });
|
|
return res.json({
|
|
jobId: job.jobId,
|
|
inputPath: job.inputPath,
|
|
outDir: job.outDir,
|
|
notified: job.notified,
|
|
useRkmpp: job.useRkmpp,
|
|
startTime: job.startTime
|
|
});
|
|
});
|
|
|
|
// GET /hls/url/:jobId -> restituisce public URL completo se possibile
|
|
router.get('/url/:jobId', (req, res) => {
|
|
const job = getJob(req.params.jobId);
|
|
if (!job) return res.status(404).json({ error: 'Job not found' });
|
|
|
|
const masterPath = path.join(
|
|
job.outDir,
|
|
'master.m3u8'
|
|
);
|
|
|
|
/*
|
|
* Il master.m3u8 puo esistere prima che sia disponibile
|
|
* un buffer sufficiente per iniziare il Cast.
|
|
*
|
|
* hlsManager imposta job.notified=true soltanto quando
|
|
* sono disponibili almeno job.segmentsNeeded segmenti.
|
|
*
|
|
* Con segmentsNeeded=3 il client puo quindi iniziare
|
|
* la riproduzione appena sono pronti i primi 3 segmenti,
|
|
* mentre FFmpeg continua la transcodifica in background.
|
|
*/
|
|
const tsCount = countTsSegments(job.outDir);
|
|
|
|
|
|
const masterExists = fs.existsSync(masterPath);
|
|
const enoughSegments = tsCount >= job.segmentsNeeded;
|
|
|
|
const finishedPlayable =
|
|
job.exitCode === 0 &&
|
|
job.ready &&
|
|
masterExists &&
|
|
tsCount >= 1;
|
|
|
|
if (
|
|
!masterExists ||
|
|
!job.ready ||
|
|
(!enoughSegments && !finishedPlayable)
|
|
) {
|
|
if (job.exitCode !== null) {
|
|
return res.status(409).json({
|
|
error: 'HLS finished before minimum buffer was ready',
|
|
status: job.status,
|
|
ready_segments: tsCount,
|
|
required_segments: job.segmentsNeeded
|
|
});
|
|
}
|
|
|
|
return res.status(404).json({
|
|
error: 'Playlist not yet ready',
|
|
ready_segments: tsCount,
|
|
required_segments: job.segmentsNeeded
|
|
});
|
|
}
|
|
|
|
const base =
|
|
PUBLIC_BASE_URL ||
|
|
`${req.protocol}://${req.get('host')}`;
|
|
|
|
let publicUrl;
|
|
|
|
if (
|
|
masterPath === PUBLIC_ROOT ||
|
|
masterPath.startsWith(
|
|
`${PUBLIC_ROOT}${path.sep}`
|
|
)
|
|
) {
|
|
const rel = path
|
|
.relative(PUBLIC_ROOT, masterPath)
|
|
.split(path.sep)
|
|
.join('/');
|
|
|
|
publicUrl =
|
|
`${base.replace(/\/$/, '')}/${rel}`;
|
|
} else {
|
|
/*
|
|
* Compatibilita con eventuali vecchi job HLS
|
|
* salvati sotto HLS_BASE.
|
|
*/
|
|
const rel = path
|
|
.relative(HLS_BASE, masterPath)
|
|
.split(path.sep)
|
|
.join('/');
|
|
|
|
publicUrl =
|
|
`${base.replace(/\/$/, '')}/hls/${rel}`;
|
|
}
|
|
|
|
return res.json({ jobId: job.jobId, master_m3u8: publicUrl, status: job.notified ? 'ready' : 'processing' });
|
|
});
|
|
|
|
module.exports = router;
|