647 lines
14 KiB
Dart
647 lines
14 KiB
Dart
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();
|
||
}
|