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