import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; /// Dart handles upstream TLS; FFmpeg sees only a short-lived loopback URL. /// Only the initial resource and URLs discovered in its HLS playlists can be /// requested, with a shared transfer budget across playlists, keys and ranges. class MotionArtworkProxy { MotionArtworkProxy._(this._server, this._client, this._maxBytes); final HttpServer _server; final HttpClient _client; final int _maxBytes; final _resources = {}; final _paths = {}; final String _token = List.generate( 24, (_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'), ).join(); late final Timer _deadline; late final Uri source; int _transferred = 0; bool _closed = false; bool _hlsDetected = false; Future? _closing; bool get isClosed => _closed; bool get isHls => _hlsDetected || source.path.endsWith('.m3u8'); static Future start( Uri source, { HttpClient? client, int maxBytes = 24 << 20, Duration lifetime = const Duration(seconds: 30), }) async { _checkRemote(source); final upstream = client ?? HttpClient(); upstream ..connectionTimeout = const Duration(seconds: 8) ..autoUncompress = false; try { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final proxy = MotionArtworkProxy._(server, upstream, maxBytes); proxy.source = proxy._register(source); proxy._deadline = Timer(lifetime, () => unawaited(proxy.close())); server.listen(proxy._handle, onError: (Object _) {}); return proxy; } catch (_) { upstream.close(force: true); rethrow; } } Future close() => _closing ??= _close(); Future _close() async { _closed = true; _deadline.cancel(); _client.close(force: true); await _server.close(force: true); } static void _checkRemote(Uri uri) { if (!['http', 'https'].contains(uri.scheme) || uri.host.isEmpty || uri.toString().length > 8192) { throw const FormatException('Artwork resource requires HTTP or HTTPS'); } } Uri _register(Uri remote) { _checkRemote(remote); remote = remote.removeFragment(); final existing = _paths[remote]; if (existing != null) return _local(existing); if (_resources.length >= 4096 || remote.toString().length > 8192) { throw const FormatException('Too many artwork resources'); } // HLS validates segment extensions before opening them; retain the // upstream suffix while hiding its URL/query behind the resource token. final extension = RegExp(r'\.[a-z0-9]{1,10}$').stringMatch(remote.path.toLowerCase()) ?? '.bin'; final path = '/$_token/${_resources.length}$extension'; _resources[path] = remote; _paths[remote] = path; return _local(path); } Uri _local(String path) => Uri( scheme: 'http', host: InternetAddress.loopbackIPv4.address, port: _server.port, path: path, ); Future<({HttpClientResponse response, Uri uri})> _request( Uri uri, HttpRequest incoming, ) async { for (var redirects = 0; redirects <= 5; redirects++) { _checkRemote(uri); final request = await _client .openUrl(incoming.method, uri) .timeout(const Duration(seconds: 8)); request.followRedirects = false; request.headers.set(HttpHeaders.acceptEncodingHeader, 'identity'); for (final name in [HttpHeaders.rangeHeader, HttpHeaders.ifRangeHeader]) { final value = incoming.headers.value(name); if (value != null) request.headers.set(name, value); } final response = await request.close().timeout( const Duration(seconds: 8), ); if (!response.isRedirect) return (response: response, uri: uri); final location = response.headers.value(HttpHeaders.locationHeader); await response.listen(null).cancel(); if (location == null) throw const HttpException('Missing redirect'); uri = uri.resolve(location); } throw const HttpException('Too many artwork redirects'); } Stream> _body(HttpClientResponse response) async* { await for (final chunk in response.timeout(const Duration(seconds: 8))) { if (_closed || _transferred + chunk.length > _maxBytes) { unawaited(close()); throw const HttpException('Artwork transfer budget exceeded'); } _transferred += chunk.length; yield chunk; } } Future _handle(HttpRequest request) async { final outgoing = request.response; outgoing.persistentConnection = false; outgoing.bufferOutput = false; try { final remote = _resources[request.uri.path]; if (_closed || remote == null || request.uri.hasQuery) { outgoing.statusCode = HttpStatus.notFound; return; } if (request.method != 'GET' && request.method != 'HEAD') { outgoing.statusCode = HttpStatus.methodNotAllowed; return; } final upstream = await _request(remote, request); final response = upstream.response; outgoing.statusCode = response.statusCode; for (final name in [ HttpHeaders.contentTypeHeader, HttpHeaders.contentRangeHeader, HttpHeaders.acceptRangesHeader, HttpHeaders.contentEncodingHeader, ]) { final value = response.headers.value(name); if (value != null) outgoing.headers.set(name, value); } if (request.method == 'HEAD') { if (response.contentLength >= 0) { outgoing.contentLength = response.contentLength; } await response.drain(); return; } final body = StreamIterator(_body(response)); try { final prefix = []; while (prefix.length < 7 && await body.moveNext()) { prefix.addAll(body.current); } final playlist = response.statusCode >= 200 && response.statusCode < 300 && (upstream.uri.path.toLowerCase().endsWith('.m3u8') || (response.headers.contentType?.mimeType.contains('mpegurl') ?? false) || utf8 .decode(prefix.take(32).toList(), allowMalformed: true) .trimLeft() .startsWith('#EXTM3U')); if (playlist) { _hlsDetected = true; final bytes = [...prefix]; while (await body.moveNext()) { if (bytes.length + body.current.length > 512 << 10) { throw const FormatException( 'Artwork playlist exceeds size limit', ); } bytes.addAll(body.current); } if (bytes.length > 512 << 10) { throw const FormatException('Artwork playlist exceeds size limit'); } final text = await _playlistText(bytes, response.headers); final rewritten = utf8.encode(_rewrite(text, upstream.uri)); outgoing ..statusCode = HttpStatus.ok ..headers.removeAll(HttpHeaders.contentRangeHeader) ..headers.removeAll(HttpHeaders.contentEncodingHeader) ..headers.contentType = ContentType( 'application', 'vnd.apple.mpegurl', ) ..contentLength = rewritten.length ..add(rewritten); } else { if (response.contentLength >= 0) { outgoing.contentLength = response.contentLength; } outgoing.add(prefix); await outgoing.flush(); while (await body.moveNext()) { outgoing.add(body.current); await outgoing.flush(); } } } finally { await body.cancel(); } } catch (_) { try { outgoing.statusCode = HttpStatus.badGateway; } catch (_) { // A stream failure can happen after binary response headers were sent. } } finally { try { await outgoing.close(); } catch (_) {} } } Future _playlistText(List bytes, HttpHeaders headers) async { Stream> stream = Stream.value(bytes); switch (headers.value(HttpHeaders.contentEncodingHeader)?.toLowerCase()) { case 'gzip': stream = gzip.decoder.bind(stream); case 'deflate': stream = zlib.decoder.bind(stream); case null || 'identity': break; default: throw const FormatException('Unsupported artwork playlist encoding'); } final decoded = []; await for (final chunk in stream) { if (decoded.length + chunk.length > 512 << 10) { throw const FormatException('Artwork playlist exceeds size limit'); } decoded.addAll(chunk); } return utf8.decode(decoded); } String _rewrite(String text, Uri base) => const LineSplitter() .convert(text) .map((raw) { final line = raw.trim(); if (line.isEmpty) return raw; if (!line.startsWith('#')) { return _register(base.resolve(line)).toString(); } return raw.replaceAllMapped(RegExp(r'([:,])URI="([^"]+)"'), (match) { return '${match[1]}URI="${_register(base.resolve(match[2]!))}"'; }); }) .join('\n'); } Future withMotionArtworkProxy( Uri source, Future Function(Uri source) action, { HttpClient? client, int maxBytes = 24 << 20, Duration lifetime = const Duration(seconds: 30), }) async { final proxy = await MotionArtworkProxy.start( source, client: client, maxBytes: maxBytes, lifetime: lifetime, ); try { return await action(proxy.source); } finally { await proxy.close(); } }