import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; class WindowsRelaySession { const WindowsRelaySession({ required this.videoUrl, required this.audioUrl, }); final String videoUrl; final String audioUrl; } /// Relais HTTP local Windows dont le transport upstream est exécuté par /// yt-dlp lui-même. /// /// Architecture : /// /// libmpv -> 127.0.0.1 -> yt-dlp HttpFD -> googlevideo /// /// Les étapes 17.10/17.11 ont démontré que : /// - libmpv lit correctement le 1080p depuis localhost ; /// - le HttpClient Dart n'est pas accepté de façon stable par Googlevideo ; /// - yt-dlp, lui, sait télécharger le même format. /// /// Pour chaque GET/Range demandé par mpv, le relais construit un petit /// info-json local contenant l'URL signée, les headers et la plage demandée, /// puis lance le yt-dlp déjà empaqueté avec `--load-info-json` et `-o -`. /// /// yt-dlp applique alors exactement son propre downloader HTTP, y compris /// la logique officielle de reprise/chunking et son stack réseau. class WindowsMediaRelayService { HttpServer? _server; StreamSubscription? _serverSubscription; final Map _sources = {}; final Set _activeProcesses = {}; Directory? _sessionDirectory; Future start({ required String ytDlpPath, required String videoUrl, required String audioUrl, required Map videoHeaders, required Map audioHeaders, required int videoLength, required int audioLength, required int videoChunkSize, required int audioChunkSize, required String videoMimeType, required String audioMimeType, }) async { if (!Platform.isWindows) { throw UnsupportedError( 'Le relais HD local est réservé à Windows.', ); } await stop(); final ytDlp = File(ytDlpPath); if (!await ytDlp.exists()) { throw StateError( 'yt-dlp.exe introuvable pour le relais : $ytDlpPath', ); } if (videoLength <= 0 || audioLength <= 0) { throw StateError( 'Taille vidéo/audio inconnue : ' 'video=$videoLength audio=$audioLength', ); } final videoUri = Uri.tryParse(videoUrl); final audioUri = Uri.tryParse(audioUrl); if (videoUri == null || !videoUri.hasScheme || audioUri == null || !audioUri.hasScheme) { throw StateError( 'URL vidéo/audio invalide pour le relais.', ); } final sessionDirectory = await Directory.systemTemp.createTemp( 'youtube_personal_ytdlp_relay_', ); final server = await HttpServer.bind( InternetAddress.loopbackIPv4, 0, shared: false, ); _sessionDirectory = sessionDirectory; _server = server; _sources ..clear() ..['/video'] = _RelaySource( id: 'video', ytDlpPath: ytDlp.path, upstream: videoUri, headers: videoHeaders, length: videoLength, chunkSize: _safeChunkSize(videoChunkSize), contentType: videoMimeType, ext: _extensionForMime( videoMimeType, fallback: 'mp4', ), ) ..['/audio'] = _RelaySource( id: 'audio', ytDlpPath: ytDlp.path, upstream: audioUri, headers: audioHeaders, length: audioLength, chunkSize: _safeChunkSize(audioChunkSize), contentType: audioMimeType, ext: _extensionForMime( audioMimeType, fallback: 'm4a', ), ); _serverSubscription = server.listen( (request) { unawaited(_handleRequest(request)); }, onError: (Object error, StackTrace stackTrace) { debugPrint( '[HD-RELAY] erreur serveur local: $error', ); }, cancelOnError: false, ); final base = 'http://127.0.0.1:${server.port}'; debugPrint( '[HD-RELAY][17.12] actif sur 127.0.0.1:${server.port} ' '(transport upstream = yt-dlp HttpFD)', ); return WindowsRelaySession( videoUrl: '$base/video', audioUrl: '$base/audio', ); } Future stop() async { final subscription = _serverSubscription; _serverSubscription = null; if (subscription != null) { await subscription.cancel(); } final server = _server; _server = null; if (server != null) { await server.close(force: true); } final processes = _activeProcesses.toList(growable: false); _activeProcesses.clear(); for (final process in processes) { process.kill(); } final sessionDirectory = _sessionDirectory; _sessionDirectory = null; if (sessionDirectory != null) { try { if (await sessionDirectory.exists()) { await sessionDirectory.delete( recursive: true, ); } } catch (_) { // Windows peut garder brièvement un handle après l'arrêt de yt-dlp. } } _sources.clear(); } int _safeChunkSize(int requested) { if (requested <= 0) { return 10 * 1024 * 1024; } return requested.clamp( 256 * 1024, 10 * 1024 * 1024, ); } String _extensionForMime( String mime, { required String fallback, }) { final normalized = mime.toLowerCase(); if (normalized.contains('webm')) { return 'webm'; } if (normalized.contains('mp4') || normalized.contains('m4a')) { return fallback; } return fallback; } Future _handleRequest( HttpRequest request, ) async { final source = _sources[request.uri.path]; if (source == null) { request.response.statusCode = HttpStatus.notFound; await request.response.close(); return; } if (request.method != 'GET' && request.method != 'HEAD') { request.response.statusCode = HttpStatus.methodNotAllowed; request.response.headers.set( 'allow', 'GET, HEAD', ); await request.response.close(); return; } final rangeHeader = request.headers.value( HttpHeaders.rangeHeader, ); final parsed = _parseRange( rangeHeader, source.length, ); if (rangeHeader != null && parsed == null) { request.response.statusCode = HttpStatus.requestedRangeNotSatisfiable; request.response.headers.set( 'content-range', 'bytes */${source.length}', ); await request.response.close(); return; } final start = parsed?.$1 ?? 0; final end = parsed?.$2 ?? source.length - 1; final response = request.response; response.statusCode = parsed == null ? HttpStatus.ok : HttpStatus.partialContent; response.headers.set( 'accept-ranges', 'bytes', ); response.headers.set( HttpHeaders.cacheControlHeader, 'no-store', ); response.headers.set( HttpHeaders.contentTypeHeader, source.contentType, ); final responseLength = end - start + 1; response.contentLength = responseLength; if (parsed != null) { response.headers.set( 'content-range', 'bytes $start-$end/${source.length}', ); } debugPrint( '[HD-RELAY][17.12] ${request.uri.path} ' '${request.method} bytes=$start-$end ' '(${responseLength}o)', ); if (request.method == 'HEAD') { await response.close(); return; } try { await _pipeWithYtDlp( source: source, start: start, end: end, downstream: response, ); } on Object catch (error) { debugPrint( '[HD-RELAY][17.12] ${request.uri.path} échec: $error', ); try { await response.close(); } catch (_) { // Le lecteur peut avoir fermé la connexion lors d'un seek. } } } (int, int)? _parseRange( String? value, int totalLength, ) { if (value == null || value.trim().isEmpty) { return null; } final match = RegExp( r'^bytes=(\d*)-(\d*)$', caseSensitive: false, ).firstMatch(value.trim()); if (match == null) { return null; } final startText = match.group(1) ?? ''; final endText = match.group(2) ?? ''; if (startText.isEmpty) { final suffix = int.tryParse(endText); if (suffix == null || suffix <= 0) { return null; } final start = (totalLength - suffix).clamp( 0, totalLength - 1, ); return (start, totalLength - 1); } final start = int.tryParse(startText); if (start == null || start < 0 || start >= totalLength) { return null; } final parsedEnd = endText.isEmpty ? totalLength - 1 : int.tryParse(endText); if (parsedEnd == null || parsedEnd < start) { return null; } final end = parsedEnd.clamp( start, totalLength - 1, ); return (start, end); } Future _pipeWithYtDlp({ required _RelaySource source, required int start, required int end, required HttpResponse downstream, }) async { final sessionDirectory = _sessionDirectory; if (sessionDirectory == null) { throw StateError( 'Session relais yt-dlp fermée.', ); } final rangeValue = 'bytes=$start-$end'; final infoHeaders = { ...source.headers, 'Accept-Encoding': 'identity', 'Range': rangeValue, }; final info = { 'id': 'youtube-personal-${source.id}', 'title': 'YouTube Personal ${source.id}', 'ext': source.ext, 'protocol': 'https', 'url': source.upstream.toString(), 'http_headers': infoHeaders, 'filesize': source.length, 'downloader_options': { 'http_chunk_size': source.chunkSize, }, }; final stamp = '${DateTime.now().microsecondsSinceEpoch}' '-${requestCounter++}'; final infoFile = File( '${sessionDirectory.path}' '${Platform.pathSeparator}' '${source.id}-$stamp.info.json', ); await infoFile.writeAsString( jsonEncode(info), encoding: utf8, flush: true, ); final process = await Process.start( source.ytDlpPath, [ '--ignore-config', '--no-warnings', '--no-progress', '--socket-timeout', '20', '--retries', '2', '--no-part', '--load-info-json', infoFile.path, '-o', '-', ], workingDirectory: File(source.ytDlpPath).parent.path, runInShell: false, mode: ProcessStartMode.normal, ); _activeProcesses.add(process); final stderrBuffer = StringBuffer(); final stderrSubscription = process.stderr .transform(utf8.decoder) .listen( (text) { if (stderrBuffer.length < 4000) { final remaining = 4000 - stderrBuffer.length; stderrBuffer.write( text.length <= remaining ? text : text.substring(0, remaining), ); } }, ); var written = 0; Object? streamError; debugPrint( '[HD-RELAY][17.12] ${source.id}: ' 'yt-dlp démarre Range=$rangeValue', ); try { await for (final data in process.stdout) { if (written >= end - start + 1) { break; } final remaining = end - start + 1 - written; if (data.length <= remaining) { downstream.add(data); written += data.length; } else { downstream.add( data.sublist(0, remaining), ); written += remaining; } // Backpressure : permet au lecteur de contrôler la vitesse // au lieu de remplir massivement la mémoire. await downstream.flush(); if (written >= end - start + 1) { break; } } } on Object catch (error) { streamError = error; } if (written >= end - start + 1) { // La plage demandée est complète. Si yt-dlp n'a pas encore quitté // (par ex. sortie stdout plus large), on peut arrêter le process. process.kill(); } final exitCode = await process.exitCode; await stderrSubscription.cancel(); _activeProcesses.remove(process); try { if (await infoFile.exists()) { await infoFile.delete(); } } catch (_) { // Non bloquant. } if (streamError != null) { throw streamError; } if (written != end - start + 1) { final stderr = stderrBuffer .toString() .replaceAll(RegExp(r'\s+'), ' ') .trim(); throw HttpException( 'yt-dlp a fourni $written/' '${end - start + 1} octets ' '(code=$exitCode)' '${stderr.isEmpty ? '' : ': $stderr'}', ); } debugPrint( '[HD-RELAY][17.12] ${source.id}: ' 'yt-dlp OK ${written}o', ); await downstream.close(); } int requestCounter = 0; } class _RelaySource { const _RelaySource({ required this.id, required this.ytDlpPath, required this.upstream, required this.headers, required this.length, required this.chunkSize, required this.contentType, required this.ext, }); final String id; final String ytDlpPath; final Uri upstream; final Map headers; final int length; final int chunkSize; final String contentType; final String ext; }