519 lines
12 KiB
Dart
519 lines
12 KiB
Dart
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<Process> _activeProcesses =
|
|
<Process>{};
|
|
|
|
Future<WindowsEmbeddedLocalMedia> 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 = <Future<_DownloadResult>>[];
|
|
|
|
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 = <String>[
|
|
'--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<bool> _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<int> _safeLength(
|
|
File file,
|
|
) async {
|
|
try {
|
|
if (!await file.exists()) {
|
|
return 0;
|
|
}
|
|
|
|
return await file.length();
|
|
} catch (_) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
Future<void> _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<void> stop() async {
|
|
final processes =
|
|
_activeProcesses.toList(growable: false);
|
|
|
|
_activeProcesses.clear();
|
|
|
|
for (final process in processes) {
|
|
process.kill();
|
|
}
|
|
|
|
if (processes.isNotEmpty) {
|
|
await Future<void>.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;
|
|
}
|