330 lines
7.5 KiB
Dart
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;
|