import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'media_cache_settings_service.dart'; typedef WindowsDownloadProgress = void Function( String track, double percent, ); class WindowsEmbeddedLocalMedia { const WindowsEmbeddedLocalMedia({ required this.videoUri, required this.audioUri, required this.videoPath, required this.audioPath, required this.videoFromCache, required this.audioFromCache, }); final String videoUri; final String audioUri; final String videoPath; final String audioPath; final bool videoFromCache; final bool audioFromCache; } /// Mode Windows fiable et plus rapide. /// /// Le transport reste exactement celui validé en 17.16 : yt-dlp télécharge /// complètement les pistes, puis media_kit ouvre uniquement des fichiers /// locaux. /// /// Optimisations 17.17 : /// - cache persistant vérifié sous LOCALAPPDATA ; /// - un fichier déjà téléchargé n'est jamais repris du réseau ; /// - vidéo & audio manquants sont téléchargés en parallèle ; /// - les anciennes vidéos sont automatiquement limitées aux 5 plus récentes ; /// - callback de progression pour que l'UI affiche l'avancement. class WindowsEmbeddedDownloadService { final Set _activeProcesses = {}; Future download({ required String videoId, required String ytDlpPath, required String denoPath, required String watchUrl, required String videoFormatId, required String audioFormatId, required String videoExt, required String audioExt, required int expectedVideoLength, required int expectedAudioLength, WindowsDownloadProgress? onProgress, }) async { if (!Platform.isWindows) { throw UnsupportedError( 'Le téléchargement web_embedded est réservé à Windows.', ); } await stop(); final ytDlp = File(ytDlpPath); final deno = File(denoPath); if (!await ytDlp.exists()) { throw StateError( 'yt-dlp.exe introuvable : $ytDlpPath', ); } if (!await deno.exists()) { throw StateError( 'deno.exe introuvable : $denoPath', ); } final cacheSettings = MediaCacheSettingsService.instance; final cacheRoot = await cacheSettings.resolveCacheRoot(); final safeVideoId = _safeName(videoId); await cacheSettings.enforceLimit(keepVideoId: safeVideoId); final videoDirectory = Directory( '${cacheRoot.path}' '${Platform.pathSeparator}' '$safeVideoId', ); await videoDirectory.create( recursive: true, ); final videoFile = File( '${videoDirectory.path}' '${Platform.pathSeparator}' 'video_${_safeName(videoFormatId)}.$videoExt', ); final audioFile = File( '${videoDirectory.path}' '${Platform.pathSeparator}' 'audio_${_safeName(audioFormatId)}.$audioExt', ); final videoHit = await _isValidCacheFile( videoFile, expectedVideoLength, ); final audioHit = await _isValidCacheFile( audioFile, expectedAudioLength, ); debugPrint( '[HD-CACHE][17.17] ' 'video=${videoHit ? 'HIT' : 'MISS'} ' 'audio=${audioHit ? 'HIT' : 'MISS'} ' 'dossier=${videoDirectory.path}', ); if (videoHit) { onProgress?.call('video', 100); } if (audioHit) { onProgress?.call('audio', 100); } final futures = >[]; if (!videoHit) { await _deleteIfExists(videoFile); futures.add( _downloadOne( id: 'video', ytDlpPath: ytDlp.path, denoPath: deno.path, watchUrl: watchUrl, formatId: videoFormatId, outputFile: videoFile, expectedLength: expectedVideoLength, onProgress: onProgress, ), ); } if (!audioHit) { await _deleteIfExists(audioFile); futures.add( _downloadOne( id: 'audio', ytDlpPath: ytDlp.path, denoPath: deno.path, watchUrl: watchUrl, formatId: audioFormatId, outputFile: audioFile, expectedLength: expectedAudioLength, onProgress: onProgress, ), ); } if (futures.isNotEmpty) { debugPrint( '[HD-DOWNLOAD][17.17] téléchargement ' '${videoHit ? '' : 'video=$videoFormatId '}' '${audioHit ? '' : 'audio=$audioFormatId'}', ); final results = await Future.wait<_DownloadResult>( futures, ); final failed = results .where((result) => !result.ok) .toList(growable: false); if (failed.isNotEmpty) { throw StateError( failed.map((result) => result.error).join(' | '), ); } } final videoLength = await _safeLength(videoFile); final audioLength = await _safeLength(audioFile); if (!_lengthMatches( videoLength, expectedVideoLength, ) || !_lengthMatches( audioLength, expectedAudioLength, )) { throw StateError( 'Cache final invalide: ' 'video=$videoLength/$expectedVideoLength ' 'audio=$audioLength/$expectedAudioLength', ); } // Marque ce cache comme récemment utilisé. // // On utilise un petit fichier marqueur dont la date de modification // sert de timestamp LRU pour le nettoyage des anciens caches. final accessMarker = File( '${videoDirectory.path}' '${Platform.pathSeparator}' '.last_access', ); try { await accessMarker.writeAsString( DateTime.now().toIso8601String(), flush: true, ); } catch (_) { // Non bloquant. } await cacheSettings.touchVideoDirectory(videoDirectory); await cacheSettings.enforceLimit(keepVideoId: safeVideoId); debugPrint( '[HD-DOWNLOAD][17.17] prêts: ' 'video=$videoLength/$expectedVideoLength ' 'audio=$audioLength/$expectedAudioLength', ); return WindowsEmbeddedLocalMedia( videoUri: Uri.file( videoFile.path, windows: true, ).toString(), audioUri: Uri.file( audioFile.path, windows: true, ).toString(), videoPath: videoFile.path, audioPath: audioFile.path, videoFromCache: videoHit, audioFromCache: audioHit, ); } Future<_DownloadResult> _downloadOne({ required String id, required String ytDlpPath, required String denoPath, required String watchUrl, required String formatId, required File outputFile, required int expectedLength, required WindowsDownloadProgress? onProgress, }) async { final args = [ '--ignore-config', '--no-playlist', '--no-warnings', '--newline', '--socket-timeout', '20', '--retries', '5', '--fragment-retries', '5', '--js-runtimes', 'deno:$denoPath', '--extractor-args', 'youtube:player_client=web_embedded', '--force-overwrites', '--no-part', '-f', formatId, '-o', outputFile.path, watchUrl, ]; final process = await Process.start( ytDlpPath, args, workingDirectory: File(ytDlpPath).parent.path, runInShell: false, mode: ProcessStartMode.normal, ); _activeProcesses.add(process); final stderr = StringBuffer(); var lastLoggedBucket = -1; final stdoutSubscription = process.stdout .transform( const SystemEncoding().decoder, ) .listen( (text) { for (final line in text.split(RegExp(r'[\r\n]+'))) { final match = RegExp( r'\[download\]\s+(\d+(?:\.\d+)?)%', ).firstMatch(line); if (match == null) { continue; } final value = double.tryParse( match.group(1) ?? '', ); if (value == null) { continue; } onProgress?.call(id, value); final bucket = value >= 100 ? 10 : value.floor() ~/ 10; if (bucket > lastLoggedBucket) { lastLoggedBucket = bucket; debugPrint( '[HD-DOWNLOAD][17.17] ' '$id ${value.toStringAsFixed(1)}%', ); } } }, ); final stderrSubscription = process.stderr .transform( const SystemEncoding().decoder, ) .listen( (text) { if (stderr.length < 5000) { final remaining = 5000 - stderr.length; stderr.write( text.length <= remaining ? text : text.substring(0, remaining), ); } }, ); final exitCode = await process.exitCode; await stdoutSubscription.cancel(); await stderrSubscription.cancel(); _activeProcesses.remove(process); final actualLength = await _safeLength(outputFile); if (exitCode != 0) { final compact = stderr .toString() .replaceAll(RegExp(r'\s+'), ' ') .trim(); await _deleteIfExists(outputFile); return _DownloadResult( ok: false, error: '$id yt-dlp code=$exitCode' '${compact.isEmpty ? '' : ': $compact'}', ); } if (!_lengthMatches( actualLength, expectedLength, )) { await _deleteIfExists(outputFile); return _DownloadResult( ok: false, error: '$id taille invalide ' '$actualLength/$expectedLength', ); } onProgress?.call(id, 100); debugPrint( '[HD-DOWNLOAD][17.17] $id terminé: ' '$actualLength/$expectedLength octets', ); return const _DownloadResult( ok: true, error: '', ); } String _safeName(String value) { final safe = value.replaceAll( RegExp(r'[^A-Za-z0-9._-]'), '_', ); return safe.isEmpty ? 'media' : safe; } bool _lengthMatches( int actual, int expected, ) { if (actual <= 0) { return false; } if (expected <= 0) { return true; } return actual == expected; } Future _isValidCacheFile( File file, int expectedLength, ) async { final length = await _safeLength(file); if (_lengthMatches( length, expectedLength, )) { return true; } if (length > 0) { await _deleteIfExists(file); } return false; } Future _safeLength( File file, ) async { try { if (!await file.exists()) { return 0; } return await file.length(); } catch (_) { return 0; } } Future _deleteIfExists( File file, ) async { try { if (await file.exists()) { await file.delete(); } } catch (_) { // Le prochain téléchargement donnera une erreur explicite si besoin. } } Future stop() async { final processes = _activeProcesses.toList(growable: false); _activeProcesses.clear(); for (final process in processes) { process.kill(); } if (processes.isNotEmpty) { await Future.delayed( const Duration(milliseconds: 120), ); } // Important : le cache persistant n'est PAS supprimé ici. // C'est ce qui rend une seconde lecture quasi immédiate. } } class _DownloadResult { const _DownloadResult({ required this.ok, required this.error, }); final bool ok; final String error; }