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