// 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 _optimizedImages = {}; final Map _servingChoices = {}; final Map _assetCache = {}; /// Evita di richiamare il MethodChannel a ogni nuova richiesta Range /// per lo stesso media. final Map _filePathCache = {}; 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 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 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 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 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 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 _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 _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: { 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 = { 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 = { 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 _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 _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 _baseHeaders( String mime, int length, ) { return { '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: { '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 { 'Allow': 'GET, HEAD', 'Accept-Ranges': 'bytes', 'Content-Length': '0', }, ); } static Future _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: { ..._baseHeaders(mime, slice.length), 'Content-Range': 'bytes ${range.start}-${range.end}/$length', }, ); } static Future _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: { ..._baseHeaders(mime, contentLength), 'Content-Range': 'bytes ${range.start}-${range.end}/$length', }, ); } static Stream> _logStreamCompletion( Stream> 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 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 _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 _getFilePath(int id) async { const channel = MethodChannel( 'aves/entry_path', ); final stopwatch = Stopwatch()..start(); try { final path = await channel.invokeMethod( 'getEntryPath', { '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();