Light theme default + yt-dlp search fallback
This commit is contained in:
@@ -0,0 +1,518 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user