Light theme default + yt-dlp search fallback
This commit is contained in:
@@ -0,0 +1,937 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'media_cache_settings_service.dart';
|
||||
|
||||
class WindowsEmbeddedProgressiveSession {
|
||||
const WindowsEmbeddedProgressiveSession({
|
||||
required this.videoUrl,
|
||||
required this.audioUri,
|
||||
required this.videoFromCache,
|
||||
required this.audioFromCache,
|
||||
});
|
||||
|
||||
final String videoUrl;
|
||||
final String audioUri;
|
||||
final bool videoFromCache;
|
||||
final bool audioFromCache;
|
||||
}
|
||||
|
||||
class WindowsEmbeddedProgressiveStatus {
|
||||
const WindowsEmbeddedProgressiveStatus({
|
||||
required this.videoBytes,
|
||||
required this.videoLength,
|
||||
required this.audioBytes,
|
||||
required this.audioLength,
|
||||
required this.videoComplete,
|
||||
required this.audioComplete,
|
||||
required this.estimatedBufferedDuration,
|
||||
});
|
||||
|
||||
final int videoBytes;
|
||||
final int videoLength;
|
||||
final int audioBytes;
|
||||
final int audioLength;
|
||||
final bool videoComplete;
|
||||
final bool audioComplete;
|
||||
final Duration estimatedBufferedDuration;
|
||||
}
|
||||
|
||||
/// Streaming progressif Windows basé sur le transport déjà validé yt-dlp.
|
||||
///
|
||||
/// yt-dlp télécharge le fichier web_embedded pendant que libmpv lit une
|
||||
/// façade HTTP locale du fichier qui grandit.
|
||||
///
|
||||
/// Différence critique avec l'étape 17.15 :
|
||||
/// on N'OUVRE PLUS libmpv lorsque le fichier fait 0 octet. Le lecteur 360p
|
||||
/// reste actif jusqu'à ce qu'une vraie réserve HD soit disponible autour du
|
||||
/// timestamp courant.
|
||||
///
|
||||
/// Les fichiers restent dans le cache persistant :
|
||||
/// %LOCALAPPDATA%\YouTubePersonal\media_cache\<videoId>
|
||||
class WindowsEmbeddedProgressiveService {
|
||||
HttpServer? _server;
|
||||
StreamSubscription<HttpRequest>? _serverSubscription;
|
||||
|
||||
final Set<Process> _activeProcesses =
|
||||
<Process>{};
|
||||
|
||||
_GrowingSource? _video;
|
||||
_GrowingSource? _audio;
|
||||
String? _activeVideoCacheId;
|
||||
Directory? _activeVideoDirectory;
|
||||
|
||||
Future<WindowsEmbeddedProgressiveSession> start({
|
||||
required String videoId,
|
||||
required String ytDlpPath,
|
||||
required String denoPath,
|
||||
required String watchUrl,
|
||||
required String videoFormatId,
|
||||
required String audioFormatId,
|
||||
required String videoExt,
|
||||
required String audioExt,
|
||||
required String videoMimeType,
|
||||
required int videoLength,
|
||||
required int audioLength,
|
||||
}) async {
|
||||
if (!Platform.isWindows) {
|
||||
throw UnsupportedError(
|
||||
'Le streaming progressif HD 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 cacheSettings = MediaCacheSettingsService.instance;
|
||||
final cacheRoot = await cacheSettings.resolveCacheRoot();
|
||||
final safeVideoId = _safeName(videoId);
|
||||
|
||||
await cacheSettings.enforceLimit(keepVideoId: safeVideoId);
|
||||
|
||||
final videoDirectory = Directory(
|
||||
'${cacheRoot.path}'
|
||||
'${Platform.pathSeparator}'
|
||||
'$safeVideoId',
|
||||
);
|
||||
|
||||
await videoDirectory.create(
|
||||
recursive: true,
|
||||
);
|
||||
_activeVideoCacheId = safeVideoId;
|
||||
_activeVideoDirectory = videoDirectory;
|
||||
await cacheSettings.touchVideoDirectory(videoDirectory);
|
||||
|
||||
final videoFile = File(
|
||||
'${videoDirectory.path}'
|
||||
'${Platform.pathSeparator}'
|
||||
'video_${_safeName(videoFormatId)}.$videoExt',
|
||||
);
|
||||
|
||||
final audioFile = File(
|
||||
'${videoDirectory.path}'
|
||||
'${Platform.pathSeparator}'
|
||||
'audio_${_safeName(audioFormatId)}.$audioExt',
|
||||
);
|
||||
|
||||
final videoHit =
|
||||
await _isComplete(
|
||||
videoFile,
|
||||
videoLength,
|
||||
);
|
||||
|
||||
final audioHit =
|
||||
await _isComplete(
|
||||
audioFile,
|
||||
audioLength,
|
||||
);
|
||||
|
||||
// Un ancien fichier incomplet ne doit pas être pris pour un HIT.
|
||||
// On le recrée proprement; yt-dlp continuera ensuite à le remplir
|
||||
// pendant la lecture.
|
||||
if (!videoHit) {
|
||||
await _deleteIfExists(videoFile);
|
||||
}
|
||||
|
||||
if (!audioHit) {
|
||||
await _deleteIfExists(audioFile);
|
||||
}
|
||||
|
||||
final video = _GrowingSource(
|
||||
id: 'video',
|
||||
file: videoFile,
|
||||
expectedLength: videoLength,
|
||||
formatId: videoFormatId,
|
||||
);
|
||||
|
||||
final audio = _GrowingSource(
|
||||
id: 'audio',
|
||||
file: audioFile,
|
||||
expectedLength: audioLength,
|
||||
formatId: audioFormatId,
|
||||
);
|
||||
|
||||
if (videoHit) {
|
||||
video.exitCode = 0;
|
||||
}
|
||||
|
||||
if (audioHit) {
|
||||
audio.exitCode = 0;
|
||||
}
|
||||
|
||||
_video = video;
|
||||
_audio = audio;
|
||||
|
||||
final server = await HttpServer.bind(
|
||||
InternetAddress.loopbackIPv4,
|
||||
0,
|
||||
shared: false,
|
||||
);
|
||||
|
||||
_server = server;
|
||||
|
||||
_serverSubscription = server.listen(
|
||||
(request) {
|
||||
unawaited(
|
||||
_handleVideoRequest(
|
||||
request,
|
||||
video,
|
||||
videoMimeType,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
debugPrint(
|
||||
'[HD-PROGRESSIVE][17.19] serveur local: $error',
|
||||
);
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
if (!videoHit) {
|
||||
await _startDownload(
|
||||
source: video,
|
||||
ytDlpPath: ytDlp.path,
|
||||
denoPath: deno.path,
|
||||
watchUrl: watchUrl,
|
||||
);
|
||||
}
|
||||
|
||||
if (!audioHit) {
|
||||
await _startDownload(
|
||||
source: audio,
|
||||
ytDlpPath: ytDlp.path,
|
||||
denoPath: deno.path,
|
||||
watchUrl: watchUrl,
|
||||
);
|
||||
}
|
||||
|
||||
final base =
|
||||
'http://127.0.0.1:${server.port}';
|
||||
|
||||
debugPrint(
|
||||
'[HD-PROGRESSIVE][17.19] session prête '
|
||||
'video=${videoHit ? 'HIT' : 'STREAM'} '
|
||||
'audio=${audioHit ? 'HIT' : 'DOWNLOAD'} '
|
||||
'video=$videoFormatId audio=$audioFormatId',
|
||||
);
|
||||
|
||||
return WindowsEmbeddedProgressiveSession(
|
||||
videoUrl: '$base/video',
|
||||
audioUri: Uri.file(
|
||||
audioFile.path,
|
||||
windows: true,
|
||||
).toString(),
|
||||
videoFromCache: videoHit,
|
||||
audioFromCache: audioHit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<WindowsEmbeddedProgressiveStatus> status({
|
||||
required Duration mediaDuration,
|
||||
}) async {
|
||||
final video = _video;
|
||||
final audio = _audio;
|
||||
|
||||
if (video == null || audio == null) {
|
||||
throw StateError(
|
||||
'Aucune session progressive active.',
|
||||
);
|
||||
}
|
||||
|
||||
final videoBytes =
|
||||
await _safeLength(video.file);
|
||||
|
||||
final audioBytes =
|
||||
await _safeLength(audio.file);
|
||||
|
||||
final videoComplete =
|
||||
videoBytes == video.expectedLength;
|
||||
|
||||
final audioComplete =
|
||||
audioBytes == audio.expectedLength;
|
||||
|
||||
Duration estimated = Duration.zero;
|
||||
|
||||
if (mediaDuration > Duration.zero &&
|
||||
video.expectedLength > 0 &&
|
||||
videoBytes > 0) {
|
||||
final ratio = (
|
||||
videoBytes / video.expectedLength
|
||||
).clamp(0.0, 1.0);
|
||||
|
||||
estimated = Duration(
|
||||
milliseconds:
|
||||
(mediaDuration.inMilliseconds * ratio)
|
||||
.round(),
|
||||
);
|
||||
}
|
||||
|
||||
return WindowsEmbeddedProgressiveStatus(
|
||||
videoBytes: videoBytes,
|
||||
videoLength: video.expectedLength,
|
||||
audioBytes: audioBytes,
|
||||
audioLength: audio.expectedLength,
|
||||
videoComplete: videoComplete,
|
||||
audioComplete: audioComplete,
|
||||
estimatedBufferedDuration: estimated,
|
||||
);
|
||||
}
|
||||
|
||||
/// Vrai lorsque le fichier vidéo contient suffisamment d'octets pour
|
||||
/// couvrir approximativement [position] + [ahead], avec une marge.
|
||||
///
|
||||
/// On n'utilise pas --download-sections ici : le fichier DASH continue de
|
||||
/// grandir normalement, ce qui évite une découpe/re-encapsulation ffmpeg.
|
||||
Future<bool> hasEnoughBuffer({
|
||||
required Duration position,
|
||||
required Duration mediaDuration,
|
||||
Duration ahead = const Duration(seconds: 20),
|
||||
}) async {
|
||||
final video = _video;
|
||||
final audio = _audio;
|
||||
|
||||
if (video == null || audio == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final videoBytes =
|
||||
await _safeLength(video.file);
|
||||
|
||||
final audioBytes =
|
||||
await _safeLength(audio.file);
|
||||
|
||||
// L'audio 251 est petit. Pour garantir le son lors de la bascule,
|
||||
// on attend qu'il soit complet.
|
||||
if (audioBytes != audio.expectedLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (videoBytes == video.expectedLength) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (mediaDuration <= Duration.zero) {
|
||||
return videoBytes >= 24 * 1024 * 1024;
|
||||
}
|
||||
|
||||
final wantedPosition = Duration(
|
||||
milliseconds: math.min(
|
||||
mediaDuration.inMilliseconds,
|
||||
position.inMilliseconds +
|
||||
ahead.inMilliseconds,
|
||||
),
|
||||
);
|
||||
|
||||
final ratio = (
|
||||
wantedPosition.inMilliseconds /
|
||||
mediaDuration.inMilliseconds
|
||||
).clamp(0.0, 1.0);
|
||||
|
||||
// 6 MiB de marge pour l'init/index + variations de bitrate.
|
||||
final estimatedRequired =
|
||||
(video.expectedLength * ratio).ceil() +
|
||||
6 * 1024 * 1024;
|
||||
|
||||
// Même au début, on exige un prébuffer réel pour ne pas reproduire
|
||||
// l'échec 17.15 où mpv ouvrait le flux alors qu'il était vide.
|
||||
final required = math.min(
|
||||
video.expectedLength,
|
||||
math.max(
|
||||
16 * 1024 * 1024,
|
||||
estimatedRequired,
|
||||
),
|
||||
);
|
||||
|
||||
return videoBytes >= required;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
if (processes.isNotEmpty) {
|
||||
await Future<void>.delayed(
|
||||
const Duration(milliseconds: 150),
|
||||
);
|
||||
}
|
||||
|
||||
// Les fichiers COMPLETS restent en cache.
|
||||
// Les fichiers incomplets sont supprimés pour éviter un faux HIT.
|
||||
final video = _video;
|
||||
final audio = _audio;
|
||||
|
||||
_video = null;
|
||||
_audio = null;
|
||||
|
||||
if (video != null) {
|
||||
final length =
|
||||
await _safeLength(video.file);
|
||||
|
||||
if (length != video.expectedLength) {
|
||||
await _deleteIfExists(video.file);
|
||||
}
|
||||
}
|
||||
|
||||
if (audio != null) {
|
||||
final length =
|
||||
await _safeLength(audio.file);
|
||||
|
||||
if (length != audio.expectedLength) {
|
||||
await _deleteIfExists(audio.file);
|
||||
}
|
||||
}
|
||||
|
||||
final cacheId = _activeVideoCacheId;
|
||||
final cacheDirectory = _activeVideoDirectory;
|
||||
_activeVideoCacheId = null;
|
||||
_activeVideoDirectory = null;
|
||||
|
||||
if (cacheDirectory != null) {
|
||||
final cacheSettings = MediaCacheSettingsService.instance;
|
||||
await cacheSettings.touchVideoDirectory(cacheDirectory);
|
||||
await cacheSettings.enforceLimit(keepVideoId: cacheId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startDownload({
|
||||
required _GrowingSource source,
|
||||
required String ytDlpPath,
|
||||
required String denoPath,
|
||||
required String watchUrl,
|
||||
}) async {
|
||||
final args = <String>[
|
||||
'--ignore-config',
|
||||
'--no-playlist',
|
||||
'--no-warnings',
|
||||
'--newline',
|
||||
'--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-PROGRESSIVE][17.19] 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;
|
||||
_activeProcesses.add(process);
|
||||
|
||||
process.stdout
|
||||
.transform(
|
||||
const SystemEncoding().decoder,
|
||||
)
|
||||
.listen(
|
||||
(_) {
|
||||
// Le player affiche l'état à partir de la taille réelle du fichier,
|
||||
// plus fiable que les lignes de progression yt-dlp.
|
||||
},
|
||||
);
|
||||
|
||||
process.stderr
|
||||
.transform(
|
||||
const SystemEncoding().decoder,
|
||||
)
|
||||
.listen(
|
||||
(text) {
|
||||
if (source.stderr.length < 5000) {
|
||||
final remaining =
|
||||
5000 - source.stderr.length;
|
||||
|
||||
source.stderr.write(
|
||||
text.length <= remaining
|
||||
? text
|
||||
: text.substring(0, remaining),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
unawaited(
|
||||
process.exitCode.then(
|
||||
(code) async {
|
||||
_activeProcesses.remove(process);
|
||||
source.exitCode = code;
|
||||
|
||||
final length =
|
||||
await _safeLength(source.file);
|
||||
|
||||
if (code == 0 &&
|
||||
length == source.expectedLength) {
|
||||
debugPrint(
|
||||
'[HD-PROGRESSIVE][17.19] '
|
||||
'${source.id} complet: '
|
||||
'$length/${source.expectedLength}',
|
||||
);
|
||||
} else {
|
||||
final stderr = source.stderr
|
||||
.toString()
|
||||
.replaceAll(
|
||||
RegExp(r'\s+'),
|
||||
' ',
|
||||
)
|
||||
.trim();
|
||||
|
||||
debugPrint(
|
||||
'[HD-PROGRESSIVE][17.19] '
|
||||
'${source.id} fin code=$code '
|
||||
'$length/${source.expectedLength}'
|
||||
'${stderr.isEmpty ? '' : ': $stderr'}',
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleVideoRequest(
|
||||
HttpRequest request,
|
||||
_GrowingSource source,
|
||||
String contentType,
|
||||
) async {
|
||||
if (request.uri.path != '/video') {
|
||||
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.expectedLength,
|
||||
);
|
||||
|
||||
if (rangeHeader != null &&
|
||||
parsed == null) {
|
||||
request.response.statusCode =
|
||||
HttpStatus.requestedRangeNotSatisfiable;
|
||||
|
||||
request.response.headers.set(
|
||||
'content-range',
|
||||
'bytes */${source.expectedLength}',
|
||||
);
|
||||
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
final start = parsed?.$1 ?? 0;
|
||||
final end =
|
||||
parsed?.$2 ??
|
||||
source.expectedLength - 1;
|
||||
|
||||
// Ne pas envoyer de réponse vide à mpv.
|
||||
// Si mpv demande directement un offset légèrement au-delà du buffer
|
||||
// estimé, on attend que yt-dlp atteigne réellement cet offset.
|
||||
await _waitForOffset(
|
||||
source,
|
||||
start,
|
||||
);
|
||||
|
||||
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,
|
||||
contentType,
|
||||
);
|
||||
|
||||
response.contentLength =
|
||||
end - start + 1;
|
||||
|
||||
if (parsed != null) {
|
||||
response.headers.set(
|
||||
'content-range',
|
||||
'bytes $start-$end/'
|
||||
'${source.expectedLength}',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[HD-PROGRESSIVE][17.19] /video '
|
||||
'${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-PROGRESSIVE][17.19] /video: $error',
|
||||
);
|
||||
|
||||
try {
|
||||
await response.close();
|
||||
} catch (_) {
|
||||
// mpv peut fermer la connexion lors d'un seek/changement source.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _waitForOffset(
|
||||
_GrowingSource source,
|
||||
int offset,
|
||||
) async {
|
||||
final deadline =
|
||||
DateTime.now().add(
|
||||
const Duration(seconds: 45),
|
||||
);
|
||||
|
||||
while (true) {
|
||||
final available =
|
||||
await _safeLength(source.file);
|
||||
|
||||
if (available > offset ||
|
||||
available == source.expectedLength) {
|
||||
return;
|
||||
}
|
||||
|
||||
final exitCode = source.exitCode;
|
||||
|
||||
if (exitCode != null &&
|
||||
exitCode != 0) {
|
||||
throw HttpException(
|
||||
'yt-dlp ${source.id} code=$exitCode',
|
||||
);
|
||||
}
|
||||
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
throw TimeoutException(
|
||||
'Offset $offset non disponible '
|
||||
'après 45 s.',
|
||||
);
|
||||
}
|
||||
|
||||
await Future<void>.delayed(
|
||||
const Duration(milliseconds: 40),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: 20),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
response.add(data);
|
||||
await response.flush();
|
||||
|
||||
cursor += data.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
final exitCode = source.exitCode;
|
||||
|
||||
if (exitCode != null) {
|
||||
if (exitCode != 0) {
|
||||
throw HttpException(
|
||||
'yt-dlp ${source.id} code=$exitCode',
|
||||
);
|
||||
}
|
||||
|
||||
if (available <= cursor) {
|
||||
throw HttpException(
|
||||
'EOF progressif à $available, '
|
||||
'offset demandé=$cursor.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Le Player a rejoint le bord du téléchargement.
|
||||
// On attend que yt-dlp reprenne de l'avance.
|
||||
await Future<void>.delayed(
|
||||
const Duration(milliseconds: 30),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return (
|
||||
start,
|
||||
math.min(
|
||||
parsedEnd,
|
||||
totalLength - 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _safeName(
|
||||
String value,
|
||||
) {
|
||||
final safe =
|
||||
value.replaceAll(
|
||||
RegExp(r'[^A-Za-z0-9._-]'),
|
||||
'_',
|
||||
);
|
||||
|
||||
return safe.isEmpty
|
||||
? 'media'
|
||||
: safe;
|
||||
}
|
||||
|
||||
Future<bool> _isComplete(
|
||||
File file,
|
||||
int expectedLength,
|
||||
) async {
|
||||
return await _safeLength(file) ==
|
||||
expectedLength;
|
||||
}
|
||||
|
||||
Future<int> _safeLength(
|
||||
File file,
|
||||
) async {
|
||||
try {
|
||||
if (!await file.exists()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await file.length();
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteIfExists(
|
||||
File file,
|
||||
) async {
|
||||
try {
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {
|
||||
// Une erreur ultérieure sera plus explicite.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _GrowingSource {
|
||||
_GrowingSource({
|
||||
required this.id,
|
||||
required this.file,
|
||||
required this.expectedLength,
|
||||
required this.formatId,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final File file;
|
||||
final int expectedLength;
|
||||
final String formatId;
|
||||
|
||||
Process? process;
|
||||
int? exitCode;
|
||||
final StringBuffer stderr =
|
||||
StringBuffer();
|
||||
}
|
||||
Reference in New Issue
Block a user