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 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 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 getWorkingMp4Path(int mediaId) async { final directory = await getMediaDirectory(mediaId); return '${directory.path}/stream.fmp4.part'; } Future getInitTemporaryPath(int mediaId) async { final directory = await getMediaDirectory(mediaId); return '${directory.path}/init.mp4.part'; } Future getInitPath(int mediaId) async { final directory = await getMediaDirectory(mediaId); return '${directory.path}/init.mp4'; } Future getPlaylistTemporaryPath(int mediaId) async { final directory = await getMediaDirectory(mediaId); return '${directory.path}/master.m3u8.tmp'; } Future getPlaylistPath(int mediaId) async { final directory = await getMediaDirectory(mediaId); return '${directory.path}/master.m3u8'; } Future getSessionTemporaryPath(int mediaId) async { final directory = await getMediaDirectory(mediaId); return '${directory.path}/session.json.tmp'; } Future getSessionPath(int mediaId) async { final directory = await getMediaDirectory(mediaId); return '${directory.path}/session.json'; } Future getCompleteFlagPath(int mediaId) async { final directory = await getMediaDirectory(mediaId); return '${directory.path}/complete.flag'; } Future 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 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 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 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 hasInitializationSegment(int mediaId) async { final path = await getInitPath(mediaId); final file = File(path); return await file.exists() && await file.length() > 0; } Future hasPlaylist(int mediaId) async { final path = await getPlaylistPath(mediaId); final file = File(path); return await file.exists() && await file.length() > 0; } Future 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 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 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 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 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 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 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 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 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 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 writeSessionAtomically( int mediaId, Map 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 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 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 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;