Files
youtube_personal/lib/services/windows_embedded_progressive_service.dart

938 lines
21 KiB
Dart

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();
}