1551 lines
37 KiB
Dart
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();
|