Aves_mio0.88/lib/services/cast/cast_video_cache.dart
2026-10-03 12:51:15 +02:00

330 lines
7.5 KiB
Dart

// 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<Directory> 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<String> 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<String> 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<File?> 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<File> 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<void> 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<void> 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<void> 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<bool> _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;