Light theme default + yt-dlp search fallback

This commit is contained in:
iliasmouhcine
2026-08-22 13:02:32 +01:00
commit 7704cae4a0
282 changed files with 52324 additions and 0 deletions
@@ -0,0 +1,646 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
class WindowsEmbeddedCacheSession {
const WindowsEmbeddedCacheSession({
required this.videoUrl,
required this.audioUrl,
});
final String videoUrl;
final String audioUrl;
}
/// Cache progressif Windows pour le chemin `web_embedded`.
///
/// Le test manuel a démontré que yt-dlp télécharge intégralement :
/// - le format vidéo 299 (1080p60) ;
/// - le format audio 251 (Opus).
///
/// Au lieu de donner les URLs googlevideo à libmpv, cette classe lance les
/// mêmes commandes yt-dlp validées vers deux fichiers temporaires qui
/// grandissent pendant le téléchargement.
///
/// libmpv lit uniquement un serveur HTTP loopback :
///
/// libmpv -> 127.0.0.1 -> fichier temporaire en croissance
/// ^
/// |
/// yt-dlp web_embedded
/// |
/// googlevideo
///
/// Le lecteur peut commencer avant la fin du téléchargement complet.
/// Lors d'un seek vers une zone qui n'est pas encore en cache, le serveur
/// attend simplement que yt-dlp atteigne cette zone.
class WindowsEmbeddedCacheRelayService {
HttpServer? _server;
StreamSubscription<HttpRequest>? _serverSubscription;
Directory? _sessionDirectory;
final Map<String, _GrowingSource> _sources =
<String, _GrowingSource>{};
Future<WindowsEmbeddedCacheSession> start({
required String ytDlpPath,
required String denoPath,
required String watchUrl,
required String videoFormatId,
required String audioFormatId,
required int videoLength,
required int audioLength,
required String videoExt,
required String audioExt,
required String videoMimeType,
required String audioMimeType,
}) async {
if (!Platform.isWindows) {
throw UnsupportedError(
'Le cache 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',
);
}
if (videoLength <= 0 || audioLength <= 0) {
throw StateError(
'Tailles web_embedded invalides : '
'video=$videoLength audio=$audioLength',
);
}
final sessionDirectory =
await Directory.systemTemp.createTemp(
'youtube_personal_embedded_cache_',
);
final videoFile = File(
'${sessionDirectory.path}'
'${Platform.pathSeparator}'
'video.$videoExt',
);
final audioFile = File(
'${sessionDirectory.path}'
'${Platform.pathSeparator}'
'audio.$audioExt',
);
final video = _GrowingSource(
id: 'video',
file: videoFile,
formatId: videoFormatId,
length: videoLength,
contentType: videoMimeType,
);
final audio = _GrowingSource(
id: 'audio',
file: audioFile,
formatId: audioFormatId,
length: audioLength,
contentType: audioMimeType,
);
_sessionDirectory = sessionDirectory;
_sources
..clear()
..['/video'] = video
..['/audio'] = audio;
final server = await HttpServer.bind(
InternetAddress.loopbackIPv4,
0,
shared: false,
);
_server = server;
_serverSubscription = server.listen(
(request) {
unawaited(_handleRequest(request));
},
onError: (Object error, StackTrace stackTrace) {
debugPrint(
'[HD-CACHE][17.15] erreur serveur local: $error',
);
},
cancelOnError: false,
);
// On lance vidéo et audio en parallèle. C'est volontaire :
// yt-dlp peut imposer quelques secondes de sleep avant le téléchargement.
// L'audio 251 est petit et sera normalement entièrement mis en cache
// très rapidement, pendant que la vidéo continue à grossir.
await Future.wait<void>([
_startYtDlp(
source: video,
ytDlpPath: ytDlp.path,
denoPath: deno.path,
watchUrl: watchUrl,
),
_startYtDlp(
source: audio,
ytDlpPath: ytDlp.path,
denoPath: deno.path,
watchUrl: watchUrl,
),
]);
final base =
'http://127.0.0.1:${server.port}';
debugPrint(
'[HD-CACHE][17.15] actif sur 127.0.0.1:${server.port} '
'video=$videoFormatId audio=$audioFormatId',
);
return WindowsEmbeddedCacheSession(
videoUrl: '$base/video',
audioUrl: '$base/audio',
);
}
Future<void> stop() async {
final subscription = _serverSubscription;
_serverSubscription = null;
if (subscription != null) {
await subscription.cancel();
}
final server = _server;
_server = null;
if (server != null) {
await server.close(force: true);
}
final currentSources =
_sources.values.toList(growable: false);
for (final source in currentSources) {
final process = source.process;
if (process != null) {
process.kill();
}
}
// Laisse Windows libérer les handles de fichiers/processus.
await Future<void>.delayed(
const Duration(milliseconds: 120),
);
_sources.clear();
final directory = _sessionDirectory;
_sessionDirectory = null;
if (directory != null) {
try {
if (await directory.exists()) {
await directory.delete(
recursive: true,
);
}
} catch (error) {
debugPrint(
'[HD-CACHE][17.15] nettoyage différé: $error',
);
}
}
}
Future<void> _startYtDlp({
required _GrowingSource source,
required String ytDlpPath,
required String denoPath,
required String watchUrl,
}) async {
final args = <String>[
'--ignore-config',
'--no-playlist',
'--no-warnings',
'--no-progress',
'--socket-timeout',
'20',
'--retries',
'5',
'--fragment-retries',
'5',
'--js-runtimes',
'deno:$denoPath',
'--extractor-args',
'youtube:player_client=web_embedded',
'--force-overwrites',
'--no-part',
'-f',
source.formatId,
'-o',
source.file.path,
watchUrl,
];
debugPrint(
'[HD-CACHE][17.15] lancement yt-dlp '
'${source.id} format=${source.formatId}',
);
final process = await Process.start(
ytDlpPath,
args,
workingDirectory:
File(ytDlpPath).parent.path,
runInShell: false,
mode: ProcessStartMode.normal,
);
source.process = process;
process.stdout
.transform(
const SystemEncoding().decoder,
)
.listen(
(text) {
final compact =
text.replaceAll(RegExp(r'\s+'), ' ').trim();
if (compact.isNotEmpty &&
compact.toLowerCase().contains('error')) {
debugPrint(
'[HD-CACHE][17.15] ${source.id} stdout: $compact',
);
}
},
);
process.stderr
.transform(
const SystemEncoding().decoder,
)
.listen(
(text) {
if (source.stderr.length < 6000) {
final remaining =
6000 - source.stderr.length;
source.stderr.write(
text.length <= remaining
? text
: text.substring(0, remaining),
);
}
},
);
unawaited(
process.exitCode.then(
(code) async {
source.exitCode = code;
if (!source.done.isCompleted) {
source.done.complete(code);
}
final size = await _safeLength(
source.file,
);
if (code == 0) {
debugPrint(
'[HD-CACHE][17.15] ${source.id} téléchargé: '
'$size/${source.length} octets',
);
} else {
final stderr = source.stderr
.toString()
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
debugPrint(
'[HD-CACHE][17.15] ${source.id} yt-dlp code=$code'
'${stderr.isEmpty ? '' : ': $stderr'}',
);
}
},
),
);
}
Future<void> _handleRequest(
HttpRequest request,
) async {
final source = _sources[request.uri.path];
if (source == null) {
request.response.statusCode =
HttpStatus.notFound;
await request.response.close();
return;
}
if (request.method != 'GET' &&
request.method != 'HEAD') {
request.response.statusCode =
HttpStatus.methodNotAllowed;
await request.response.close();
return;
}
final rangeHeader =
request.headers.value(
HttpHeaders.rangeHeader,
);
final parsed = _parseRange(
rangeHeader,
source.length,
);
if (rangeHeader != null && parsed == null) {
request.response.statusCode =
HttpStatus.requestedRangeNotSatisfiable;
request.response.headers.set(
'content-range',
'bytes */${source.length}',
);
await request.response.close();
return;
}
final start = parsed?.$1 ?? 0;
final end =
parsed?.$2 ?? source.length - 1;
final response = request.response;
response.statusCode = parsed == null
? HttpStatus.ok
: HttpStatus.partialContent;
response.headers.set(
'accept-ranges',
'bytes',
);
response.headers.set(
HttpHeaders.cacheControlHeader,
'no-store',
);
response.headers.set(
HttpHeaders.contentTypeHeader,
source.contentType,
);
final responseLength =
end - start + 1;
response.contentLength =
responseLength;
if (parsed != null) {
response.headers.set(
'content-range',
'bytes $start-$end/${source.length}',
);
}
debugPrint(
'[HD-CACHE][17.15] ${request.uri.path} '
'${request.method} bytes=$start-$end',
);
if (request.method == 'HEAD') {
await response.close();
return;
}
try {
await _streamGrowingFile(
source: source,
start: start,
end: end,
response: response,
);
} on Object catch (error) {
debugPrint(
'[HD-CACHE][17.15] ${request.uri.path} échec: $error',
);
try {
await response.close();
} catch (_) {
// libmpv peut fermer une requête lors d'un seek.
}
}
}
Future<void> _streamGrowingFile({
required _GrowingSource source,
required int start,
required int end,
required HttpResponse response,
}) async {
var cursor = start;
RandomAccessFile? raf;
try {
while (cursor <= end) {
final available =
await _safeLength(source.file);
if (available > cursor) {
raf ??=
await source.file.open(
mode: FileMode.read,
);
final readable = math.min(
available - cursor,
end - cursor + 1,
);
final count = math.min(
readable,
512 * 1024,
);
await raf.setPosition(cursor);
final data =
await raf.read(count);
if (data.isEmpty) {
await Future<void>.delayed(
const Duration(milliseconds: 30),
);
continue;
}
response.add(data);
await response.flush();
cursor += data.length;
continue;
}
final exitCode = source.exitCode;
if (exitCode != null) {
if (exitCode != 0) {
final stderr = source.stderr
.toString()
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
throw HttpException(
'yt-dlp ${source.id} code=$exitCode'
'${stderr.isEmpty ? '' : ': $stderr'}',
);
}
final finalLength =
await _safeLength(source.file);
if (finalLength <= cursor) {
throw HttpException(
'Cache ${source.id} terminé à '
'$finalLength/${source.length} octets, '
'mais mpv demande l’offset $cursor.',
);
}
}
// Le téléchargement est encore en cours et n'a pas atteint la zone.
await Future<void>.delayed(
const Duration(milliseconds: 35),
);
}
await response.close();
} finally {
await raf?.close();
}
}
(int, int)? _parseRange(
String? value,
int totalLength,
) {
if (value == null || value.trim().isEmpty) {
return null;
}
final match = RegExp(
r'^bytes=(\d*)-(\d*)$',
caseSensitive: false,
).firstMatch(value.trim());
if (match == null) {
return null;
}
final startText = match.group(1) ?? '';
final endText = match.group(2) ?? '';
if (startText.isEmpty) {
final suffix = int.tryParse(endText);
if (suffix == null || suffix <= 0) {
return null;
}
final start = math.max(
0,
totalLength - suffix,
);
return (start, totalLength - 1);
}
final start = int.tryParse(startText);
if (start == null ||
start < 0 ||
start >= totalLength) {
return null;
}
final parsedEnd = endText.isEmpty
? totalLength - 1
: int.tryParse(endText);
if (parsedEnd == null ||
parsedEnd < start) {
return null;
}
final end = math.min(
parsedEnd,
totalLength - 1,
);
return (start, end);
}
Future<int> _safeLength(
File file,
) async {
try {
if (!await file.exists()) {
return 0;
}
return await file.length();
} catch (_) {
return 0;
}
}
}
class _GrowingSource {
_GrowingSource({
required this.id,
required this.file,
required this.formatId,
required this.length,
required this.contentType,
});
final String id;
final File file;
final String formatId;
final int length;
final String contentType;
Process? process;
int? exitCode;
final Completer<int> done =
Completer<int>();
final StringBuffer stderr =
StringBuffer();
}