// lib/services/cast/cast_video_cache.dart import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; class CastVideoCache { CastVideoCache._(); static final CastVideoCache instance = CastVideoCache._(); /* * Durata predefinita della cache. * * 21600 secondi corrispondono a 6 ore. * Il valore potrà essere cambiato facilmente in seguito. */ static const Duration cacheTtl = Duration(seconds: 21600); static const String _cacheDirectoryName = 'aves_cast_videos'; static void _log(String message) { debugPrint('[CAST VIDEO CACHE] $message'); } /// Restituisce la directory utilizzata per le varianti video Cast. /// /// La directory viene creata automaticamente se non esiste. Future getCacheDirectory() async { final baseDirectory = await getApplicationCacheDirectory(); final directory = Directory( '${baseDirectory.path}/$_cacheDirectoryName', ); if (!await directory.exists()) { await directory.create(recursive: true); _log( 'DIRECTORY CREATED ' 'path=${directory.path}', ); } return directory; } /// Restituisce il percorso finale della variante MP4. /// /// La conversione verrà scritta inizialmente in un file `.part` /// e poi rinominata nel file restituito da questo metodo. Future getFinalPath(int mediaId) async { final directory = await getCacheDirectory(); return '${directory.path}/cast_$mediaId.mp4'; } /// Restituisce il percorso temporaneo usato durante la conversione. /// /// Il receiver non dovrà mai leggere direttamente questo file. Future getTemporaryPath(int mediaId) async { final directory = await getCacheDirectory(); return '${directory.path}/cast_$mediaId.mp4.part'; } /// Restituisce il file MP4 presente in cache se: /// /// - esiste; /// - non è vuoto; /// - non ha superato il TTL. /// /// In caso contrario restituisce null. Future getValidFile(int mediaId) async { final path = await getFinalPath(mediaId); final file = File(path); if (!await file.exists()) { _log( 'MISS ' 'mediaId=$mediaId ' 'reason=not-found ' 'path=$path', ); return null; } final stat = await file.stat(); if (stat.size <= 0) { _log( 'INVALID ' 'mediaId=$mediaId ' 'reason=empty ' 'path=$path', ); await _deleteFileIfPresent(file); return null; } final now = DateTime.now(); final age = now.difference(stat.modified); if (age >= cacheTtl) { _log( 'EXPIRED ' 'mediaId=$mediaId ' 'size=${stat.size} ' 'ageSeconds=${age.inSeconds} ' 'ttlSeconds=${cacheTtl.inSeconds} ' 'path=$path', ); await _deleteFileIfPresent(file); return null; } _log( 'HIT ' 'mediaId=$mediaId ' 'size=${stat.size} ' 'ageSeconds=${age.inSeconds} ' 'ttlSeconds=${cacheTtl.inSeconds} ' 'path=$path', ); return file; } /// Pubblica atomicamente una conversione completata. /// /// Il file temporaneo viene rinominato nel percorso definitivo soltanto /// dopo che la conversione è terminata con successo. In questo modo il /// receiver non può vedere un MP4 incompleto. Future commitTemporaryFile(int mediaId) async { final temporaryPath = await getTemporaryPath(mediaId); final finalPath = await getFinalPath(mediaId); final temporaryFile = File(temporaryPath); if (!await temporaryFile.exists()) { throw StateError( 'File video Cast temporaneo non trovato: $temporaryPath', ); } final temporaryLength = await temporaryFile.length(); if (temporaryLength <= 0) { await _deleteFileIfPresent(temporaryFile); throw StateError( 'File video Cast temporaneo vuoto: $temporaryPath', ); } final finalFile = File(finalPath); /* * Su alcuni filesystem rename non sostituisce un file esistente. * Lo eliminiamo esplicitamente prima della pubblicazione. */ await _deleteFileIfPresent(finalFile); final committedFile = await temporaryFile.rename(finalPath); _log( 'COMMIT ' 'mediaId=$mediaId ' 'bytes=$temporaryLength ' 'path=${committedFile.path}', ); return committedFile; } /// Elimina sia il file definitivo sia un'eventuale conversione parziale. Future invalidate(int mediaId) async { final finalPath = await getFinalPath(mediaId); final temporaryPath = await getTemporaryPath(mediaId); final deletedFinal = await _deleteFileIfPresent( File(finalPath), ); final deletedTemporary = await _deleteFileIfPresent( File(temporaryPath), ); _log( 'INVALIDATE ' 'mediaId=$mediaId ' 'deletedFinal=$deletedFinal ' 'deletedTemporary=$deletedTemporary', ); } /// Rimuove tutti i file scaduti, vuoti o parziali. Future cleanupExpired() async { final directory = await getCacheDirectory(); final now = DateTime.now(); var inspected = 0; var removed = 0; var retained = 0; await for (final entity in directory.list(followLinks: false)) { if (entity is! File) { continue; } inspected++; final path = entity.path; /* * Un file .part appartiene a una conversione interrotta. * Non deve essere mai conservato tra due avvii. */ if (path.endsWith('.part')) { if (await _deleteFileIfPresent(entity)) { removed++; } _log( 'CLEANUP REMOVE ' 'reason=partial ' 'path=$path', ); continue; } /* * Ignora file estranei eventualmente presenti nella directory. */ if (!path.endsWith('.mp4')) { retained++; _log( 'CLEANUP KEEP ' 'reason=unknown-file ' 'path=$path', ); continue; } final stat = await entity.stat(); final age = now.difference(stat.modified); if (stat.size <= 0 || age >= cacheTtl) { if (await _deleteFileIfPresent(entity)) { removed++; } _log( 'CLEANUP REMOVE ' 'reason=${stat.size <= 0 ? 'empty' : 'expired'} ' 'size=${stat.size} ' 'ageSeconds=${age.inSeconds} ' 'path=$path', ); continue; } retained++; } _log( 'CLEANUP COMPLETE ' 'inspected=$inspected ' 'removed=$removed ' 'retained=$retained ' 'ttlSeconds=${cacheTtl.inSeconds}', ); } /// Elimina interamente la cache video Cast. Future clear() async { final directory = await getCacheDirectory(); var removed = 0; await for (final entity in directory.list(followLinks: false)) { if (entity is File && await _deleteFileIfPresent(entity)) { removed++; } } _log( 'CLEAR ' 'removed=$removed ' 'path=${directory.path}', ); } Future _deleteFileIfPresent(File file) async { try { if (!await file.exists()) { return false; } await file.delete(); return true; } catch (error, stackTrace) { _log( 'DELETE ERROR ' 'path=${file.path} ' 'error=$error', ); debugPrint( '[CAST VIDEO CACHE] DELETE STACK ' 'path=${file.path}\n' '$stackTrace', ); return false; } } } final castVideoCache = CastVideoCache.instance;