1973 lines
46 KiB
Dart
1973 lines
46 KiB
Dart
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;
|
||
}
|