import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:media_kit/media_kit.dart'; /// Extraction HD/4K locale pour Windows. /// /// yt-dlp choisit les URLs vidéo et audio. Cette version renvoie également /// les en-têtes HTTP associés aux formats, puis les applique à libmpv avant /// lecture. /// /// Aucun cookie Google de l'application et aucun jeton OAuth YouTube Data API /// n'est transmis à yt-dlp. class WindowsHdService { WindowsHdService._(); static final WindowsHdService instance = WindowsHdService._(); /// Extraction prioritaire Windows via `web_embedded`. /// /// Ce client est particulièrement intéressant ici car yt-dlp n'exige /// actuellement pas de PO Token GVS pour les vidéos embeddables. /// /// Le test manuel de l'étape 17.14 a confirmé pour la vidéo de validation : /// - audio 251 Opus disponible et téléchargeable intégralement ; /// - 1080p60 format 299 ; /// - 1440p60 format 308 ; /// - 2160p60 format 315. Future?> getEmbeddedHdStream({ required String videoId, required int targetHeight, String preferredLanguage = 'auto', String originalLanguageHint = 'auto', }) async { if (!Platform.isWindows) { return null; } final tools = _resolveBundledTools(); final ytDlp = tools.$1; final deno = tools.$2; if (!await ytDlp.exists()) { throw StateError( 'yt-dlp.exe absent. Lance PREPARER_HD_WINDOWS.ps1.', ); } if (!await deno.exists()) { throw StateError( 'deno.exe absent. Lance PREPARER_HD_WINDOWS.ps1.', ); } final watchUrl = Uri.https( 'www.youtube.com', '/watch', { 'v': videoId, }, ).toString(); final arguments = [ '--ignore-config', '--no-playlist', '--no-warnings', '--encoding', 'utf-8', '--socket-timeout', '20', '--retries', '2', '--js-runtimes', 'deno:${deno.path}', '--extractor-args', 'youtube:player_client=web_embedded', '--skip-download', '--dump-single-json', watchUrl, ]; debugPrint( '[HD-WINDOWS][17.14] extraction web_embedded ' 'cible=$targetHeight', ); final result = await Process.run( ytDlp.path, arguments, workingDirectory: ytDlp.parent.path, runInShell: false, stdoutEncoding: utf8, stderrEncoding: utf8, ).timeout( const Duration(seconds: 45), ); if (result.exitCode != 0) { final stderr = _compactError( result.stderr?.toString() ?? '', ); debugPrint( '[HD-WINDOWS][17.14] web_embedded indisponible: ' '${stderr.isEmpty ? 'code ${result.exitCode}' : stderr}', ); return null; } final stdout = result.stdout?.toString().trim() ?? ''; if (stdout.isEmpty) { return null; } final dynamic decoded; try { decoded = jsonDecode(stdout); } on Object catch (error) { debugPrint( '[HD-WINDOWS][17.14] JSON web_embedded invalide: $error', ); return null; } if (decoded is! Map) { return null; } final rawFormats = decoded['formats']; if (rawFormats is! List) { return null; } final formats = rawFormats .whereType() .map( (format) => format.map( (key, value) => MapEntry( key.toString(), value, ), ), ) .toList(growable: false); final effectiveTarget = targetHeight <= 0 ? 2160 : targetHeight; final availableHeights = formats .where(_isVideoOnly) .where(_hasHttpUrl) .map((format) => _intValue(format['height'])) .where((height) => height >= 360) .toSet() .toList() ..sort(); final maxAvailableHeight = availableHeights.isEmpty ? 0 : availableHeights.last; final availableAudioTracks = _collectAvailableAudioTracks(formats); if (targetHeight > 0 && maxAvailableHeight > 0 && maxAvailableHeight < targetHeight) { debugPrint( '[HD-WINDOWS][17.21] cible ${targetHeight}p absente ' 'pour web_embedded; disponibles=$availableHeights', ); } final video = _selectVideo( formats, effectiveTarget, ); final audio = _selectEmbeddedAudio( formats, preferredLanguage: preferredLanguage, originalLanguageHint: originalLanguageHint, ); if (video == null || audio == null) { debugPrint( '[HD-WINDOWS][17.14] paire web_embedded absente ' 'jusqu’à ${effectiveTarget}p', ); return null; } final videoUrl = _stringValue(video['url']); final audioUrl = _stringValue(audio['url']); if (videoUrl == null || videoUrl.isEmpty || audioUrl == null || audioUrl.isEmpty) { return null; } final videoHeaders = {}; final audioHeaders = {}; _mergeHeaders( videoHeaders, decoded['http_headers'], overwrite: false, ); _mergeHeaders( videoHeaders, video['http_headers'], overwrite: true, ); _mergeHeaders( audioHeaders, decoded['http_headers'], overwrite: false, ); _mergeHeaders( audioHeaders, audio['http_headers'], overwrite: true, ); final combinedHeaders = { ...videoHeaders, }; _mergeHeaders( combinedHeaders, audioHeaders, overwrite: false, ); final width = _intValue(video['width']); final height = _intValue(video['height']); final fps = _intValue(video['fps']); final videoCodec = _stringValue(video['vcodec']) ?? 'inconnu'; final audioCodec = _stringValue(audio['acodec']) ?? 'inconnu'; final videoFormatId = _stringValue(video['format_id']) ?? 'inconnu'; final audioFormatId = _stringValue(audio['format_id']) ?? 'inconnu'; final audioLanguage = _audioLanguageCode(audio); final audioTrackName = _audioTrackLabel(audio); final preferred = _normalizeLanguageCode(preferredLanguage); final preferredAudioMatched = preferred != 'auto' && preferred != 'original' && _matchesAudioLanguage(audio, preferred); final originalAudioSelected = _isOriginalAudioFormat(audio); final videoClient = _youtubeClientFromUrl(videoUrl); final audioClient = _youtubeClientFromUrl(audioUrl); final videoLength = _formatLength( video, videoUrl, ); final audioLength = _formatLength( audio, audioUrl, ); final videoExt = _stringValue(video['ext']) ?? 'mp4'; final audioExt = _stringValue(audio['ext']) ?? 'webm'; final videoMimeType = _formatMimeType( video, isVideo: true, ); final audioMimeType = _formatMimeType( audio, isVideo: false, ); debugPrint( '[HD-WINDOWS][17.14] paire embedded: ' '${width}x$height ' '${fps > 0 ? '${fps}fps ' : ''}' 'video=$videoFormatId/$videoCodec ' 'audio=$audioFormatId/$audioCodec', ); debugPrint( '[HD-WINDOWS][17.14] embedded GVS: ' 'videoClient=${videoClient ?? 'inconnu'} ' 'videoPOT=${_hasPoToken(videoUrl)} ' 'audioClient=${audioClient ?? 'inconnu'} ' 'audioPOT=${_hasPoToken(audioUrl)}', ); return { 'videoUrl': videoUrl, 'audioUrl': audioUrl, 'width': width, 'height': height, 'fps': fps, 'videoCodec': videoCodec, 'audioCodec': audioCodec, 'videoFormatId': videoFormatId, 'audioFormatId': audioFormatId, 'audioLanguage': audioLanguage, 'audioTrackName': audioTrackName, 'preferredAudioMatched': preferredAudioMatched, 'originalAudioSelected': originalAudioSelected, 'availableAudioTracks': availableAudioTracks, 'videoHeaders': videoHeaders, 'audioHeaders': audioHeaders, 'httpHeaders': combinedHeaders, 'youtubeClient': videoClient, 'audioYoutubeClient': audioClient, 'videoLength': videoLength, 'audioLength': audioLength, 'videoExt': videoExt, 'audioExt': audioExt, 'videoMimeType': videoMimeType, 'audioMimeType': audioMimeType, 'availableHeights': availableHeights, 'maxAvailableHeight': maxAvailableHeight, 'ytDlpPath': ytDlp.path, 'denoPath': deno.path, 'watchUrl': watchUrl, 'source': 'web_embedded qualités strictes 17.21', }; } /// Tente d'obtenir un flux HLS muxé (vidéo + audio) via le client /// `web_safari`. /// /// yt-dlp documente ce client comme pouvant exposer des formats HLS /// pré-muxés jusqu'à 1080p. Cela évite complètement la paire DASH /// vidéo/audio séparée et donc le problème GVS/PO Token observé sur mweb. /// /// Depuis 2026.07 YouTube ne renvoie ces HLS que pour certaines sessions. /// Cette méthode est donc opportuniste : si aucun HLS n'est disponible, /// elle retourne null et le pipeline mweb existant prend le relais. Future?> getMuxedHlsStream({ required String videoId, required int targetHeight, String preferredLanguage = 'auto', String originalLanguageHint = 'auto', }) async { if (!Platform.isWindows) { return null; } final tools = _resolveBundledTools(); final ytDlp = tools.$1; final deno = tools.$2; if (!await ytDlp.exists() || !await deno.exists()) { return null; } final effectiveTarget = targetHeight <= 0 ? 1080 : targetHeight.clamp(720, 1080); final watchUrl = Uri.https( 'www.youtube.com', '/watch', { 'v': videoId, }, ).toString(); final arguments = [ '--ignore-config', '--no-playlist', '--no-warnings', '--encoding', 'utf-8', '--socket-timeout', '20', '--retries', '2', '--js-runtimes', 'deno:${deno.path}', '--extractor-args', 'youtube:player_client=web_safari', '--skip-download', '--dump-single-json', watchUrl, ]; debugPrint( '[HD-WINDOWS][17.13] tentative HLS muxé web_safari ' 'cible=${effectiveTarget}p', ); ProcessResult result; try { result = await Process.run( ytDlp.path, arguments, workingDirectory: ytDlp.parent.path, runInShell: false, stdoutEncoding: utf8, stderrEncoding: utf8, ).timeout( const Duration(seconds: 45), ); } on Object catch (error) { debugPrint( '[HD-WINDOWS][17.13] web_safari indisponible: $error', ); return null; } if (result.exitCode != 0) { debugPrint( '[HD-WINDOWS][17.13] web_safari sans HLS exploitable: ' '${_compactError(result.stderr?.toString() ?? '')}', ); return null; } final stdout = result.stdout?.toString().trim() ?? ''; if (stdout.isEmpty) { return null; } dynamic decoded; try { decoded = jsonDecode(stdout); } on Object catch (_) { return null; } if (decoded is! Map) { return null; } final rawFormats = decoded['formats']; if (rawFormats is! List) { return null; } var candidates = rawFormats .whereType() .map( (format) => format.map( (key, value) => MapEntry( key.toString(), value, ), ), ) .where((format) { final url = _stringValue(format['url']); final height = _intValue(format['height']); final protocol = (_stringValue(format['protocol']) ?? '') .toLowerCase(); final videoCodec = _stringValue(format['vcodec']); final audioCodec = _stringValue(format['acodec']); return url != null && url.isNotEmpty && height >= 720 && height <= effectiveTarget && protocol.contains('m3u8') && videoCodec != null && videoCodec != 'none' && audioCodec != null && audioCodec != 'none'; }) .toList(); candidates = _preferAudioLanguageCandidates( candidates, preferredLanguage: preferredLanguage, originalLanguageHint: originalLanguageHint, ); if (candidates.isEmpty) { debugPrint( '[HD-WINDOWS][17.13] aucun HLS muxé web_safari ' '720-${effectiveTarget}p disponible', ); return null; } candidates.sort((a, b) { var result = _intValue(a['height']).compareTo( _intValue(b['height']), ); if (result != 0) { return result; } result = _intValue(a['fps']).compareTo( _intValue(b['fps']), ); if (result != 0) { return result; } return _doubleValue(a['tbr']).compareTo( _doubleValue(b['tbr']), ); }); final selected = candidates.last; final url = _stringValue(selected['url']); if (url == null || url.isEmpty) { return null; } final headers = {}; _mergeHeaders( headers, decoded['http_headers'], overwrite: false, ); _mergeHeaders( headers, selected['http_headers'], overwrite: true, ); final width = _intValue(selected['width']); final height = _intValue(selected['height']); final fps = _intValue(selected['fps']); final videoCodec = _stringValue(selected['vcodec']) ?? 'inconnu'; final audioCodec = _stringValue(selected['acodec']) ?? 'inconnu'; final formatId = _stringValue(selected['format_id']) ?? 'inconnu'; debugPrint( '[HD-WINDOWS][17.13] HLS muxé trouvé: ' '${width}x$height ' '${fps > 0 ? '${fps}fps ' : ''}' 'format=$formatId ' 'video=$videoCodec ' 'audio=$audioCodec', ); return { 'url': url, 'width': width, 'height': height, 'fps': fps, 'videoCodec': videoCodec, 'audioCodec': audioCodec, 'formatId': formatId, 'httpHeaders': headers, 'source': 'web_safari HLS muxé', }; } Future?> getHdStream({ required String videoId, required int targetHeight, String preferredLanguage = 'auto', String originalLanguageHint = 'auto', }) async { if (!Platform.isWindows) { return null; } final tools = _resolveBundledTools(); final ytDlp = tools.$1; final deno = tools.$2; if (!await ytDlp.exists()) { throw StateError( 'yt-dlp.exe absent. Lance PREPARER_HD_WINDOWS.ps1 ' 'puis reconstruis l’application Windows.', ); } if (!await deno.exists()) { throw StateError( 'deno.exe absent. Lance PREPARER_HD_WINDOWS.ps1 ' 'puis reconstruis l’application Windows.', ); } final provider = _resolvePoTokenProvider(); if (!await provider.pluginArchive.exists()) { throw StateError( 'Plugin PO Token absent. Lance ' 'PREPARER_PO_TOKEN_WINDOWS.ps1 une fois.', ); } if (!await provider.serverDirectory.exists()) { throw StateError( 'Provider BgUtils absent. Lance ' 'PREPARER_PO_TOKEN_WINDOWS.ps1 une fois.', ); } final watchUrl = Uri.https( 'www.youtube.com', '/watch', { 'v': videoId, }, ).toString(); final arguments = [ '--ignore-config', '--no-playlist', '--no-warnings', '--encoding', 'utf-8', '--socket-timeout', '20', '--retries', '2', '--js-runtimes', 'deno:${deno.path}', '--plugin-dirs', provider.pluginDirectory.path, '--extractor-args', 'youtube:player_client=mweb;fetch_pot=auto;pot_trace=true', '--extractor-args', 'youtubepot-bgutilscript:server_home=${provider.serverDirectory.path}', '--skip-download', '--dump-single-json', watchUrl, ]; debugPrint( '[HD-WINDOWS] yt-dlp extraction locale ' 'cible=$targetHeight', ); final result = await Process.run( ytDlp.path, arguments, workingDirectory: ytDlp.parent.path, runInShell: false, stdoutEncoding: utf8, stderrEncoding: utf8, ).timeout( const Duration(seconds: 45), ); if (result.exitCode != 0) { final stderr = _compactError( result.stderr?.toString() ?? '', ); throw StateError( 'yt-dlp code=${result.exitCode}' '${stderr.isEmpty ? '' : ': $stderr'}', ); } final stdout = result.stdout?.toString().trim() ?? ''; if (stdout.isEmpty) { throw StateError( 'yt-dlp n’a retourné aucune donnée.', ); } final dynamic decoded; try { decoded = jsonDecode(stdout); } catch (error) { throw FormatException( 'Réponse yt-dlp JSON invalide: $error', ); } if (decoded is! Map) { throw const FormatException( 'Réponse yt-dlp inattendue.', ); } final rawFormats = decoded['formats']; if (rawFormats is! List) { throw const FormatException( 'Liste des formats absente.', ); } final formats = rawFormats .whereType() .map( (format) => format.map( (key, value) => MapEntry( key.toString(), value, ), ), ) .toList(growable: false); final availableAudioTracks = _collectAvailableAudioTracks(formats); final effectiveTarget = targetHeight <= 0 ? 2160 : targetHeight; final video = _selectVideo( formats, effectiveTarget, ); final audio = _selectAudio( formats, preferredLanguage: preferredLanguage, originalLanguageHint: originalLanguageHint, ); if (video == null) { throw StateError( 'Aucun flux vidéo direct >=720p ' 'jusqu’à ${effectiveTarget}p.', ); } if (audio == null) { throw StateError( 'Aucun flux audio direct exploitable.', ); } final videoUrl = video['url']; final audioUrl = audio['url']; if (videoUrl is! String || videoUrl.isEmpty || audioUrl is! String || audioUrl.isEmpty) { throw StateError( 'URL vidéo/audio directe absente.', ); } final height = _intValue(video['height']); final width = _intValue(video['width']); final fps = _intValue(video['fps']); final videoCodec = _stringValue(video['vcodec']) ?? 'inconnu'; final audioCodec = _stringValue(audio['acodec']) ?? 'inconnu'; final audioLanguage = _audioLanguageCode(audio); final audioTrackName = _audioTrackLabel(audio); final preferred = _normalizeLanguageCode(preferredLanguage); final preferredAudioMatched = preferred != 'auto' && preferred != 'original' && _matchesAudioLanguage(audio, preferred); final originalAudioSelected = _isOriginalAudioFormat(audio); final videoHeaders = {}; final audioHeaders = {}; _mergeHeaders( videoHeaders, decoded['http_headers'], overwrite: false, ); _mergeHeaders( videoHeaders, video['http_headers'], overwrite: true, ); _mergeHeaders( audioHeaders, decoded['http_headers'], overwrite: false, ); _mergeHeaders( audioHeaders, audio['http_headers'], overwrite: true, ); // Conservé pour compatibilité avec les étapes précédentes. final headers = { ...videoHeaders, }; _mergeHeaders( headers, audioHeaders, overwrite: false, ); final videoLength = _formatLength( video, videoUrl, ); final audioLength = _formatLength( audio, audioUrl, ); final videoChunkSize = _formatChunkSize(video); final audioChunkSize = _formatChunkSize(audio); final videoMimeType = _formatMimeType( video, isVideo: true, ); final audioMimeType = _formatMimeType( audio, isVideo: false, ); final selectedClient = _youtubeClientFromUrl( videoUrl, ); final audioClient = _youtubeClientFromUrl( audioUrl, ); final videoHasPoToken = _hasPoToken(videoUrl); final audioHasPoToken = _hasPoToken(audioUrl); debugPrint( '[HD-WINDOWS][17.9] extraction mweb + PO Token provider local', ); debugPrint( '[HD-WINDOWS][17.9] provider plugin=${provider.pluginArchive.path}', ); debugPrint( '[HD-WINDOWS] flux sélectionné: ' '${width}x$height ' '${fps > 0 ? '${fps}fps ' : ''}' 'video=$videoCodec ' 'audio=$audioCodec ' 'client=${selectedClient ?? 'inconnu'}', ); debugPrint( '[HD-WINDOWS][17.11] GVS: ' 'videoClient=${selectedClient ?? 'inconnu'} ' 'videoPOT=$videoHasPoToken ' 'audioClient=${audioClient ?? 'inconnu'} ' 'audioPOT=$audioHasPoToken', ); debugPrint( '[HD-WINDOWS] headers sélectionnés: ' '${headers.keys.join(', ')}', ); debugPrint( '[HD-WINDOWS][17.10] transport yt-dlp: ' 'video=${videoLength}o chunk=${videoChunkSize}o ' 'audio=${audioLength}o chunk=${audioChunkSize}o', ); return { 'videoUrl': videoUrl, 'audioUrl': audioUrl, 'width': width, 'height': height, 'fps': fps, 'videoCodec': videoCodec, 'audioCodec': audioCodec, 'audioLanguage': audioLanguage, 'audioTrackName': audioTrackName, 'preferredAudioMatched': preferredAudioMatched, 'originalAudioSelected': originalAudioSelected, 'availableAudioTracks': availableAudioTracks, 'targetHeight': targetHeight, 'httpHeaders': headers, 'videoHeaders': videoHeaders, 'audioHeaders': audioHeaders, 'videoLength': videoLength, 'audioLength': audioLength, 'videoChunkSize': videoChunkSize, 'audioChunkSize': audioChunkSize, 'videoMimeType': videoMimeType, 'audioMimeType': audioMimeType, 'youtubeClient': selectedClient, 'audioYoutubeClient': audioClient, 'videoHasPoToken': videoHasPoToken, 'audioHasPoToken': audioHasPoToken, 'ytDlpPath': ytDlp.path, 'source': 'yt-dlp mweb + PO Token + relais piloté par yt-dlp 17.12', }; } int _formatLength( Map format, String url, ) { final exact = _intValue(format['filesize']); if (exact > 0) { return exact; } final approximate = _intValue(format['filesize_approx']); if (approximate > 0) { return approximate; } final uri = Uri.tryParse(url); final clen = uri?.queryParameters['clen']; if (clen != null) { final parsed = int.tryParse(clen); if (parsed != null && parsed > 0) { return parsed; } } return 0; } int _formatChunkSize( Map format, ) { final options = format['downloader_options']; if (options is Map) { final raw = options['http_chunk_size']; final parsed = _intValue(raw); if (parsed > 0) { // yt-dlp documente actuellement 10 MiB pour YouTube. // On ne dépasse jamais cette valeur dans notre relais. return parsed.clamp( 256 * 1024, 10 * 1024 * 1024, ); } } return 10 * 1024 * 1024; } String _formatMimeType( Map format, { required bool isVideo, }) { final explicit = _stringValue(format['mime_type']); if (explicit != null && explicit.contains('/')) { return explicit; } final ext = (_stringValue(format['ext']) ?? '') .toLowerCase(); if (isVideo) { if (ext == 'webm') { return 'video/webm'; } return 'video/mp4'; } if (ext == 'webm') { return 'audio/webm'; } return 'audio/mp4'; } bool _hasPoToken(String url) { final uri = Uri.tryParse(url); if (uri == null) { return false; } final value = uri.queryParameters['pot']; return value != null && value.trim().isNotEmpty; } String? _youtubeClientFromUrl(String url) { final uri = Uri.tryParse(url); if (uri == null) { return null; } final client = uri.queryParameters['c']; if (client == null || client.trim().isEmpty) { return null; } return client.trim(); } /// Reproduit côté libmpv les headers réseau essentiels utilisés par /// l'intégration yt-dlp de mpv : User-Agent, Referer, Cookie et /// X-Forwarded-For. /// Applique globalement à libmpv les headers exacts fournis par yt-dlp. /// /// C'est volontairement GLOBAL sur cette instance mpv : /// - Media(videoUrl, httpHeaders: ...) couvre la vidéo ; /// - AudioTrack.uri(audioUrl) ne permet pas de passer des headers ; /// - les propriétés mpv ci-dessous couvrent donc aussi l'audio externe. Future applyHttpHeaders( NativePlayer player, Map headers, ) async { String? userAgent; for (final entry in headers.entries) { if (entry.key.toLowerCase() == 'user-agent') { userAgent = entry.value; break; } } // IMPORTANT : // NativePlayer.setProperty attend le nom de propriété mpv lui-même. // Il ne faut PAS préfixer avec "options/" ici. if (userAgent != null && userAgent.isNotEmpty) { await player.setProperty( 'user-agent', userAgent, ); } else { await player.setProperty( 'user-agent', '', ); } // mpv traite http-header-fields comme une string-list séparée par // des virgules. Une virgule présente DANS une valeur de header doit // donc être échappée, sinon la liste est coupée au mauvais endroit. // // On transmet tous les headers yt-dlp sauf User-Agent, qui possède // sa propriété mpv dédiée. final fields = []; for (final entry in headers.entries) { if (entry.key.toLowerCase() == 'user-agent') { continue; } final key = entry.key.trim(); final value = entry.value.trim(); if (key.isEmpty || value.isEmpty) { continue; } final escapedValue = value .replaceAll(r'\', r'\\') .replaceAll(',', r'\,'); fields.add( '$key: $escapedValue', ); } await player.setProperty( 'http-header-fields', fields.join(','), ); debugPrint( '[HD-WINDOWS][17.7] headers globaux mpv appliqués: ' 'UA=${userAgent != null && userAgent.isNotEmpty}, ' 'champs=${fields.length}', ); if (fields.isNotEmpty) { debugPrint( '[HD-WINDOWS][17.7] noms headers mpv: ' '${headers.keys.where((key) => key.toLowerCase() != 'user-agent').join(', ')}', ); } } Map? _selectVideo( List> formats, int targetHeight, ) { final candidates = formats .where(_isVideoOnly) .where((format) { final height = _intValue(format['height']); return height >= 720 && height <= targetHeight && _hasHttpUrl(format); }) .toList(); if (candidates.isEmpty) { return null; } candidates.sort( (a, b) => _compareVideo( a, b, targetHeight, ), ); return candidates.last; } int _compareVideo( Map a, Map b, int targetHeight, ) { var result = _intValue(a['height']).compareTo( _intValue(b['height']), ); if (result != 0) { return result; } result = _videoCodecScore( a, targetHeight, ).compareTo( _videoCodecScore( b, targetHeight, ), ); if (result != 0) { return result; } result = _intValue(a['fps']).compareTo( _intValue(b['fps']), ); if (result != 0) { return result; } result = _protocolScore(a).compareTo( _protocolScore(b), ); if (result != 0) { return result; } return _doubleValue(a['tbr']).compareTo( _doubleValue(b['tbr']), ); } Map? _selectEmbeddedFastVideo( List> formats, int targetHeight, ) { final candidates = formats .where(_isVideoOnly) .where(_hasHttpUrl) .where((format) { final height = _intValue(format['height']); return height >= 720 && height <= targetHeight; }) .toList(); if (candidates.isEmpty) { return null; } // On garde d'abord la meilleure résolution réellement disponible. final bestHeight = candidates .map((format) => _intValue(format['height'])) .reduce((a, b) => a > b ? a : b); final atBestHeight = candidates .where( (format) => _intValue(format['height']) == bestHeight, ) .toList(); // Puis le meilleur FPS à cette résolution. final bestFps = atBestHeight .map((format) => _intValue(format['fps'])) .reduce((a, b) => a > b ? a : b); final atBestFps = atBestHeight .where( (format) => _intValue(format['fps']) == bestFps, ) .toList(); // Pour le mode "téléchargement complet", le temps de démarrage dépend // directement du nombre d'octets à récupérer. // // Les formats AV1 YouTube sont nettement plus compacts sur les vidéos // testées. On les privilégie donc, puis VP9, puis AVC. À codec égal, // on prend le plus petit fichier connu. atBestFps.sort((a, b) { int efficiencyScore( Map format, ) { final codec = (_stringValue(format['vcodec']) ?? '') .toLowerCase(); if (codec.contains('av01') || codec.contains('av1')) { return 30; } if (codec.contains('vp9') || codec.contains('vp09')) { return 20; } if (codec.contains('avc') || codec.contains('h264') || codec.contains('h.264')) { return 10; } return 0; } var result = efficiencyScore(a).compareTo( efficiencyScore(b), ); if (result != 0) { return result; } int knownLength( Map format, ) { final exact = _intValue(format['filesize']); if (exact > 0) { return exact; } return _intValue( format['filesize_approx'], ); } final aLength = knownLength(a); final bLength = knownLength(b); // candidates.sort(...); on retourne le dernier. // Un fichier plus petit doit donc être considéré "meilleur". if (aLength > 0 && bLength > 0) { return bLength.compareTo(aLength); } if (aLength > 0) { return 1; } if (bLength > 0) { return -1; } // À défaut de taille, bitrate plus bas = téléchargement plus court. return _doubleValue(b['tbr']).compareTo( _doubleValue(a['tbr']), ); }); final selected = atBestFps.last; debugPrint( '[HD-WINDOWS][17.17] format rapide sélectionné: ' '${_stringValue(selected['format_id']) ?? '?'} ' '${_intValue(selected['width'])}x' '${_intValue(selected['height'])} ' '${_intValue(selected['fps'])}fps ' '${_stringValue(selected['vcodec']) ?? '?'} ' 'taille≈${_intValue(selected['filesize']) > 0 ? _intValue(selected['filesize']) : _intValue(selected['filesize_approx'])}o', ); return selected; } Map? _selectEmbeddedAudio( List> formats, { String preferredLanguage = 'auto', String originalLanguageHint = 'auto', }) { var candidates = formats .where(_isAudioOnly) .where(_hasHttpUrl) .toList(); candidates = _preferAudioLanguageCandidates( candidates, preferredLanguage: preferredLanguage, originalLanguageHint: originalLanguageHint, ); if (candidates.isEmpty) { return null; } candidates.sort((a, b) { int embeddedScore(Map format) { final id = _stringValue(format['format_id']) ?? ''; // 251 a été téléchargé entièrement pendant le test utilisateur. if (id == '251' || id.startsWith('251-')) { return 100; } if (id == '140' || id.startsWith('140-')) { return 90; } final codec = (_stringValue(format['acodec']) ?? '') .toLowerCase(); if (codec.contains('opus')) { return 80; } if (codec.contains('mp4a') || codec.contains('aac')) { return 70; } return 10; } var result = embeddedScore(a).compareTo( embeddedScore(b), ); if (result != 0) { return result; } return _doubleValue(a['abr']).compareTo( _doubleValue(b['abr']), ); }); return candidates.last; } Map? _selectAudio( List> formats, { String preferredLanguage = 'auto', String originalLanguageHint = 'auto', }) { var candidates = formats .where(_isAudioOnly) .where(_hasHttpUrl) .toList(); candidates = _preferAudioLanguageCandidates( candidates, preferredLanguage: preferredLanguage, originalLanguageHint: originalLanguageHint, ); if (candidates.isEmpty) { return null; } candidates.sort((a, b) { var result = _audioCodecScore(a).compareTo( _audioCodecScore(b), ); if (result != 0) { return result; } result = _protocolScore(a).compareTo( _protocolScore(b), ); if (result != 0) { return result; } return _doubleValue(a['abr']).compareTo( _doubleValue(b['abr']), ); }); return candidates.last; } List> _preferAudioLanguageCandidates( List> candidates, { required String preferredLanguage, required String originalLanguageHint, }) { if (candidates.isEmpty) { return candidates; } final preferred = _normalizeLanguageCode(preferredLanguage); // Une langue explicitement choisie par l'utilisateur reste prioritaire. if (preferred != 'auto') { final matches = candidates .where((format) => _matchesAudioLanguage(format, preferred)) .toList(); if (matches.isNotEmpty) { debugPrint( '[HD-WINDOWS][18.7.25] audio langue préférée=$preferred ' 'candidats=${matches.length}', ); return matches; } } // En mode Auto, toujours préférer la piste marquée "original" par // YouTube/yt-dlp. Les variantes 251-0, 251-1, etc. ne sont plus choisies // uniquement parce qu'elles utilisent Opus. final originals = candidates .where(_isOriginalAudioFormat) .toList(); if (originals.isNotEmpty) { debugPrint( '[HD-WINDOWS][18.7.25] piste audio originale détectée ' 'candidats=${originals.length}', ); return originals; } // Certains clients YouTube ne transmettent pas le mot "original". Dans // ce cas, le script du titre/auteur sert uniquement de dernier indice. final hint = _normalizeLanguageCode(originalLanguageHint); if (hint != 'auto') { final matches = candidates .where((format) => _matchesAudioLanguage(format, hint)) .toList(); if (matches.isNotEmpty) { debugPrint( '[HD-WINDOWS][18.7.25] audio original estimé=$hint ' 'candidats=${matches.length}', ); return matches; } } return candidates; } bool _isOriginalAudioFormat(Map format) { final text = [ _stringValue(format['format']) ?? '', _stringValue(format['format_note']) ?? '', _stringValue(format['audio_track']) ?? '', _stringValue(format['audio_track_name']) ?? '', ].join(' ').toLowerCase(); return text.contains('original') || text.contains('الأصلية') || text.contains('اصلي') || text.contains('أصلي'); } bool _matchesAudioLanguage( Map format, String language, ) { final wanted = _normalizeLanguageCode(language); if (wanted == 'auto') { return false; } final direct = _normalizeLanguageCode( _stringValue(format['language']) ?? '', ); if (direct == wanted) { return true; } final text = [ _stringValue(format['format']) ?? '', _stringValue(format['format_note']) ?? '', _stringValue(format['audio_track']) ?? '', _stringValue(format['audio_track_name']) ?? '', ].join(' ').toLowerCase(); switch (wanted) { case 'ar': return text.contains('[ar]') || text.contains('arabic') || text.contains('arabe') || text.contains('العربية') || text.contains('عربي'); case 'en': return text.contains('[en]') || text.contains('english') || text.contains('anglais'); case 'fr': return text.contains('[fr]') || text.contains('français') || text.contains('francais') || text.contains('french'); case 'de': return text.contains('[de]') || text.contains('deutsch') || text.contains('german'); case 'es': return text.contains('[es]') || text.contains('español') || text.contains('espanol') || text.contains('spanish'); default: return false; } } String _normalizeLanguageCode(String language) { final raw = language.trim().toLowerCase(); if (raw.isEmpty || raw == 'auto' || raw == 'und') { return 'auto'; } final normalized = raw.replaceAll('_', '-'); final separator = normalized.indexOf('-'); return separator > 0 ? normalized.substring(0, separator) : normalized; } List> _collectAvailableAudioTracks( List> formats, ) { final byKey = >{}; for (final format in formats) { if (!_isAudioOnly(format) || !_hasHttpUrl(format)) { continue; } final original = _isOriginalAudioFormat(format); final language = _audioLanguageCode(format); final key = original ? 'original' : language != 'und' ? language : ''; if (key.isEmpty) { continue; } byKey.putIfAbsent( key, () => { 'key': key, 'language': language, 'label': _audioTrackLabel(format), 'original': original, }, ); } return byKey.values.toList(growable: false); } String _audioLanguageCode(Map format) { final direct = _normalizeLanguageCode( _stringValue(format['language']) ?? '', ); if (direct != 'auto') { return direct; } for (final code in const ['ar', 'en', 'fr', 'de', 'es']) { if (_matchesAudioLanguage(format, code)) { return code; } } return 'und'; } String _audioTrackLabel(Map format) { final candidates = [ _stringValue(format['audio_track_name']), _stringValue(format['audio_track']), _stringValue(format['format_note']), ]; for (final value in candidates) { final text = value?.trim() ?? ''; if (text.isNotEmpty) { return text; } } final language = _audioLanguageCode(format); return language == 'und' ? 'Piste audio' : language.toUpperCase(); } bool _isVideoOnly(Map format) { final videoCodec = _stringValue(format['vcodec']); final audioCodec = _stringValue(format['acodec']); return videoCodec != null && videoCodec != 'none' && (audioCodec == null || audioCodec == 'none'); } bool _isAudioOnly(Map format) { final videoCodec = _stringValue(format['vcodec']); final audioCodec = _stringValue(format['acodec']); return audioCodec != null && audioCodec != 'none' && (videoCodec == null || videoCodec == 'none'); } bool _hasHttpUrl(Map format) { final url = _stringValue(format['url']); if (url == null || url.isEmpty) { return false; } final uri = Uri.tryParse(url); return uri != null && (uri.scheme == 'https' || uri.scheme == 'http'); } int _videoCodecScore( Map format, int targetHeight, ) { final codec = (_stringValue(format['vcodec']) ?? '') .toLowerCase(); final isAvc = codec.contains('avc') || codec.contains('h264') || codec.contains('h.264'); final isVp9 = codec.contains('vp9') || codec.contains('vp09'); final isAv1 = codec.contains('av01') || codec.contains('av1'); if (targetHeight <= 1080) { if (isAvc) { return 40; } if (isVp9) { return 30; } if (isAv1) { return 20; } return 10; } if (isVp9) { return 40; } if (isAv1) { return 30; } if (isAvc) { return 20; } return 10; } int _audioCodecScore( Map format, ) { final codec = (_stringValue(format['acodec']) ?? '') .toLowerCase(); if (codec.contains('mp4a') || codec.contains('aac')) { return 40; } if (codec.contains('opus')) { return 30; } return 10; } int _protocolScore( Map format, ) { final protocol = (_stringValue(format['protocol']) ?? '') .toLowerCase(); if (protocol == 'https' || protocol == 'http') { return 40; } if (protocol.contains('dash')) { return 30; } if (protocol.contains('m3u8')) { return 20; } return 10; } void _mergeHeaders( Map output, dynamic source, { required bool overwrite, }) { if (source is! Map) { return; } for (final entry in source.entries) { final key = entry.key.toString().trim(); final value = entry.value?.toString().trim() ?? ''; if (key.isEmpty || value.isEmpty) { continue; } if (overwrite || !output.containsKey(key)) { output[key] = value; } } } _WindowsPoTokenProvider _resolvePoTokenProvider() { final localAppData = Platform.environment['LOCALAPPDATA']; if (localAppData == null || localAppData.trim().isEmpty) { throw StateError( 'LOCALAPPDATA est indisponible sous Windows.', ); } final root = Directory( '$localAppData' '${Platform.pathSeparator}YouTubePersonal' '${Platform.pathSeparator}windows_hd', ); final pluginDirectory = Directory( '${root.path}' '${Platform.pathSeparator}yt-dlp-plugins', ); final pluginArchive = File( '${pluginDirectory.path}' '${Platform.pathSeparator}bgutil-ytdlp-pot-provider.zip', ); final serverDirectory = Directory( '${root.path}' '${Platform.pathSeparator}bgutil-provider' '${Platform.pathSeparator}server', ); return _WindowsPoTokenProvider( pluginDirectory: pluginDirectory, pluginArchive: pluginArchive, serverDirectory: serverDirectory, ); } (File, File) _resolveBundledTools() { final executableDirectory = File(Platform.resolvedExecutable).parent; final bundledDirectory = Directory( '${executableDirectory.path}' '${Platform.pathSeparator}data' '${Platform.pathSeparator}flutter_assets' '${Platform.pathSeparator}assets' '${Platform.pathSeparator}windows_hd', ); final bundledYtDlp = File( '${bundledDirectory.path}' '${Platform.pathSeparator}yt-dlp.exe', ); final bundledDeno = File( '${bundledDirectory.path}' '${Platform.pathSeparator}deno.exe', ); if (bundledYtDlp.existsSync() && bundledDeno.existsSync()) { return (bundledYtDlp, bundledDeno); } final projectDirectory = Directory( '${Directory.current.path}' '${Platform.pathSeparator}assets' '${Platform.pathSeparator}windows_hd', ); return ( File( '${projectDirectory.path}' '${Platform.pathSeparator}yt-dlp.exe', ), File( '${projectDirectory.path}' '${Platform.pathSeparator}deno.exe', ), ); } String? _stringValue(dynamic value) { if (value == null) { return null; } final text = value.toString().trim(); return text.isEmpty ? null : text; } int _intValue(dynamic value) { if (value is int) { return value; } if (value is num) { return value.round(); } return int.tryParse( value?.toString() ?? '', ) ?? 0; } double _doubleValue(dynamic value) { if (value is num) { return value.toDouble(); } return double.tryParse( value?.toString() ?? '', ) ?? 0; } String _compactError(String value) { final normalized = value .replaceAll(RegExp(r'\s+'), ' ') .trim(); if (normalized.length <= 500) { return normalized; } return '${normalized.substring(0, 500)}…'; } } class _WindowsPoTokenProvider { const _WindowsPoTokenProvider({ required this.pluginDirectory, required this.pluginArchive, required this.serverDirectory, }); final Directory pluginDirectory; final File pluginArchive; final Directory serverDirectory; }