614 lines
13 KiB
Dart
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;
|