// tools/cleanupCast.js const fs = require('fs'); const path = require('path'); const CAST_ROOT = path.join( process.cwd(), 'public', 'cast' ); const MIN_AGE_MS = 24 * 60 * 60 * 1000; function getAgeMs(stat, now) { return now - stat.mtimeMs; } function formatAge(ageMs) { return ( ageMs / (60 * 60 * 1000) ).toFixed(2); } /* * ============================================================ * FOTO CAST * ============================================================ * * Ricerca ricorsivamente sotto: * * public/cast//photos/ * * e cancella soltanto file con almeno 24 ore. */ async function cleanupPhotos({ photosDir, dryRun, now, counters, }) { let entries; try { entries = await fs.promises.readdir( photosDir, { withFileTypes: true, }, ); } catch (error) { if (error.code === 'ENOENT') { return; } throw error; } for (const entry of entries) { const fullPath = path.join( photosDir, entry.name, ); if (entry.isDirectory()) { await cleanupPhotos({ photosDir: fullPath, dryRun, now, counters, }); /* * Rimuove eventuali directory vuote. */ try { const remaining = await fs.promises.readdir(fullPath); if (remaining.length === 0) { if (dryRun) { console.log( '[cleanupCast] WOULD REMOVE EMPTY DIR', fullPath, ); } else { await fs.promises.rmdir(fullPath); console.log( '[cleanupCast] removed empty dir', fullPath, ); } } } catch (error) { if (error.code !== 'ENOENT') { throw error; } } continue; } if (!entry.isFile()) { continue; } const stat = await fs.promises.stat(fullPath); const ageMs = getAgeMs(stat, now); counters.photoFilesChecked += 1; if (ageMs < MIN_AGE_MS) { console.log( '[cleanupCast] KEEP photo', `age=${formatAge(ageMs)}h`, fullPath, ); counters.kept += 1; continue; } if (dryRun) { console.log( '[cleanupCast] WOULD DELETE photo', `age=${formatAge(ageMs)}h`, fullPath, ); } else { await fs.promises.unlink(fullPath); console.log( '[cleanupCast] deleted photo', `age=${formatAge(ageMs)}h`, fullPath, ); } counters.deleted += 1; } } /* * ============================================================ * VIDEO CAST * ============================================================ * * Determina la data piu recente di tutti i file presenti * nella directory HLS. * * In questo modo un video viene eliminato soltanto quando * tutti i file che lo compongono hanno almeno 24 ore. */ async function getNewestMtimeMs(dir) { const entries = await fs.promises.readdir( dir, { withFileTypes: true, }, ); let newest = 0; for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { const childNewest = await getNewestMtimeMs(fullPath); newest = Math.max( newest, childNewest, ); continue; } if (!entry.isFile()) { continue; } const stat = await fs.promises.stat(fullPath); newest = Math.max( newest, stat.mtimeMs, ); } return newest; } /* * Identifica le directory che rappresentano * effettivamente un video HLS cercando master.m3u8. */ async function findVideoDirs( videosRoot, result = [], ) { let entries; try { entries = await fs.promises.readdir( videosRoot, { withFileTypes: true, }, ); } catch (error) { if (error.code === 'ENOENT') { return result; } throw error; } for (const entry of entries) { if (!entry.isDirectory()) { continue; } const fullPath = path.join( videosRoot, entry.name, ); const masterPath = path.join( fullPath, 'master.m3u8', ); try { const stat = await fs.promises.stat( masterPath, ); if (stat.isFile()) { result.push(fullPath); continue; } } catch (error) { if (error.code !== 'ENOENT') { throw error; } } await findVideoDirs( fullPath, result, ); } return result; } async function cleanupVideos({ videosRoot, dryRun, now, counters, }) { const videoDirs = await findVideoDirs( videosRoot, ); for (const videoDir of videoDirs) { counters.videoDirsChecked += 1; const newestMtimeMs = await getNewestMtimeMs( videoDir, ); if (!newestMtimeMs) { continue; } const ageMs = now - newestMtimeMs; if (ageMs < MIN_AGE_MS) { console.log( '[cleanupCast] KEEP video', `age=${formatAge(ageMs)}h`, videoDir, ); counters.kept += 1; continue; } if (dryRun) { console.log( '[cleanupCast] WOULD DELETE video', `age=${formatAge(ageMs)}h`, videoDir, ); } else { await fs.promises.rm( videoDir, { recursive: true, force: true, }, ); console.log( '[cleanupCast] deleted video', `age=${formatAge(ageMs)}h`, videoDir, ); } counters.deleted += 1; } } /* * ============================================================ * CLEANUP GENERALE * ============================================================ */ async function cleanupCast({ dryRun = false, } = {}) { const startedAt = Date.now(); const now = Date.now(); const counters = { photoFilesChecked: 0, videoDirsChecked: 0, kept: 0, deleted: 0, }; console.log( '[cleanupCast] start', dryRun ? 'DRY-RUN' : 'LIVE', ); console.log( '[cleanupCast] root:', CAST_ROOT, ); if (!fs.existsSync(CAST_ROOT)) { console.log( '[cleanupCast] cast root does not exist' ); return counters; } const users = await fs.promises.readdir( CAST_ROOT, { withFileTypes: true, }, ); for (const userEntry of users) { if (!userEntry.isDirectory()) { continue; } const userRoot = path.join( CAST_ROOT, userEntry.name, ); await cleanupPhotos({ photosDir: path.join( userRoot, 'photos', ), dryRun, now, counters, }); await cleanupVideos({ videosRoot: path.join( userRoot, 'videos', ), dryRun, now, counters, }); } console.log( '[cleanupCast] completed', { ...counters, dryRun, elapsedMs: Date.now() - startedAt, }, ); return counters; } /* * Esecuzione standalone: * * node tools/cleanupCast.js --dry-run * node tools/cleanupCast.js */ if (require.main === module) { const dryRun = process.argv.includes('--dry-run'); cleanupCast({ dryRun, }) .then(() => { process.exitCode = 0; }) .catch((error) => { console.error( '[cleanupCast] fatal error', error, ); process.exitCode = 1; }); } module.exports = { cleanupCast, MIN_AGE_MS, CAST_ROOT, };