'use strict'; const fs = require('fs'); const path = require('path'); const SEGMENT_PATTERN = /^segment_[0-9]{5}\.ts$/; const DEFAULT_TIMEOUT_MS = Number( process.env.CAST_SEGMENT_WAIT_TIMEOUT_MS || 300000 ); const DEFAULT_POLL_MS = Number( process.env.CAST_SEGMENT_WAIT_POLL_MS || 100 ); const DEFAULT_STABILITY_MS = Number( process.env.CAST_SEGMENT_STABILITY_MS || 50 ); function sleep(ms) { return new Promise(resolve => { setTimeout(resolve, ms); }); } async function getStableFileSize( filePath, stabilityMs ) { try { const first = await fs.promises.stat(filePath); if ( !first.isFile() || first.size <= 0 ) { return null; } await sleep(stabilityMs); const second = await fs.promises.stat(filePath); if ( !second.isFile() || second.size <= 0 || second.size !== first.size ) { return null; } return second.size; } catch { return null; } } function isInsideRoot(root, candidate) { const relative = path.relative( root, candidate ); return ( relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) ); } function requestIsClosed(req, res) { return ( req.destroyed || res.destroyed || res.writableEnded ); } function createWaitForCastSegment({ castRoot, timeoutMs = DEFAULT_TIMEOUT_MS, pollMs = DEFAULT_POLL_MS, stabilityMs = DEFAULT_STABILITY_MS }) { const resolvedRoot = path.resolve(castRoot); return async function waitForCastSegment( req, res, next ) { try { if ( req.method !== 'GET' && req.method !== 'HEAD' ) { next(); return; } let pathname; try { pathname = decodeURIComponent( req.path || req.url.split('?')[0] ); } catch { res .status(400) .end('Invalid URL'); return; } const filename = path.basename(pathname); if (!SEGMENT_PATTERN.test(filename)) { next(); return; } /* * Il middleware è montato su /cast. * req.path è quindi relativo a public/cast. */ const relativePath = pathname.replace(/^\/+/, ''); const filePath = path.resolve( resolvedRoot, relativePath ); if (!isInsideRoot( resolvedRoot, filePath )) { res .status(403) .end('Forbidden'); return; } /* * Se il segmento esiste già ed è stabile, * prosegue immediatamente verso express.static. */ const initialSize = await getStableFileSize( filePath, stabilityMs ); if (initialSize !== null) { next(); return; } const startedAt = Date.now(); const deadline = startedAt + timeoutMs; let lastProgressLogAt = 0; console.log( `[castSegmentWait] WAIT ` + `${relativePath} ` + `timeoutMs=${timeoutMs}` ); while (Date.now() < deadline) { if (requestIsClosed(req, res)) { console.log( `[castSegmentWait] CLIENT_CLOSED ` + `${relativePath} ` + `waitedMs=${Date.now() - startedAt}` ); return; } const size = await getStableFileSize( filePath, stabilityMs ); if (size !== null) { console.log( `[castSegmentWait] READY ` + `${relativePath} ` + `waitedMs=${Date.now() - startedAt} ` + `size=${size}` ); /* * Il file ora esiste ed è stabile. * express.static lo invierà al client. */ next(); return; } const now = Date.now(); if ( now - lastProgressLogAt >= 5000 ) { console.log( `[castSegmentWait] STILL_WAITING ` + `${relativePath} ` + `elapsedMs=${now - startedAt}` ); lastProgressLogAt = now; } await sleep(pollMs); } if (requestIsClosed(req, res)) { return; } console.warn( `[castSegmentWait] TIMEOUT ` + `${relativePath} ` + `waitedMs=${Date.now() - startedAt}` ); res .status(503) .set({ 'Cache-Control': 'no-store', 'Retry-After': '1' }) .end( 'Segment not ready' ); } catch (err) { /* * Chromecast può chiudere richieste speculative. * Non è un errore di transcodifica. */ if ( err && ( err.code === 'EPIPE' || err.code === 'ECONNRESET' ) ) { console.log( `[castSegmentWait] CLIENT_DISCONNECTED ` + `${req.originalUrl || req.url}` ); return; } console.error( `[castSegmentWait] ERROR ` + `${req.originalUrl || req.url}: ` + `${err.stack || err.message}` ); if (!requestIsClosed(req, res)) { next(err); } } }; } module.exports = { createWaitForCastSegment };