620 lines
14 KiB
Dart
620 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
class WindowsRelaySession {
|
|
const WindowsRelaySession({
|
|
required this.videoUrl,
|
|
required this.audioUrl,
|
|
});
|
|
|
|
final String videoUrl;
|
|
final String audioUrl;
|
|
}
|
|
|
|
/// Relais HTTP local Windows dont le transport upstream est exécuté par
|
|
/// yt-dlp lui-même.
|
|
///
|
|
/// Architecture :
|
|
///
|
|
/// libmpv -> 127.0.0.1 -> yt-dlp HttpFD -> googlevideo
|
|
///
|
|
/// Les étapes 17.10/17.11 ont démontré que :
|
|
/// - libmpv lit correctement le 1080p depuis localhost ;
|
|
/// - le HttpClient Dart n'est pas accepté de façon stable par Googlevideo ;
|
|
/// - yt-dlp, lui, sait télécharger le même format.
|
|
///
|
|
/// Pour chaque GET/Range demandé par mpv, le relais construit un petit
|
|
/// info-json local contenant l'URL signée, les headers et la plage demandée,
|
|
/// puis lance le yt-dlp déjà empaqueté avec `--load-info-json` et `-o -`.
|
|
///
|
|
/// yt-dlp applique alors exactement son propre downloader HTTP, y compris
|
|
/// la logique officielle de reprise/chunking et son stack réseau.
|
|
class WindowsMediaRelayService {
|
|
HttpServer? _server;
|
|
StreamSubscription<HttpRequest>? _serverSubscription;
|
|
|
|
final Map<String, _RelaySource> _sources =
|
|
<String, _RelaySource>{};
|
|
|
|
final Set<Process> _activeProcesses =
|
|
<Process>{};
|
|
|
|
Directory? _sessionDirectory;
|
|
|
|
Future<WindowsRelaySession> start({
|
|
required String ytDlpPath,
|
|
required String videoUrl,
|
|
required String audioUrl,
|
|
required Map<String, String> videoHeaders,
|
|
required Map<String, String> audioHeaders,
|
|
required int videoLength,
|
|
required int audioLength,
|
|
required int videoChunkSize,
|
|
required int audioChunkSize,
|
|
required String videoMimeType,
|
|
required String audioMimeType,
|
|
}) async {
|
|
if (!Platform.isWindows) {
|
|
throw UnsupportedError(
|
|
'Le relais HD local est réservé à Windows.',
|
|
);
|
|
}
|
|
|
|
await stop();
|
|
|
|
final ytDlp = File(ytDlpPath);
|
|
|
|
if (!await ytDlp.exists()) {
|
|
throw StateError(
|
|
'yt-dlp.exe introuvable pour le relais : $ytDlpPath',
|
|
);
|
|
}
|
|
|
|
if (videoLength <= 0 || audioLength <= 0) {
|
|
throw StateError(
|
|
'Taille vidéo/audio inconnue : '
|
|
'video=$videoLength audio=$audioLength',
|
|
);
|
|
}
|
|
|
|
final videoUri = Uri.tryParse(videoUrl);
|
|
final audioUri = Uri.tryParse(audioUrl);
|
|
|
|
if (videoUri == null ||
|
|
!videoUri.hasScheme ||
|
|
audioUri == null ||
|
|
!audioUri.hasScheme) {
|
|
throw StateError(
|
|
'URL vidéo/audio invalide pour le relais.',
|
|
);
|
|
}
|
|
|
|
final sessionDirectory =
|
|
await Directory.systemTemp.createTemp(
|
|
'youtube_personal_ytdlp_relay_',
|
|
);
|
|
|
|
final server = await HttpServer.bind(
|
|
InternetAddress.loopbackIPv4,
|
|
0,
|
|
shared: false,
|
|
);
|
|
|
|
_sessionDirectory = sessionDirectory;
|
|
_server = server;
|
|
|
|
_sources
|
|
..clear()
|
|
..['/video'] = _RelaySource(
|
|
id: 'video',
|
|
ytDlpPath: ytDlp.path,
|
|
upstream: videoUri,
|
|
headers: videoHeaders,
|
|
length: videoLength,
|
|
chunkSize: _safeChunkSize(videoChunkSize),
|
|
contentType: videoMimeType,
|
|
ext: _extensionForMime(
|
|
videoMimeType,
|
|
fallback: 'mp4',
|
|
),
|
|
)
|
|
..['/audio'] = _RelaySource(
|
|
id: 'audio',
|
|
ytDlpPath: ytDlp.path,
|
|
upstream: audioUri,
|
|
headers: audioHeaders,
|
|
length: audioLength,
|
|
chunkSize: _safeChunkSize(audioChunkSize),
|
|
contentType: audioMimeType,
|
|
ext: _extensionForMime(
|
|
audioMimeType,
|
|
fallback: 'm4a',
|
|
),
|
|
);
|
|
|
|
_serverSubscription = server.listen(
|
|
(request) {
|
|
unawaited(_handleRequest(request));
|
|
},
|
|
onError: (Object error, StackTrace stackTrace) {
|
|
debugPrint(
|
|
'[HD-RELAY] erreur serveur local: $error',
|
|
);
|
|
},
|
|
cancelOnError: false,
|
|
);
|
|
|
|
final base =
|
|
'http://127.0.0.1:${server.port}';
|
|
|
|
debugPrint(
|
|
'[HD-RELAY][17.12] actif sur 127.0.0.1:${server.port} '
|
|
'(transport upstream = yt-dlp HttpFD)',
|
|
);
|
|
|
|
return WindowsRelaySession(
|
|
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 processes =
|
|
_activeProcesses.toList(growable: false);
|
|
|
|
_activeProcesses.clear();
|
|
|
|
for (final process in processes) {
|
|
process.kill();
|
|
}
|
|
|
|
final sessionDirectory = _sessionDirectory;
|
|
_sessionDirectory = null;
|
|
|
|
if (sessionDirectory != null) {
|
|
try {
|
|
if (await sessionDirectory.exists()) {
|
|
await sessionDirectory.delete(
|
|
recursive: true,
|
|
);
|
|
}
|
|
} catch (_) {
|
|
// Windows peut garder brièvement un handle après l'arrêt de yt-dlp.
|
|
}
|
|
}
|
|
|
|
_sources.clear();
|
|
}
|
|
|
|
int _safeChunkSize(int requested) {
|
|
if (requested <= 0) {
|
|
return 10 * 1024 * 1024;
|
|
}
|
|
|
|
return requested.clamp(
|
|
256 * 1024,
|
|
10 * 1024 * 1024,
|
|
);
|
|
}
|
|
|
|
String _extensionForMime(
|
|
String mime, {
|
|
required String fallback,
|
|
}) {
|
|
final normalized = mime.toLowerCase();
|
|
|
|
if (normalized.contains('webm')) {
|
|
return 'webm';
|
|
}
|
|
|
|
if (normalized.contains('mp4') ||
|
|
normalized.contains('m4a')) {
|
|
return fallback;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
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;
|
|
request.response.headers.set(
|
|
'allow',
|
|
'GET, HEAD',
|
|
);
|
|
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-RELAY][17.12] ${request.uri.path} '
|
|
'${request.method} bytes=$start-$end '
|
|
'(${responseLength}o)',
|
|
);
|
|
|
|
if (request.method == 'HEAD') {
|
|
await response.close();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await _pipeWithYtDlp(
|
|
source: source,
|
|
start: start,
|
|
end: end,
|
|
downstream: response,
|
|
);
|
|
} on Object catch (error) {
|
|
debugPrint(
|
|
'[HD-RELAY][17.12] ${request.uri.path} échec: $error',
|
|
);
|
|
|
|
try {
|
|
await response.close();
|
|
} catch (_) {
|
|
// Le lecteur peut avoir fermé la connexion lors d'un seek.
|
|
}
|
|
}
|
|
}
|
|
|
|
(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 =
|
|
(totalLength - suffix).clamp(
|
|
0,
|
|
totalLength - 1,
|
|
);
|
|
|
|
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 = parsedEnd.clamp(
|
|
start,
|
|
totalLength - 1,
|
|
);
|
|
|
|
return (start, end);
|
|
}
|
|
|
|
Future<void> _pipeWithYtDlp({
|
|
required _RelaySource source,
|
|
required int start,
|
|
required int end,
|
|
required HttpResponse downstream,
|
|
}) async {
|
|
final sessionDirectory = _sessionDirectory;
|
|
|
|
if (sessionDirectory == null) {
|
|
throw StateError(
|
|
'Session relais yt-dlp fermée.',
|
|
);
|
|
}
|
|
|
|
final rangeValue =
|
|
'bytes=$start-$end';
|
|
|
|
final infoHeaders =
|
|
<String, String>{
|
|
...source.headers,
|
|
'Accept-Encoding': 'identity',
|
|
'Range': rangeValue,
|
|
};
|
|
|
|
final info = <String, dynamic>{
|
|
'id':
|
|
'youtube-personal-${source.id}',
|
|
'title':
|
|
'YouTube Personal ${source.id}',
|
|
'ext': source.ext,
|
|
'protocol': 'https',
|
|
'url': source.upstream.toString(),
|
|
'http_headers': infoHeaders,
|
|
'filesize': source.length,
|
|
'downloader_options':
|
|
<String, dynamic>{
|
|
'http_chunk_size':
|
|
source.chunkSize,
|
|
},
|
|
};
|
|
|
|
final stamp =
|
|
'${DateTime.now().microsecondsSinceEpoch}'
|
|
'-${requestCounter++}';
|
|
|
|
final infoFile = File(
|
|
'${sessionDirectory.path}'
|
|
'${Platform.pathSeparator}'
|
|
'${source.id}-$stamp.info.json',
|
|
);
|
|
|
|
await infoFile.writeAsString(
|
|
jsonEncode(info),
|
|
encoding: utf8,
|
|
flush: true,
|
|
);
|
|
|
|
final process = await Process.start(
|
|
source.ytDlpPath,
|
|
<String>[
|
|
'--ignore-config',
|
|
'--no-warnings',
|
|
'--no-progress',
|
|
'--socket-timeout',
|
|
'20',
|
|
'--retries',
|
|
'2',
|
|
'--no-part',
|
|
'--load-info-json',
|
|
infoFile.path,
|
|
'-o',
|
|
'-',
|
|
],
|
|
workingDirectory:
|
|
File(source.ytDlpPath).parent.path,
|
|
runInShell: false,
|
|
mode: ProcessStartMode.normal,
|
|
);
|
|
|
|
_activeProcesses.add(process);
|
|
|
|
final stderrBuffer =
|
|
StringBuffer();
|
|
|
|
final stderrSubscription =
|
|
process.stderr
|
|
.transform(utf8.decoder)
|
|
.listen(
|
|
(text) {
|
|
if (stderrBuffer.length < 4000) {
|
|
final remaining =
|
|
4000 - stderrBuffer.length;
|
|
|
|
stderrBuffer.write(
|
|
text.length <= remaining
|
|
? text
|
|
: text.substring(0, remaining),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
var written = 0;
|
|
Object? streamError;
|
|
|
|
debugPrint(
|
|
'[HD-RELAY][17.12] ${source.id}: '
|
|
'yt-dlp démarre Range=$rangeValue',
|
|
);
|
|
|
|
try {
|
|
await for (final data in process.stdout) {
|
|
if (written >= end - start + 1) {
|
|
break;
|
|
}
|
|
|
|
final remaining =
|
|
end - start + 1 - written;
|
|
|
|
if (data.length <= remaining) {
|
|
downstream.add(data);
|
|
written += data.length;
|
|
} else {
|
|
downstream.add(
|
|
data.sublist(0, remaining),
|
|
);
|
|
written += remaining;
|
|
}
|
|
|
|
// Backpressure : permet au lecteur de contrôler la vitesse
|
|
// au lieu de remplir massivement la mémoire.
|
|
await downstream.flush();
|
|
|
|
if (written >= end - start + 1) {
|
|
break;
|
|
}
|
|
}
|
|
} on Object catch (error) {
|
|
streamError = error;
|
|
}
|
|
|
|
if (written >= end - start + 1) {
|
|
// La plage demandée est complète. Si yt-dlp n'a pas encore quitté
|
|
// (par ex. sortie stdout plus large), on peut arrêter le process.
|
|
process.kill();
|
|
}
|
|
|
|
final exitCode = await process.exitCode;
|
|
|
|
await stderrSubscription.cancel();
|
|
_activeProcesses.remove(process);
|
|
|
|
try {
|
|
if (await infoFile.exists()) {
|
|
await infoFile.delete();
|
|
}
|
|
} catch (_) {
|
|
// Non bloquant.
|
|
}
|
|
|
|
if (streamError != null) {
|
|
throw streamError;
|
|
}
|
|
|
|
if (written != end - start + 1) {
|
|
final stderr = stderrBuffer
|
|
.toString()
|
|
.replaceAll(RegExp(r'\s+'), ' ')
|
|
.trim();
|
|
|
|
throw HttpException(
|
|
'yt-dlp a fourni $written/'
|
|
'${end - start + 1} octets '
|
|
'(code=$exitCode)'
|
|
'${stderr.isEmpty ? '' : ': $stderr'}',
|
|
);
|
|
}
|
|
|
|
debugPrint(
|
|
'[HD-RELAY][17.12] ${source.id}: '
|
|
'yt-dlp OK ${written}o',
|
|
);
|
|
|
|
await downstream.close();
|
|
}
|
|
|
|
int requestCounter = 0;
|
|
}
|
|
|
|
class _RelaySource {
|
|
const _RelaySource({
|
|
required this.id,
|
|
required this.ytDlpPath,
|
|
required this.upstream,
|
|
required this.headers,
|
|
required this.length,
|
|
required this.chunkSize,
|
|
required this.contentType,
|
|
required this.ext,
|
|
});
|
|
|
|
final String id;
|
|
final String ytDlpPath;
|
|
final Uri upstream;
|
|
final Map<String, String> headers;
|
|
final int length;
|
|
final int chunkSize;
|
|
final String contentType;
|
|
final String ext;
|
|
}
|