gallery_server_client_s190js/routes/hls.js
2026-10-26 11:00:14 +01:00

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;