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

1551 lines
37 KiB
Dart

// lib/services/cast/cast_server.dart
import 'dart:async';
import 'dart:io';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:mime/mime.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:aves/services/cast/cast_video_cache.dart';
import 'package:aves/services/cast/cast_hls_cache.dart';
class _ByteRange {
final int start;
final int end;
const _ByteRange(this.start, this.end);
}
class _ServingChoice {
final bool optimized;
final DateTime createdAt;
const _ServingChoice({
required this.optimized,
required this.createdAt,
});
}
class CastServer {
HttpServer? _server;
String? _ip;
final Map<int, Uint8List> _optimizedImages = <int, Uint8List>{};
final Map<int, _ServingChoice> _servingChoices = <int, _ServingChoice>{};
final Map<String, Uint8List> _assetCache = <String, Uint8List>{};
/// Evita di richiamare il MethodChannel a ogni nuova richiesta Range
/// per lo stesso media.
final Map<int, String> _filePathCache = <int, String>{};
bool get isRunning => _server != null;
static final Stopwatch _timingSw = Stopwatch()..start();
static int _requestSequence = 0;
static int _ms() => _timingSw.elapsedMilliseconds;
static String _isoNow() => DateTime.now().toIso8601String();
static int _nextRequestId() {
_requestSequence++;
return _requestSequence;
}
static void _tlog(String message) {
debugPrint(
'CASTT [${_ms()} ms][${_isoNow()}] $message',
);
}
Future<void> start() async {
if (_server != null) {
_tlog(
'castServer.start skip already-running '
'ip=$_ip port=${_server?.port}',
);
return;
}
_tlog('castServer.start begin');
final interfaces = await NetworkInterface.list(
includeLoopback: false,
type: InternetAddressType.IPv4,
);
if (interfaces.isEmpty) {
throw StateError(
'Nessuna interfaccia IPv4 disponibile per Google Cast',
);
}
for (final interface in interfaces) {
final addresses = interface.addresses.map((address) => address.address).join(',');
_tlog(
'network interface '
'name=${interface.name} '
'addresses=$addresses',
);
}
final wifi = interfaces.firstWhere(
(interface) {
final name = interface.name.toLowerCase();
return name == 'wlan0' || name.contains('wifi') || name.contains('wlan');
},
orElse: () => interfaces.first,
);
if (wifi.addresses.isEmpty) {
throw StateError(
'Nessun indirizzo IPv4 disponibile su ${wifi.name}',
);
}
_ip = wifi.addresses.first.address;
_server = await HttpServer.bind(
InternetAddress.anyIPv4,
0,
shared: true,
);
final handler = const Pipeline().addHandler(_handleRequest);
shelf_io.serveRequests(
_server!,
handler,
);
_tlog(
'castServer.start active '
'interface=${wifi.name} '
'url=http://$_ip:${_server!.port}',
);
}
Future<void> stop() async {
final server = _server;
if (server == null) {
_tlog('castServer.stop skip not-running');
return;
}
_tlog(
'castServer.stop begin '
'ip=$_ip port=${server.port}',
);
_server = null;
_ip = null;
await server.close(force: true);
_optimizedImages.clear();
_servingChoices.clear();
_assetCache.clear();
_filePathCache.clear();
_tlog('castServer.stop completed');
}
Future<void> ensureStarted() async {
if (!isRunning) {
await start();
}
}
String buildHlsUrl(int mediaId) {
if (_server == null || _ip == null) {
throw StateError('CastServer non avviato');
}
final url = 'http://$_ip:${_server!.port}/hls/$mediaId/master.m3u8';
_tlog(
'buildHlsUrl '
'mediaId=$mediaId '
'url=$url',
);
return url;
}
String buildUrlForEntry(dynamic entry) {
if (_server == null || _ip == null) {
throw StateError('CastServer non avviato');
}
final uri = '${entry.uri}';
final parsedUri = Uri.tryParse(uri);
final segments = parsedUri?.pathSegments;
final mediaId = segments != null && segments.isNotEmpty ? segments.last : uri.split('/').last;
final url = 'http://$_ip:${_server!.port}/media/$mediaId';
_tlog(
'buildUrlForEntry '
'mediaId=$mediaId '
'url=$url',
);
return url;
}
String buildUrlForAsset(String fileName) {
if (_server == null || _ip == null) {
throw StateError('CastServer non avviato');
}
if (fileName != 'start.jpg' && fileName != 'end.jpg') {
throw ArgumentError.value(
fileName,
'fileName',
'Asset Cast non valido',
);
}
final url = 'http://$_ip:${_server!.port}/assets/$fileName';
_tlog(
'[CAST QUEUE] ASSET URL '
'file=$fileName '
'url=$url',
);
return url;
}
bool hasOptimizedImage(int mediaId) {
return _optimizedImages.containsKey(mediaId);
}
void storeOptimizedImage(
int mediaId,
Uint8List bytes,
) {
_optimizedImages[mediaId] = bytes;
_tlog(
'optimized stored '
'id=$mediaId '
'size=${bytes.length}',
);
}
void retainOptimizedImages(Set<int> mediaIds) {
final optimizedBefore = _optimizedImages.length;
final choicesBefore = _servingChoices.length;
_optimizedImages.removeWhere(
(id, _) => !mediaIds.contains(id),
);
_servingChoices.removeWhere(
(id, _) => !mediaIds.contains(id),
);
_tlog(
'optimized retain '
'requested=${mediaIds.length} '
'optimizedBefore=$optimizedBefore '
'optimizedAfter=${_optimizedImages.length} '
'choicesBefore=$choicesBefore '
'choicesAfter=${_servingChoices.length}',
);
}
void clearOptimizedImages() {
final optimizedCount = _optimizedImages.length;
final choicesCount = _servingChoices.length;
_optimizedImages.clear();
_servingChoices.clear();
_tlog(
'optimized clear '
'optimizedRemoved=$optimizedCount '
'choicesRemoved=$choicesCount',
);
}
/// Chiamato quando una voce diventa quella visualizzata.
/// La prima richiesta successiva sceglierà nuovamente tra
/// rappresentazione ottimizzata e originale.
void resetServingChoice(int mediaId) {
final removed = _servingChoices.remove(mediaId);
_tlog(
'serving choice reset '
'id=$mediaId '
'hadChoice=${removed != null}',
);
}
/// Rimuove dalla cache il percorso locale di un media.
///
/// Può essere chiamato se il media viene spostato, modificato o eliminato.
void invalidateFilePath(int mediaId) {
final removed = _filePathCache.remove(mediaId);
_tlog(
'file path cache invalidate '
'id=$mediaId '
'hadPath=${removed != null}',
);
}
void clearFilePathCache() {
final count = _filePathCache.length;
_filePathCache.clear();
_tlog(
'file path cache clear '
'removed=$count',
);
}
Future<Uint8List?> readOriginalBytes(int mediaId) async {
final path = await _resolveFilePath(mediaId);
if (path == null) {
_tlog(
'readOriginalBytes no-path '
'id=$mediaId',
);
return null;
}
final file = File(path);
if (!await file.exists()) {
_filePathCache.remove(mediaId);
_tlog(
'readOriginalBytes file-not-found '
'id=$mediaId '
'path=$path',
);
return null;
}
final stopwatch = Stopwatch()..start();
final bytes = await file.readAsBytes();
_tlog(
'readOriginalBytes completed '
'id=$mediaId '
'bytes=${bytes.length} '
'ms=${stopwatch.elapsedMilliseconds}',
);
return bytes;
}
Future<Response> _handleRequest(Request request) async {
final requestStopwatch = Stopwatch()..start();
final requestId = _nextRequestId();
final path = request.requestedUri.path;
final rangeHeader = request.headers['range'];
final userAgent = request.headers['user-agent'] ?? '-';
final connection = request.headers['connection'] ?? '-';
_tlog(
'request start '
'requestId=$requestId '
'method=${request.method} '
'path=$path '
'range=${rangeHeader ?? '-'} '
'connection=$connection '
'userAgent=$userAgent',
);
try {
if (request.url.pathSegments.length >= 2 && request.url.pathSegments.first == 'assets') {
return await _handleAssetRequest(
request,
requestStopwatch,
requestId,
);
}
if (request.url.pathSegments.length >= 3 && request.url.pathSegments.first == 'hls') {
return await _handleHlsRequest(
request,
requestStopwatch,
requestId,
);
}
if (request.url.pathSegments.length < 2 || request.url.pathSegments.first != 'media') {
_tlog(
'request invalid-path '
'requestId=$requestId '
'path=$path',
);
return Response.notFound('Invalid path');
}
final id = int.tryParse(
request.url.pathSegments[1],
);
if (id == null) {
_tlog(
'request invalid-id '
'requestId=$requestId '
'value=${request.url.pathSegments[1]}',
);
return Response.notFound('Invalid ID');
}
/*
* Mantiene coerenti le richieste HEAD, GET e Range ravvicinate.
*
* Dopo 30 secondi la scelta può essere rivalutata, oppure viene
* resettata quando cambia l'indice visualizzato.
*/
final now = DateTime.now();
var choice = _servingChoices[id];
if (choice == null || now.difference(choice.createdAt).inSeconds >= 30) {
choice = _ServingChoice(
optimized: _optimizedImages.containsKey(id),
createdAt: now,
);
_servingChoices[id] = choice;
_tlog(
'serving choice created '
'requestId=$requestId '
'id=$id '
'optimized=${choice.optimized}',
);
} else {
_tlog(
'serving choice reused '
'requestId=$requestId '
'id=$id '
'optimized=${choice.optimized} '
'ageMs=${now.difference(choice.createdAt).inMilliseconds}',
);
}
if (choice.optimized) {
final bytes = _optimizedImages[id];
if (bytes != null) {
return await _serveBytesWithRange(
bytes,
request,
requestId: requestId,
resourceId: 'media:$id',
mime: 'image/jpeg',
requestStopwatch: requestStopwatch,
representation: 'optimized',
);
}
/*
* La cache può essere stata ruotata tra due richieste.
* In questo caso si passa all'originale.
*/
_servingChoices[id] = _ServingChoice(
optimized: false,
createdAt: now,
);
_tlog(
'optimized missing fallback-original '
'requestId=$requestId '
'id=$id',
);
}
final filePath = await _resolveFilePath(id);
if (filePath == null) {
_tlog(
'request no-file-path '
'requestId=$requestId '
'id=$id '
'ms=${requestStopwatch.elapsedMilliseconds}',
);
return Response.notFound('No file for ID=$id');
}
final file = File(filePath);
if (!await file.exists()) {
_filePathCache.remove(id);
_tlog(
'request file-not-found '
'requestId=$requestId '
'id=$id '
'path=$filePath '
'ms=${requestStopwatch.elapsedMilliseconds}',
);
return Response.notFound('File not found');
}
final originalMime = lookupMimeType(file.path) ?? 'application/octet-stream';
if (originalMime.startsWith('video/')) {
final cachedVideo = await castVideoCache.getValidFile(id);
if (cachedVideo != null) {
final cachedLength = await cachedVideo.length();
_tlog(
'cast video cache selected '
'requestId=$requestId '
'id=$id '
'originalPath=${file.path} '
'cachedPath=${cachedVideo.path} '
'cachedBytes=$cachedLength '
'mime=video/mp4 '
'ms=${requestStopwatch.elapsedMilliseconds}',
);
return await _serveFileWithRange(
cachedVideo,
request,
requestId: requestId,
id: id,
mime: 'video/mp4',
requestStopwatch: requestStopwatch,
);
}
_tlog(
'cast video cache unavailable '
'requestId=$requestId '
'id=$id '
'originalPath=${file.path} '
'originalMime=$originalMime '
'ms=${requestStopwatch.elapsedMilliseconds}',
);
}
return await _serveFileWithRange(
file,
request,
requestId: requestId,
id: id,
mime: originalMime,
requestStopwatch: requestStopwatch,
);
} catch (error, stackTrace) {
_tlog(
'request unhandled-error '
'requestId=$requestId '
'method=${request.method} '
'path=$path '
'ms=${requestStopwatch.elapsedMilliseconds} '
'error=$error',
);
debugPrint(
'CASTT request stack '
'requestId=$requestId\n'
'$stackTrace',
);
return Response.internalServerError(
body: 'Cast server error',
);
}
}
Future<Response> _handleHlsRequest(
Request request,
Stopwatch requestStopwatch,
int requestId,
) async {
if (request.method != 'GET' && request.method != 'HEAD') {
_tlog(
'HLS method not allowed '
'requestId=$requestId '
'method=${request.method} '
'path=${request.requestedUri.path}',
);
return Response(
HttpStatus.methodNotAllowed,
body: 'Method not allowed',
headers: <String, String>{
HttpHeaders.allowHeader: 'GET, HEAD',
},
);
}
final pathSegments = request.url.pathSegments;
if (pathSegments.length != 3) {
_tlog(
'HLS invalid path '
'requestId=$requestId '
'path=${request.requestedUri.path}',
);
return Response.notFound('Invalid HLS path');
}
final mediaId = int.tryParse(pathSegments[1]);
final fileName = pathSegments[2];
if (mediaId == null) {
_tlog(
'HLS invalid media id '
'requestId=$requestId '
'value=${pathSegments[1]}',
);
return Response.notFound('Invalid media ID');
}
final isPlaylist = fileName == 'master.m3u8';
final isInitializationSegment = fileName == 'init.mp4';
final isTransportStreamSegment = RegExp(
r'^segment_[0-9]{5}\.ts$',
).hasMatch(fileName);
final isFragmentedMp4Segment = RegExp(
r'^segment_[0-9]{5}\.m4s$',
).hasMatch(fileName);
final isMediaFile = isTransportStreamSegment || isInitializationSegment || isFragmentedMp4Segment;
if (!isPlaylist && !isMediaFile) {
_tlog(
'HLS invalid file '
'requestId=$requestId '
'mediaId=$mediaId '
'file=$fileName',
);
return Response.notFound('Invalid HLS file');
}
final directory = await castHlsCache.getMediaDirectory(
mediaId,
);
final file = File(
'${directory.path}/$fileName',
);
if (!await file.exists()) {
_tlog(
'HLS file not ready '
'requestId=$requestId '
'mediaId=$mediaId '
'file=$fileName '
'ms=${requestStopwatch.elapsedMilliseconds}',
);
return Response.notFound('HLS file not ready');
}
if (isPlaylist) {
final content = await file.readAsString();
final contentBytes = utf8.encode(content);
final completed = content.contains('#EXT-X-ENDLIST');
_tlog(
'HLS playlist serve '
'requestId=$requestId '
'mediaId=$mediaId '
'bytes=${contentBytes.length} '
'completed=$completed '
'method=${request.method} '
'ms=${requestStopwatch.elapsedMilliseconds}',
);
final headers = <String, String>{
HttpHeaders.contentTypeHeader: 'application/vnd.apple.mpegurl',
HttpHeaders.contentLengthHeader: '${contentBytes.length}',
HttpHeaders.cacheControlHeader: 'no-cache',
HttpHeaders.connectionHeader: 'close',
'Access-Control-Allow-Origin': '*',
'Access-Control-Expose-Headers': 'Content-Length, Content-Type',
};
if (request.method == 'HEAD') {
return Response.ok(
null,
headers: headers,
);
}
return Response.ok(
contentBytes,
headers: headers,
);
}
final String mimeType;
if (isTransportStreamSegment) {
mimeType = 'video/mp2t';
} else if (isInitializationSegment) {
mimeType = 'video/mp4';
} else {
mimeType = 'video/iso.segment';
}
final fileLength = await file.length();
_tlog(
'HLS file serve '
'requestId=$requestId '
'mediaId=$mediaId '
'file=$fileName '
'mime=$mimeType '
'bytes=$fileLength '
'range=${request.headers[HttpHeaders.rangeHeader] ?? '-'} '
'method=${request.method} '
'ms=${requestStopwatch.elapsedMilliseconds}',
);
if (isTransportStreamSegment && request.headers[HttpHeaders.rangeHeader] == null) {
final bytes = await file.readAsBytes();
final headers = <String, String>{
HttpHeaders.contentTypeHeader: mimeType,
HttpHeaders.contentLengthHeader: '${bytes.length}',
HttpHeaders.cacheControlHeader: 'no-cache',
HttpHeaders.connectionHeader: 'close',
HttpHeaders.acceptRangesHeader: 'bytes',
'Access-Control-Allow-Origin': '*',
'Access-Control-Expose-Headers': 'Content-Length, Content-Type, Accept-Ranges, Content-Range',
};
if (request.method == 'HEAD') {
return Response.ok(
null,
headers: headers,
);
}
return Response.ok(
bytes,
headers: headers,
);
}
return await _serveFileWithRange(
file,
request,
requestId: requestId,
id: mediaId,
mime: mimeType,
requestStopwatch: requestStopwatch,
);
}
Future<Response> _handleAssetRequest(
Request request,
Stopwatch requestStopwatch,
int requestId,
) async {
final fileName = request.url.pathSegments[1];
if (fileName != 'start.jpg' && fileName != 'end.jpg') {
_tlog(
'[CAST QUEUE] ASSET NOT FOUND '
'requestId=$requestId '
'file=$fileName',
);
return Response.notFound('Asset not found');
}
try {
final bytes = await _loadAssetBytes(fileName);
_tlog(
'[CAST QUEUE] ASSET SERVE '
'requestId=$requestId '
'file=$fileName '
'method=${request.method} '
'bytes=${bytes.length} '
'range=${request.headers['range'] ?? '-'}',
);
return await _serveBytesWithRange(
bytes,
request,
requestId: requestId,
resourceId: 'asset:$fileName',
mime: 'image/jpeg',
requestStopwatch: requestStopwatch,
representation: 'asset',
);
} catch (error, stackTrace) {
_tlog(
'[CAST QUEUE] ASSET ERROR '
'requestId=$requestId '
'file=$fileName '
'error=$error',
);
debugPrint(
'[CAST QUEUE] ASSET STACK '
'requestId=$requestId '
'file=$fileName\n'
'$stackTrace',
);
return Response.internalServerError(
body: 'Unable to load asset',
);
}
}
Future<Uint8List> _loadAssetBytes(
String fileName,
) async {
final cached = _assetCache[fileName];
if (cached != null) {
_tlog(
'[CAST QUEUE] ASSET CACHE HIT '
'file=$fileName '
'bytes=${cached.length}',
);
return cached;
}
final stopwatch = Stopwatch()..start();
final byteData = await rootBundle.load(
'assets/$fileName',
);
final bytes = byteData.buffer.asUint8List(
byteData.offsetInBytes,
byteData.lengthInBytes,
);
_assetCache[fileName] = bytes;
_tlog(
'[CAST QUEUE] ASSET CACHE STORE '
'file=$fileName '
'bytes=${bytes.length} '
'ms=${stopwatch.elapsedMilliseconds}',
);
return bytes;
}
static Map<String, String> _baseHeaders(
String mime,
int length,
) {
return <String, String>{
'Content-Type': mime,
'Content-Length': '$length',
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-cache',
};
}
static _ByteRange? _parseRange(
String? header,
int length,
) {
if (header == null || !header.startsWith('bytes=') || length <= 0) {
return null;
}
final rangeValue = header.substring(
'bytes='.length,
);
/*
* Shelf serve una singola risposta Range.
* Se arrivano più intervalli viene considerato soltanto il primo.
*/
final spec = rangeValue.split(',').first.trim();
final separator = spec.indexOf('-');
if (separator < 0) {
return null;
}
final startText = spec.substring(0, separator).trim();
final endText = spec.substring(separator + 1).trim();
int start;
int end;
/*
* Suffix range:
*
* Range: bytes=-500
*
* Richiede gli ultimi 500 byte.
*/
if (startText.isEmpty) {
final suffixLength = int.tryParse(endText);
if (suffixLength == null || suffixLength <= 0) {
return null;
}
start = (length - suffixLength).clamp(0, length - 1).toInt();
end = length - 1;
} else {
start = int.tryParse(startText) ?? -1;
end = endText.isEmpty ? length - 1 : int.tryParse(endText) ?? -1;
if (start < 0 || start >= length || end < start) {
return null;
}
if (end >= length) {
end = length - 1;
}
}
return _ByteRange(start, end);
}
static Response _rangeNotSatisfiable(
int length, {
int? requestId,
String? resourceId,
String? rangeHeader,
}) {
_tlog(
'HTTP 416 '
'requestId=${requestId ?? '-'} '
'resource=${resourceId ?? '-'} '
'requestedRange=${rangeHeader ?? '-'} '
'length=$length',
);
return Response(
HttpStatus.requestedRangeNotSatisfiable,
headers: <String, String>{
'Content-Range': 'bytes */$length',
'Accept-Ranges': 'bytes',
'Content-Length': '0',
},
);
}
static Response _methodNotAllowed({
required int requestId,
required String method,
required String resourceId,
}) {
_tlog(
'HTTP 405 '
'requestId=$requestId '
'resource=$resourceId '
'method=$method',
);
return Response(
HttpStatus.methodNotAllowed,
headers: const <String, String>{
'Allow': 'GET, HEAD',
'Accept-Ranges': 'bytes',
'Content-Length': '0',
},
);
}
static Future<Response> _serveBytesWithRange(
Uint8List bytes,
Request request, {
required int requestId,
required String resourceId,
required String mime,
required Stopwatch requestStopwatch,
required String representation,
}) async {
final length = bytes.length;
final rangeHeader = request.headers['range'];
if (request.method != 'GET' && request.method != 'HEAD') {
return _methodNotAllowed(
requestId: requestId,
method: request.method,
resourceId: resourceId,
);
}
if (request.method == 'HEAD') {
_tlog(
'HTTP HEAD '
'requestId=$requestId '
'resource=$resourceId '
'representation=$representation '
'mime=$mime '
'length=$length '
'range=${rangeHeader ?? '-'} '
'ms=${requestStopwatch.elapsedMilliseconds}',
);
return Response.ok(
null,
headers: _baseHeaders(mime, length),
);
}
if (rangeHeader == null) {
_tlog(
'HTTP 200 bytes '
'requestId=$requestId '
'resource=$resourceId '
'representation=$representation '
'mime=$mime '
'size=$length '
'ms=${requestStopwatch.elapsedMilliseconds}',
);
return Response.ok(
bytes,
headers: _baseHeaders(mime, length),
);
}
if (!rangeHeader.startsWith('bytes=')) {
return _rangeNotSatisfiable(
length,
requestId: requestId,
resourceId: resourceId,
rangeHeader: rangeHeader,
);
}
final range = _parseRange(
rangeHeader,
length,
);
if (range == null) {
return _rangeNotSatisfiable(
length,
requestId: requestId,
resourceId: resourceId,
rangeHeader: rangeHeader,
);
}
final slice = Uint8List.sublistView(
bytes,
range.start,
range.end + 1,
);
_tlog(
'HTTP 206 bytes '
'requestId=$requestId '
'resource=$resourceId '
'representation=$representation '
'mime=$mime '
'range=${range.start}-${range.end}/$length '
'contentLength=${slice.length} '
'requestedRange=$rangeHeader '
'ms=${requestStopwatch.elapsedMilliseconds}',
);
return Response(
HttpStatus.partialContent,
body: slice,
headers: <String, String>{
..._baseHeaders(mime, slice.length),
'Content-Range': 'bytes ${range.start}-${range.end}/$length',
},
);
}
static Future<Response> _serveFileWithRange(
File file,
Request request, {
required int requestId,
required int id,
required String mime,
required Stopwatch requestStopwatch,
}) async {
final metadataStopwatch = Stopwatch()..start();
final length = await file.length();
final metadataMs = metadataStopwatch.elapsedMilliseconds;
final rangeHeader = request.headers['range'];
final resourceId = 'media:$id';
if (request.method != 'GET' && request.method != 'HEAD') {
return _methodNotAllowed(
requestId: requestId,
method: request.method,
resourceId: resourceId,
);
}
if (request.method == 'HEAD') {
_tlog(
'HTTP HEAD original '
'requestId=$requestId '
'id=$id '
'mime=$mime '
'length=$length '
'range=${rangeHeader ?? '-'} '
'fileLengthMs=$metadataMs '
'requestMs=${requestStopwatch.elapsedMilliseconds}',
);
return Response.ok(
null,
headers: _baseHeaders(mime, length),
);
}
if (rangeHeader == null) {
_tlog(
'HTTP 200 original start '
'requestId=$requestId '
'id=$id '
'mime=$mime '
'length=$length '
'fileLengthMs=$metadataMs '
'requestMs=${requestStopwatch.elapsedMilliseconds}',
);
final stream = _logStreamCompletion(
file.openRead(),
label: 'HTTP 200 original',
requestId: requestId,
id: id,
expectedBytes: length,
rangeStart: 0,
rangeEnd: length - 1,
requestStopwatch: requestStopwatch,
);
return Response.ok(
stream,
headers: _baseHeaders(mime, length),
);
}
if (!rangeHeader.startsWith('bytes=')) {
return _rangeNotSatisfiable(
length,
requestId: requestId,
resourceId: resourceId,
rangeHeader: rangeHeader,
);
}
final range = _parseRange(
rangeHeader,
length,
);
if (range == null) {
return _rangeNotSatisfiable(
length,
requestId: requestId,
resourceId: resourceId,
rangeHeader: rangeHeader,
);
}
final contentLength = range.end - range.start + 1;
_tlog(
'HTTP 206 original start '
'requestId=$requestId '
'id=$id '
'mime=$mime '
'range=${range.start}-${range.end}/$length '
'contentLength=$contentLength '
'requestedRange=$rangeHeader '
'fileLengthMs=$metadataMs '
'requestMs=${requestStopwatch.elapsedMilliseconds}',
);
/*
* File.openRead utilizza:
*
* start incluso
* end escluso
*
* Content-Range utilizza invece un estremo finale incluso.
* Per questo viene passato range.end + 1.
*/
final stream = _logStreamCompletion(
file.openRead(
range.start,
range.end + 1,
),
label: 'HTTP 206 original',
requestId: requestId,
id: id,
expectedBytes: contentLength,
rangeStart: range.start,
rangeEnd: range.end,
requestStopwatch: requestStopwatch,
);
return Response(
HttpStatus.partialContent,
body: stream,
headers: <String, String>{
..._baseHeaders(mime, contentLength),
'Content-Range': 'bytes ${range.start}-${range.end}/$length',
},
);
}
static Stream<List<int>> _logStreamCompletion(
Stream<List<int>> source, {
required String label,
required int requestId,
required int id,
required int expectedBytes,
required int rangeStart,
required int rangeEnd,
required Stopwatch requestStopwatch,
}) async* {
const progressIntervalMs = 5000;
const slowChunkGapMs = 750;
/*
* Misura esclusivamente il tempo dello stream.
*
* requestStopwatch continua invece a misurare l'intera richiesta,
* compresa la risoluzione del percorso e la preparazione della risposta.
*/
final streamStopwatch = Stopwatch()..start();
var sentBytes = 0;
var previousProgressBytes = 0;
var previousProgressMs = 0;
var previousChunkMs = 0;
var chunkCount = 0;
var maximumChunkGapMs = 0;
var completedNormally = false;
Object? streamError;
_tlog(
'$label stream-open '
'requestId=$requestId '
'id=$id '
'range=$rangeStart-$rangeEnd '
'expectedBytes=$expectedBytes '
'requestMs=${requestStopwatch.elapsedMilliseconds}',
);
try {
await for (final chunk in source) {
final nowMs = streamStopwatch.elapsedMilliseconds;
final chunkGapMs = nowMs - previousChunkMs;
if (chunkGapMs > maximumChunkGapMs) {
maximumChunkGapMs = chunkGapMs;
}
/*
* Segnala una pausa significativa nella lettura del file,
* nello scheduling Dart oppure nella backpressure HTTP/TCP.
*/
if (chunkCount > 0 && chunkGapMs >= slowChunkGapMs) {
_tlog(
'$label slow-chunk '
'requestId=$requestId '
'id=$id '
'gapMs=$chunkGapMs '
'sentBytes=$sentBytes '
'expectedBytes=$expectedBytes '
'chunkBytes=${chunk.length} '
'chunkCount=$chunkCount '
'streamMs=$nowMs '
'requestMs=${requestStopwatch.elapsedMilliseconds}',
);
}
previousChunkMs = nowMs;
chunkCount++;
sentBytes += chunk.length;
final intervalMs = nowMs - previousProgressMs;
if (intervalMs >= progressIntervalMs) {
final intervalBytes = sentBytes - previousProgressBytes;
final currentMbps = intervalMs > 0 ? (intervalBytes * 8 * 1000) / (intervalMs * 1000000) : 0.0;
final averageMbps = nowMs > 0 ? (sentBytes * 8 * 1000) / (nowMs * 1000000) : 0.0;
final completionPercent = expectedBytes > 0 ? (sentBytes * 100) / expectedBytes : 0.0;
_tlog(
'$label progress '
'requestId=$requestId '
'id=$id '
'range=$rangeStart-$rangeEnd '
'sentBytes=$sentBytes '
'expectedBytes=$expectedBytes '
'completion=${completionPercent.toStringAsFixed(1)}% '
'intervalBytes=$intervalBytes '
'currentMbps=${currentMbps.toStringAsFixed(2)} '
'averageMbps=${averageMbps.toStringAsFixed(2)} '
'lastChunkGapMs=$chunkGapMs '
'maxChunkGapMs=$maximumChunkGapMs '
'chunkBytes=${chunk.length} '
'chunkCount=$chunkCount '
'streamMs=$nowMs '
'requestMs=${requestStopwatch.elapsedMilliseconds}',
);
previousProgressBytes = sentBytes;
previousProgressMs = nowMs;
}
yield chunk;
}
completedNormally = true;
final streamMs = streamStopwatch.elapsedMilliseconds;
final averageMbps = streamMs > 0 ? (sentBytes * 8 * 1000) / (streamMs * 1000000) : 0.0;
final byteCountMatches = sentBytes == expectedBytes;
_tlog(
'$label end '
'requestId=$requestId '
'id=$id '
'range=$rangeStart-$rangeEnd '
'sentBytes=$sentBytes '
'expectedBytes=$expectedBytes '
'byteCountMatches=$byteCountMatches '
'averageMbps=${averageMbps.toStringAsFixed(2)} '
'maxChunkGapMs=$maximumChunkGapMs '
'chunkCount=$chunkCount '
'streamMs=$streamMs '
'requestMs=${requestStopwatch.elapsedMilliseconds}',
);
if (!byteCountMatches) {
_tlog(
'$label byte-count-warning '
'requestId=$requestId '
'id=$id '
'sentBytes=$sentBytes '
'expectedBytes=$expectedBytes '
'difference=${expectedBytes - sentBytes}',
);
}
} catch (error, stackTrace) {
streamError = error;
final streamMs = streamStopwatch.elapsedMilliseconds;
final averageMbps = streamMs > 0 ? (sentBytes * 8 * 1000) / (streamMs * 1000000) : 0.0;
_tlog(
'$label error '
'requestId=$requestId '
'id=$id '
'range=$rangeStart-$rangeEnd '
'sentBytes=$sentBytes '
'expectedBytes=$expectedBytes '
'averageMbps=${averageMbps.toStringAsFixed(2)} '
'maxChunkGapMs=$maximumChunkGapMs '
'chunkCount=$chunkCount '
'streamMs=$streamMs '
'requestMs=${requestStopwatch.elapsedMilliseconds} '
'error=$error',
);
debugPrint(
'CASTT stream stack '
'requestId=$requestId '
'label=$label '
'id=$id\n'
'$stackTrace',
);
rethrow;
} finally {
streamStopwatch.stop();
_tlog(
'$label closed '
'requestId=$requestId '
'id=$id '
'completedNormally=$completedNormally '
'hadError=${streamError != null} '
'sentBytes=$sentBytes '
'expectedBytes=$expectedBytes '
'maxChunkGapMs=$maximumChunkGapMs '
'streamMs=${streamStopwatch.elapsedMilliseconds} '
'requestMs=${requestStopwatch.elapsedMilliseconds}',
);
}
}
Future<String?> resolveFilePathForMediaId(int mediaId) async {
final stopwatch = Stopwatch()..start();
final path = await _resolveFilePath(mediaId);
stopwatch.stop();
_tlog(
'resolveFilePathForMediaId '
'id=$mediaId '
'hasPath=${path != null && path.isNotEmpty} '
'ms=${stopwatch.elapsedMilliseconds}',
);
return path;
}
Future<String?> _resolveFilePath(int id) async {
final cachedPath = _filePathCache[id];
if (cachedPath != null) {
final stopwatch = Stopwatch()..start();
final exists = await File(cachedPath).exists();
if (exists) {
_tlog(
'file path cache hit '
'id=$id '
'path=$cachedPath '
'checkMs=${stopwatch.elapsedMilliseconds}',
);
return cachedPath;
}
_filePathCache.remove(id);
_tlog(
'file path cache stale '
'id=$id '
'path=$cachedPath '
'checkMs=${stopwatch.elapsedMilliseconds}',
);
}
final stopwatch = Stopwatch()..start();
final path = await _getFilePath(id);
if (path == null || path.isEmpty) {
_tlog(
'file path resolve failed '
'id=$id '
'ms=${stopwatch.elapsedMilliseconds}',
);
return null;
}
final file = File(path);
if (!await file.exists()) {
_tlog(
'file path resolve missing-file '
'id=$id '
'path=$path '
'ms=${stopwatch.elapsedMilliseconds}',
);
return null;
}
_filePathCache[id] = path;
_tlog(
'file path cache store '
'id=$id '
'path=$path '
'ms=${stopwatch.elapsedMilliseconds}',
);
return path;
}
static Future<String?> _getFilePath(int id) async {
const channel = MethodChannel(
'aves/entry_path',
);
final stopwatch = Stopwatch()..start();
try {
final path = await channel.invokeMethod<String>(
'getEntryPath',
<String, dynamic>{
'id': id,
},
);
_tlog(
'getFilePath completed '
'id=$id '
'hasPath=${path != null && path.isNotEmpty} '
'ms=${stopwatch.elapsedMilliseconds}',
);
return path;
} catch (error, stackTrace) {
_tlog(
'getFilePath error '
'id=$id '
'ms=${stopwatch.elapsedMilliseconds} '
'error=$error',
);
debugPrint(
'CASTT getFilePath stack '
'id=$id\n'
'$stackTrace',
);
return null;
}
}
}
final castServer = CastServer();