// 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//videos/// */ 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;