Files

1973 lines
46 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Map<String, dynamic>?> 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',
<String, String>{
'v': videoId,
},
).toString();
final arguments = <String>[
'--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<String, dynamic>) {
return null;
}
final rawFormats = decoded['formats'];
if (rawFormats is! List) {
return null;
}
final formats = rawFormats
.whereType<Map>()
.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 = <String, String>{};
final audioHeaders = <String, String>{};
_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 = <String, String>{
...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 <String, dynamic>{
'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<Map<String, dynamic>?> 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',
<String, String>{
'v': videoId,
},
).toString();
final arguments = <String>[
'--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<String, dynamic>) {
return null;
}
final rawFormats = decoded['formats'];
if (rawFormats is! List) {
return null;
}
var candidates = rawFormats
.whereType<Map>()
.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 = <String, String>{};
_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 <String, dynamic>{
'url': url,
'width': width,
'height': height,
'fps': fps,
'videoCodec': videoCodec,
'audioCodec': audioCodec,
'formatId': formatId,
'httpHeaders': headers,
'source': 'web_safari HLS muxé',
};
}
Future<Map<String, dynamic>?> 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',
<String, String>{
'v': videoId,
},
).toString();
final arguments = <String>[
'--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<String, dynamic>) {
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>()
.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 = <String, String>{};
final audioHeaders = <String, String>{};
_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 = <String, String>{
...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 <String, dynamic>{
'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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<void> applyHttpHeaders(
NativePlayer player,
Map<String, String> 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 = <String>[];
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<String, dynamic>? _selectVideo(
List<Map<String, dynamic>> 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<String, dynamic> a,
Map<String, dynamic> 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<String, dynamic>? _selectEmbeddedFastVideo(
List<Map<String, dynamic>> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic>? _selectEmbeddedAudio(
List<Map<String, dynamic>> 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<String, dynamic> 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<String, dynamic>? _selectAudio(
List<Map<String, dynamic>> 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<Map<String, dynamic>> _preferAudioLanguageCandidates(
List<Map<String, dynamic>> 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<String, dynamic> format) {
final text = <String>[
_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<String, dynamic> 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 = <String>[
_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<Map<String, dynamic>> _collectAvailableAudioTracks(
List<Map<String, dynamic>> formats,
) {
final byKey = <String, Map<String, dynamic>>{};
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,
() => <String, dynamic>{
'key': key,
'language': language,
'label': _audioTrackLabel(format),
'original': original,
},
);
}
return byKey.values.toList(growable: false);
}
String _audioLanguageCode(Map<String, dynamic> format) {
final direct = _normalizeLanguageCode(
_stringValue(format['language']) ?? '',
);
if (direct != 'auto') {
return direct;
}
for (final code in const <String>['ar', 'en', 'fr', 'de', 'es']) {
if (_matchesAudioLanguage(format, code)) {
return code;
}
}
return 'und';
}
String _audioTrackLabel(Map<String, dynamic> format) {
final candidates = <String?>[
_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<String, dynamic> format) {
final videoCodec =
_stringValue(format['vcodec']);
final audioCodec =
_stringValue(format['acodec']);
return videoCodec != null &&
videoCodec != 'none' &&
(audioCodec == null || audioCodec == 'none');
}
bool _isAudioOnly(Map<String, dynamic> format) {
final videoCodec =
_stringValue(format['vcodec']);
final audioCodec =
_stringValue(format['acodec']);
return audioCodec != null &&
audioCodec != 'none' &&
(videoCodec == null || videoCodec == 'none');
}
bool _hasHttpUrl(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, String> 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;
}