// 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(); 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 = 3 } = req.body || {}; if (!callbackUrl) return res.status(400).json({ error: 'Missing callbackUrl' }); let inputPath = providedPath; if (photoId && !providedPath) { const row = await db('photos').where({ id: photoId }).first(); if (!row) 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, row.user); if (!allowed || allowed.length === 0) return res.status(403).json({ error: 'Access denied to this photo' }); if (row.path && typeof row.path === 'string' && row.path.trim()) { const rel = row.path.replace(/^\/+/, ''); inputPath = safeJoin(PUBLIC_ROOT, rel); } else { inputPath = safeJoin(PHOTOS_ROOT, row.user, 'original', row.cartella || '', row.name); } } if (!inputPath) return res.status(400).json({ error: 'Missing inputPath or photoId' }); // outDir sotto HLS_BASE const safeOut = String(outDir || 'fabiocast').replace(/[^a-zA-Z0-9_\-\/]/g, '_'); const jobParentDir = path.isAbsolute(safeOut) ? path.join(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'); if (!fs.existsSync(masterPath)) return res.status(404).json({ error: 'Playlist not yet available' }); // costruisci la parte relativa rispetto a HLS_BASE (evita duplicazioni come /hls/hls_output/...) const base = PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`; const rel = path.relative(HLS_BASE, masterPath).split(path.sep).join('/'); const publicUrl = `${base.replace(/\/$/, '')}/hls/${rel}`; return res.json({ jobId: job.jobId, master_m3u8: publicUrl, status: job.notified ? 'ready' : 'processing' }); }); module.exports = router;