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

614 lines
13 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
class CastHlsCache {
CastHlsCache._();
static final CastHlsCache instance = CastHlsCache._();
static const Duration cacheTtl = Duration(seconds: 21600);
static const String _cacheDirectoryName = 'aves_cast_hls';
static void _log(String message) {
debugPrint('[CAST HLS CACHE] $message');
}
Future<Directory> getBaseDirectory() async {
final applicationCache = await getApplicationCacheDirectory();
final directory = Directory(
'${applicationCache.path}/$_cacheDirectoryName',
);
if (!await directory.exists()) {
await directory.create(recursive: true);
_log(
'BASE DIRECTORY CREATED '
'path=${directory.path}',
);
}
return directory;
}
Future<Directory> getMediaDirectory(int mediaId) async {
final baseDirectory = await getBaseDirectory();
final directory = Directory(
'${baseDirectory.path}/$mediaId',
);
if (!await directory.exists()) {
await directory.create(recursive: true);
_log(
'MEDIA DIRECTORY CREATED '
'mediaId=$mediaId '
'path=${directory.path}',
);
}
return directory;
}
Future<String> getWorkingMp4Path(int mediaId) async {
final directory = await getMediaDirectory(mediaId);
return '${directory.path}/stream.fmp4.part';
}
Future<String> getInitTemporaryPath(int mediaId) async {
final directory = await getMediaDirectory(mediaId);
return '${directory.path}/init.mp4.part';
}
Future<String> getInitPath(int mediaId) async {
final directory = await getMediaDirectory(mediaId);
return '${directory.path}/init.mp4';
}
Future<String> getPlaylistTemporaryPath(int mediaId) async {
final directory = await getMediaDirectory(mediaId);
return '${directory.path}/master.m3u8.tmp';
}
Future<String> getPlaylistPath(int mediaId) async {
final directory = await getMediaDirectory(mediaId);
return '${directory.path}/master.m3u8';
}
Future<String> getSessionTemporaryPath(int mediaId) async {
final directory = await getMediaDirectory(mediaId);
return '${directory.path}/session.json.tmp';
}
Future<String> getSessionPath(int mediaId) async {
final directory = await getMediaDirectory(mediaId);
return '${directory.path}/session.json';
}
Future<String> getCompleteFlagPath(int mediaId) async {
final directory = await getMediaDirectory(mediaId);
return '${directory.path}/complete.flag';
}
Future<String> getSegmentTemporaryPath(
int mediaId,
int segmentIndex,
) async {
final directory = await getMediaDirectory(mediaId);
final number = segmentIndex.toString().padLeft(5, '0');
return '${directory.path}/segment_$number.m4s.part';
}
Future<String> getSegmentPath(
int mediaId,
int segmentIndex,
) async {
final directory = await getMediaDirectory(mediaId);
final number = segmentIndex.toString().padLeft(5, '0');
return '${directory.path}/segment_$number.m4s';
}
Future<String> getTransportStreamSegmentTemporaryPath(
int mediaId,
int segmentIndex,
) async {
final directory = await getMediaDirectory(mediaId);
final number = segmentIndex.toString().padLeft(5, '0');
return '${directory.path}/segment_$number.ts.part';
}
Future<String> getTransportStreamSegmentPath(
int mediaId,
int segmentIndex,
) async {
final directory = await getMediaDirectory(mediaId);
final number = segmentIndex.toString().padLeft(5, '0');
return '${directory.path}/segment_$number.ts';
}
Future<bool> hasInitializationSegment(int mediaId) async {
final path = await getInitPath(mediaId);
final file = File(path);
return await file.exists() && await file.length() > 0;
}
Future<bool> hasPlaylist(int mediaId) async {
final path = await getPlaylistPath(mediaId);
final file = File(path);
return await file.exists() && await file.length() > 0;
}
Future<bool> isComplete(int mediaId) async {
final flagPath = await getCompleteFlagPath(mediaId);
final flag = File(flagPath);
if (!await flag.exists()) {
return false;
}
final stat = await flag.stat();
final age = DateTime.now().difference(stat.modified);
if (age >= cacheTtl) {
_log(
'COMPLETE EXPIRED '
'mediaId=$mediaId '
'ageSeconds=${age.inSeconds} '
'ttlSeconds=${cacheTtl.inSeconds}',
);
return false;
}
final hasManifest = await hasPlaylist(mediaId);
if (!hasManifest) {
return false;
}
final segmentCount = await countPublishedSegments(
mediaId,
);
if (segmentCount <= 0) {
return false;
}
final hasInit = await hasInitializationSegment(
mediaId,
);
final transportStreamSegmentCount = await countPublishedTransportStreamSegments(
mediaId,
);
final format = transportStreamSegmentCount > 0 ? 'mpeg-ts' : 'fragmented-mp4';
_log(
'COMPLETE CHECK '
'mediaId=$mediaId '
'format=$format '
'segments=$segmentCount '
'transportStreamSegments='
'$transportStreamSegmentCount '
'hasManifest=$hasManifest '
'hasInit=$hasInit '
'complete=true',
);
return true;
}
Future<int> countPublishedSegments(int mediaId) async {
final directory = await getMediaDirectory(mediaId);
var count = 0;
await for (final entity in directory.list(followLinks: false)) {
if (entity is! File) {
continue;
}
final name = entity.uri.pathSegments.last;
final isFragmentedMp4Segment = RegExp(
r'^segment_[0-9]{5}\.m4s$',
).hasMatch(name);
final isTransportStreamSegment = RegExp(
r'^segment_[0-9]{5}\.ts$',
).hasMatch(name);
if (isFragmentedMp4Segment || isTransportStreamSegment) {
count++;
}
}
return count;
}
Future<int> countPublishedTransportStreamSegments(
int mediaId,
) async {
final directory = await getMediaDirectory(mediaId);
var count = 0;
await for (final entity in directory.list(
followLinks: false,
)) {
if (entity is! File) {
continue;
}
final name = entity.uri.pathSegments.last;
final isTransportStreamSegment = RegExp(
r'^segment_[0-9]{5}\.ts$',
).hasMatch(name);
if (isTransportStreamSegment) {
count++;
}
}
return count;
}
Future<bool> isReadyForPlayback(
int mediaId, {
int minimumSegments = 3,
}) async {
if (minimumSegments <= 0) {
throw ArgumentError.value(
minimumSegments,
'minimumSegments',
'Il numero minimo di segmenti deve essere positivo',
);
}
final hasManifest = await hasPlaylist(
mediaId,
);
if (!hasManifest) {
_log(
'READY CHECK '
'mediaId=$mediaId '
'segments=0 '
'minimumSegments=$minimumSegments '
'hasManifest=false '
'hasInit=false '
'ready=false',
);
return false;
}
final segmentCount = await countPublishedSegments(
mediaId,
);
final hasInit = await hasInitializationSegment(
mediaId,
);
final ready = segmentCount >= minimumSegments;
_log(
'READY CHECK '
'mediaId=$mediaId '
'segments=$segmentCount '
'minimumSegments=$minimumSegments '
'hasManifest=true '
'hasInit=$hasInit '
'ready=$ready',
);
return ready;
}
Future<bool> isTransportStreamReadyForPlayback(
int mediaId, {
int minimumSegments = 3,
}) async {
if (minimumSegments <= 0) {
throw ArgumentError.value(
minimumSegments,
'minimumSegments',
'Il numero minimo di segmenti deve essere positivo',
);
}
final hasManifest = await hasPlaylist(
mediaId,
);
if (!hasManifest) {
_log(
'TS READY CHECK '
'mediaId=$mediaId '
'segments=0 '
'minimumSegments=$minimumSegments '
'hasManifest=false '
'ready=false',
);
return false;
}
final segmentCount = await countPublishedTransportStreamSegments(
mediaId,
);
final ready = segmentCount >= minimumSegments;
_log(
'TS READY CHECK '
'mediaId=$mediaId '
'segments=$segmentCount '
'minimumSegments=$minimumSegments '
'hasManifest=true '
'ready=$ready',
);
return ready;
}
Future<File> publishTemporaryFile({
required String temporaryPath,
required String finalPath,
}) async {
final temporaryFile = File(temporaryPath);
if (!await temporaryFile.exists()) {
throw StateError(
'File temporaneo HLS non trovato: $temporaryPath',
);
}
final length = await temporaryFile.length();
if (length <= 0) {
await temporaryFile.delete();
throw StateError(
'File temporaneo HLS vuoto: $temporaryPath',
);
}
final finalFile = File(finalPath);
if (await finalFile.exists()) {
await finalFile.delete();
}
return temporaryFile.rename(finalPath);
}
Future<File> publishInitializationSegment(int mediaId) async {
final temporaryPath = await getInitTemporaryPath(mediaId);
final finalPath = await getInitPath(mediaId);
final file = await publishTemporaryFile(
temporaryPath: temporaryPath,
finalPath: finalPath,
);
_log(
'INIT PUBLISHED '
'mediaId=$mediaId '
'bytes=${await file.length()} '
'path=${file.path}',
);
return file;
}
Future<File> publishSegment(
int mediaId,
int segmentIndex,
) async {
final temporaryPath = await getSegmentTemporaryPath(
mediaId,
segmentIndex,
);
final finalPath = await getSegmentPath(
mediaId,
segmentIndex,
);
final file = await publishTemporaryFile(
temporaryPath: temporaryPath,
finalPath: finalPath,
);
_log(
'SEGMENT PUBLISHED '
'mediaId=$mediaId '
'segmentIndex=$segmentIndex '
'bytes=${await file.length()} '
'path=${file.path}',
);
return file;
}
Future<File> publishTransportStreamSegment(
int mediaId,
int segmentIndex,
) async {
final temporaryPath = await getTransportStreamSegmentTemporaryPath(
mediaId,
segmentIndex,
);
final finalPath = await getTransportStreamSegmentPath(
mediaId,
segmentIndex,
);
final file = await publishTemporaryFile(
temporaryPath: temporaryPath,
finalPath: finalPath,
);
_log(
'TS SEGMENT PUBLISHED '
'mediaId=$mediaId '
'segmentIndex=$segmentIndex '
'bytes=${await file.length()} '
'path=${file.path}',
);
return file;
}
Future<void> writePlaylistAtomically(
int mediaId,
String content,
) async {
final temporaryPath = await getPlaylistTemporaryPath(mediaId);
final finalPath = await getPlaylistPath(mediaId);
final temporaryFile = File(temporaryPath);
await temporaryFile.writeAsString(
content,
flush: true,
);
await publishTemporaryFile(
temporaryPath: temporaryPath,
finalPath: finalPath,
);
_log(
'PLAYLIST PUBLISHED '
'mediaId=$mediaId '
'bytes=${utf8.encode(content).length}',
);
}
Future<void> writeSessionAtomically(
int mediaId,
Map<String, dynamic> session,
) async {
final temporaryPath = await getSessionTemporaryPath(mediaId);
final finalPath = await getSessionPath(mediaId);
final content = jsonEncode(session);
final temporaryFile = File(temporaryPath);
await temporaryFile.writeAsString(
content,
flush: true,
);
await publishTemporaryFile(
temporaryPath: temporaryPath,
finalPath: finalPath,
);
_log(
'SESSION PUBLISHED '
'mediaId=$mediaId '
'bytes=${utf8.encode(content).length}',
);
}
Future<void> markComplete(int mediaId) async {
final flagPath = await getCompleteFlagPath(mediaId);
final flag = File(flagPath);
await flag.writeAsString(
DateTime.now().toIso8601String(),
flush: true,
);
_log(
'COMPLETE '
'mediaId=$mediaId '
'path=$flagPath',
);
}
Future<void> resetSession(int mediaId) async {
final directory = await getMediaDirectory(mediaId);
var removed = 0;
await for (final entity in directory.list(followLinks: false)) {
if (entity is File) {
await entity.delete();
removed++;
}
}
_log(
'SESSION RESET '
'mediaId=$mediaId '
'removed=$removed',
);
}
Future<void> cleanupExpired() async {
final baseDirectory = await getBaseDirectory();
final now = DateTime.now();
var inspected = 0;
var removed = 0;
await for (final entity in baseDirectory.list(followLinks: false)) {
if (entity is! Directory) {
continue;
}
inspected++;
final stat = await entity.stat();
final age = now.difference(stat.modified);
if (age >= cacheTtl) {
await entity.delete(recursive: true);
removed++;
_log(
'CLEANUP REMOVE '
'path=${entity.path} '
'ageSeconds=${age.inSeconds}',
);
}
}
_log(
'CLEANUP COMPLETE '
'inspected=$inspected '
'removed=$removed '
'ttlSeconds=${cacheTtl.inSeconds}',
);
}
}
final castHlsCache = CastHlsCache.instance;