Files
youtube_personal/lib/services/windows_media_relay_service.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;
}