Protocol 11: split-track relay (YouTube video+audio), chunked upstream reads

Relayed media can carry 2-4 byte tracks (video, audio): yt-dlp picks a
split pair when no single-file format exists (h264 first), the source
channel serves each track, the server keeps a buffer per track (budget
split by size, 8 MiB floor) and viewers open an mpv edl:// of the track
URLs. Expired URLs re-resolve once for all tracks. Upstream GETs are cut
into <= http_chunk_size (10 MiB) ranges: YouTube throttles larger ones.
Live streams and DRM are refused; HLS/DASH pieces are a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 19:59:41 +03:00
1 parent b2b77a6b3f
commit b038706dff
19 files changed
+945 -286

No files matched your search

+8 -1
View File
@@ -1,5 +1,5 @@
abstract class RelayConstants {
static const int protoVersion = 10;
static const int protoVersion = 11;
// Relay (`/r/...`): a member's bytes through the server's buffer.
static const int relaySliceBytes = 1 << 20;
@@ -12,6 +12,13 @@ abstract class RelayConstants {
/// Kept regardless of position: containers keep their index at the start
/// or the end, and players read both on open and on seeks.
static const int relayPinBytes = 2 << 20;
/// A relayed media of several tracks splits the buffer by bitrate; no
/// track gets less than this ahead or behind.
static const int relayTrackFloorBytes = 8 << 20;
/// Upstream GETs are split into ranges of at most this (yt-dlp's
/// `http_chunk_size` when it gives one): YouTube throttles longer ones to
/// about twice the bitrate.
static const int relayUpstreamChunkBytes = 10 << 20;
static const int relayMinWindowBytes = 256 * 1024;
static const int relayMaxWindowBytes = 8 << 20;
/// A viewer request waits this long for its bytes (source away) before
+98 -56
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'constants.dart';
import 'relay.dart';
import 'yt_dlp.dart';
@@ -70,9 +71,12 @@ String _unescape(String s) => s
String notAVideoText(String? ytDlpError) =>
ytDlpError == null ? 'Not a video' : "Not a video (and yt-dlp couldn't read it: $ytDlpError)";
/// The text for a page with only split or HLS streams.
const noSingleFileText = 'This page has no single-file video to pass on (only separate video and audio '
"streams). Choose 'Everyone fetches it' instead.";
/// The text for a page with no video file a relay can read.
const noRelayFormatText = 'This page has no video file to pass on (only streaming playlists). '
"Choose 'Everyone fetches it' instead.";
/// The text for a live stream.
const liveStreamText = "Live streams can't be passed on. Choose 'Everyone fetches it' instead.";
class LinkProbeException implements Exception {
final String message;
@@ -115,9 +119,11 @@ class LinkProbe {
? null
: info == null
? notAVideoText(ytDlpError)
: info!.single == null
? noSingleFileText
: null;
: info!.isLive
? liveStreamText
: info!.tracks.isEmpty
? noRelayFormatText
: null;
}
const _probeLimit = 512 * 1024;
@@ -184,72 +190,94 @@ Future<String> _readText(HttpClientResponse res) async {
return utf8.decode(out.takeBytes(), allowMalformed: true);
}
/// A page's single-file format (see [YtDlpInfo.single]), range-read with
/// yt-dlp's headers. Format URLs expire: on 403/410 the page is resolved
/// again once and the read goes on from where it stopped.
class PageRelayBytes implements RelayBytes {
/// A page's relay tracks (see [YtDlpInfo.tracks]: one file, or video +
/// audio), range-read with yt-dlp's headers in GETs of the format's
/// `http_chunk_size`. Format URLs expire: on 403/410 from any track the
/// page is resolved again once for all tracks (same formats, same sizes)
/// and the read goes on from where it stopped.
class PageRelayMedia implements RelayMedia {
final Uri page;
final YtDlp ytDlp;
YtDlpFormat? _format;
HttpRelayBytes? _inner;
int? _size;
List<YtDlpTrack>? _picked;
List<HttpRelayBytes?> _inner = const [];
List<RelayTrack>? _tracks;
final _sizes = <int, int>{};
var _gen = 0;
Future<void>? _refreshing;
PageRelayBytes(this.page, this.ytDlp, {YtDlpFormat? format}) : _format = format;
PageRelayMedia(this.page, this.ytDlp, {List<YtDlpTrack>? tracks}) : _picked = tracks;
Future<YtDlpFormat> _resolve() async {
final f = (await ytDlp.resolve(page.toString())).single;
if (f == null) throw const LinkProbeException(noSingleFileText);
return f;
Future<YtDlpInfo> _info() async {
final info = await ytDlp.resolve(page.toString());
if (info.isLive) throw const LinkProbeException(liveStreamText);
return info;
}
Future<HttpRelayBytes> _open() async =>
_inner ??= () {
final f = _format!;
return HttpRelayBytes(f.url, headers: f.headers);
@override
Future<List<RelayTrack>> open() async {
if (_tracks != null) return _tracks!;
final picked = _picked ??= (await _info()).tracks;
if (picked.isEmpty) throw const LinkProbeException(noRelayFormatText);
_inner = List.filled(picked.length, null, growable: false);
return _tracks = [for (var i = 0; i < picked.length; i++) RelayTrack(picked[i].kind, _PageTrack(this, i))];
}
HttpRelayBytes _open(int i) => _inner[i] ??= () {
final f = _picked![i].format;
return HttpRelayBytes(f.url,
headers: f.headers, chunkSize: f.chunkSize ?? RelayConstants.relayUpstreamChunkBytes);
}();
static bool _expired(Object e) => e is UpstreamHttpException && (e.status == 403 || e.status == 410);
/// Resolves the page again: the same format ids with the same sizes,
/// else the video changed.
Future<void> _refresh(int gen) {
if (gen != _gen) return Future.value();
return _refreshing ??= () async {
try {
final f = await _resolve();
final info = await _info();
final next = <YtDlpTrack>[];
for (final t in _picked!) {
final f = info.formats[t.format.id];
if (f == null) throw const LinkProbeException('the video changed on the page');
next.add((kind: t.kind, format: f));
}
final old = _inner;
_format = f;
_inner = null;
_picked = next;
_inner = List.filled(next.length, null, growable: false);
for (final e in _sizes.entries) {
if (await _open(e.key).size() != e.value) throw const LinkProbeException('the video changed on the page');
}
_gen++;
await old?.close();
for (final b in old) {
await b?.close();
}
} finally {
_refreshing = null;
}
}();
}
@override
Future<int> size() async {
if (_size != null) return _size!;
_format ??= await _resolve();
Future<int> _size(int i) async {
final known = _sizes[i];
if (known != null) return known;
final gen = _gen;
try {
return _size = await (await _open()).size();
return _sizes[i] = await _open(i).size();
} catch (e) {
if (!_expired(e)) rethrow;
await _refresh(gen);
return _size = await (await _open()).size();
return _sizes[i] = await _open(i).size();
}
}
@override
Stream<List<int>> read(int start, int end) async* {
Stream<List<int>> _read(int i, int start, int end) async* {
var pos = start;
var retried = false;
while (true) {
final gen = _gen;
final inner = await _open();
try {
await for (final chunk in inner.read(pos, end)) {
await for (final chunk in _open(i).read(pos, end)) {
pos += chunk.length;
yield chunk;
}
@@ -258,45 +286,59 @@ class PageRelayBytes implements RelayBytes {
if (retried || !_expired(e)) rethrow;
retried = true;
await _refresh(gen);
final n = await (await _open()).size();
if (_size != null && n != _size) throw const LinkProbeException('the video changed on the page');
}
}
}
@override
Future<void> close() async => _inner?.close();
Future<void> close() async {
for (final b in _inner) {
await b?.close();
}
}
}
/// A link of either kind: probed on first [size] (or given [probe]), then
/// read as a file ([HttpRelayBytes]) or a page ([PageRelayBytes]). Throws
/// [LinkProbeException] with the reason when it cannot be relayed.
class LinkRelayBytes implements RelayBytes {
class _PageTrack implements RelayBytes {
final PageRelayMedia media;
final int index;
_PageTrack(this.media, this.index);
@override
Future<int> size() => media._size(index);
@override
Stream<List<int>> read(int start, int end) => media._read(index, start, end);
/// The media closes its tracks.
@override
Future<void> close() async {}
}
/// A link of either kind, probed on first [open] (or given [probe]): a
/// file as one track ([HttpRelayBytes]), a page as its picked formats
/// ([PageRelayMedia]). Throws [LinkProbeException] with the reason when it
/// cannot be relayed.
class LinkRelayMedia implements RelayMedia {
final Uri url;
final YtDlp? ytDlp;
LinkProbe? _probe;
RelayBytes? _inner;
LinkRelayBytes(this.url, {this.ytDlp, LinkProbe? probe}) : _probe = probe;
RelayMedia? _inner;
LinkRelayMedia(this.url, {this.ytDlp, LinkProbe? probe}) : _probe = probe;
/// The probe, after [size].
/// The probe, after [open].
LinkProbe? get probe => _probe;
@override
Future<int> size() async {
Future<List<RelayTrack>> open() async {
if (_inner == null) {
final probe = _probe ??= await probeLink(url, ytDlp: ytDlp);
final problem = probe.relayProblem;
if (problem != null) throw LinkProbeException(problem);
_inner = probe.isPage ? PageRelayBytes(url, ytDlp!, format: probe.info!.single) : HttpRelayBytes(url);
_inner = probe.isPage
? PageRelayMedia(url, ytDlp!, tracks: probe.info!.tracks)
: RelayMedia.single(HttpRelayBytes(url));
}
return _inner!.size();
}
@override
Stream<List<int>> read(int start, int end) {
final inner = _inner;
if (inner == null) return Stream.error(StateError('size() first'));
return inner.read(start, end);
return _inner!.open();
}
@override
+24 -6
View File
@@ -149,24 +149,42 @@ class HelloMedia {
/// or, with [fetch], the server reads the swap's link itself. A swap sends
/// `{}` or `{"fetch":true}`; the server fills in [source] (the swapper's
/// member id, empty with [fetch]) and whether bytes can flow ([live]).
/// [tracks]: the kinds (`video`, `audio`) of a media relayed as several
/// tracks played together; empty for one file. A member swap names them,
/// the server fills them in for [fetch] once it has read the link.
class MediaRelay {
final String source;
final bool fetch;
final bool live;
const MediaRelay({this.source = '', this.fetch = false, this.live = false});
final List<String> tracks;
const MediaRelay({this.source = '', this.fetch = false, this.live = false, this.tracks = const []});
MediaRelay copyWith({bool? live}) => MediaRelay(source: source, fetch: fetch, live: live ?? this.live);
static const trackKinds = {'video', 'audio'};
static const maxTracks = 4;
MediaRelay copyWith({bool? live, List<String>? tracks}) =>
MediaRelay(source: source, fetch: fetch, live: live ?? this.live, tracks: tracks ?? this.tracks);
Map<String, Object?> toJson() => {
if (source.isNotEmpty) 'source': source,
if (fetch) 'fetch': true,
if (live) 'live': true,
if (tracks.length > 1) 'tracks': [for (final k in tracks) {'kind': k}],
};
factory MediaRelay.fromJson(Map<String, Object?> json) => MediaRelay(
source: json['source'] is String ? json['source'] as String : '',
fetch: json['fetch'] == true,
live: json['live'] == true);
/// Unknown kinds, a single entry or too many make it one file.
factory MediaRelay.fromJson(Map<String, Object?> json) {
final t = json['tracks'];
final kinds = t is List
? [for (final e in t) e is Map && e['kind'] is String ? e['kind'] as String : '']
: <String>[];
final ok = kinds.length > 1 && kinds.length <= maxTracks && kinds.every(trackKinds.contains);
return MediaRelay(
source: json['source'] is String ? json['source'] as String : '',
fetch: json['fetch'] == true,
live: json['live'] == true,
tracks: ok ? List.unmodifiable(kinds) : const []);
}
}
class HelloMessage {
+117 -25
View File
@@ -11,19 +11,22 @@ import 'logging.dart';
// The relay channel: a member's machine (the source) serves the current
// media's bytes to the server, which buffers them for every viewer (`/r/...`).
// The source dials out (works behind NAT/CGNAT) on its own WebSocket, so
// video bytes never queue behind `/sync` messages.
// video bytes never queue behind `/sync` messages. A media is one track (a
// file) or several (a page's separate video and audio).
//
// source -> server {"t":"info","size":N} first frame
// source -> server {"t":"info","size":N} first frame, one track
// {"t":"info","tracks":[{"kind":"video","size":N},...]}
// binary [u32 BE pull id][bytes] in order per pull
// {"t":"end","id":1} pull complete
// {"t":"fail","id":1,"reason":"..."} cannot read
// server -> source {"t":"pull","id":1,"start":0,"end":N} bytes start..end
// server -> source {"t":"pull","id":1,"track":0,"start":0,"end":N}
// bytes start..end
// {"t":"ack","id":1,"n":65536} n bytes arrived
// {"t":"cancel","id":1} stop this pull
//
// Flow control is on the source: one adaptive in-flight budget per
// connection (see [RelayWindow]); a cancelled pull's unacked bytes are
// written off.
// Pull ids are unique across tracks. Flow control is on the source: one
// adaptive in-flight budget per connection (see [RelayWindow]), shared by
// the tracks; a cancelled pull's unacked bytes are written off.
final _log = createLogger('relay');
@@ -32,6 +35,21 @@ final _log = createLogger('relay');
/// slash still splits the path). The server ignores the segment.
String relayUrlName(String name) => Uri.encodeComponent(name.replaceAll(RegExp(r'[/\\]'), '_'));
/// What a viewer opens for relayed media at [base]
/// (`http://<host>/r/<sid>/<token>/<mediaId>`): `<base>/<name>` for one
/// track, else an mpv EDL playing `<base>/<i>/<name>` of every track
/// together (see [mpvEdl]).
String relayViewerUrl(String base, String name, {int tracks = 1}) {
final n = relayUrlName(name);
return tracks <= 1 ? '$base/$n' : mpvEdl([for (var i = 0; i < tracks; i++) '$base/$i/$n']);
}
/// An mpv `edl://` URL playing [urls] as the streams of one file (a video
/// track and its audio track), each `%<byte length>%`-framed so `;` and
/// `,` inside a URL are safe.
String mpvEdl(List<String> urls) =>
'edl://${urls.map((u) => '!no_clip;%${utf8.encode(u).length}%$u').join(';!new_stream;')}';
/// Binary relay frame: 4-byte big-endian pull id, then payload.
Uint8List encodeRelayFrame(int id, List<int> payload) {
final out = Uint8List(4 + payload.length);
@@ -78,13 +96,17 @@ class UpstreamHttpException extends HttpException {
}
/// An HTTP(S) resource with Range support, e.g. a link or a Jellyfin
/// stream; [headers] (auth) never leave this machine.
/// stream; [headers] (auth) never leave this machine. A read is one GET
/// per [chunkSize] bytes, back to back (see
/// [RelayConstants.relayUpstreamChunkBytes]).
class HttpRelayBytes implements RelayBytes {
final Uri url;
final Map<String, String> headers;
final int chunkSize;
final HttpClient _client;
int? _size;
HttpRelayBytes(this.url, {this.headers = const {}, HttpClient? client})
HttpRelayBytes(this.url,
{this.headers = const {}, this.chunkSize = RelayConstants.relayUpstreamChunkBytes, HttpClient? client})
: _client = client ?? HttpClient();
Future<HttpClientResponse> _get(String range) async {
@@ -118,11 +140,33 @@ class HttpRelayBytes implements RelayBytes {
@override
Stream<List<int>> read(int start, int end) async* {
final res = await _get('bytes=$start-$end');
if (res.statusCode == 200 && start > 0) {
throw HttpException('upstream ignores Range', uri: url);
var pos = start;
while (pos <= end) {
final last = chunkSize > 0 ? min(end, pos + chunkSize - 1) : end;
final res = await _get('bytes=$pos-$last');
if (res.statusCode == 200) {
if (pos > 0) {
await res.drain<void>().catchError((_) {});
throw HttpException('upstream ignores Range', uri: url);
}
// The whole file from 0: take what was asked in this one answer.
await for (final c in res) {
final take = min(c.length, end - pos + 1);
yield take == c.length ? c : c.sublist(0, take);
pos += take;
if (pos > end) return;
}
if (pos <= end) throw HttpException('upstream ended early', uri: url);
return;
}
await for (final c in res) {
final take = min(c.length, last - pos + 1);
if (take <= 0) break;
yield take == c.length ? c : c.sublist(0, take);
pos += take;
}
if (pos <= last) throw HttpException('upstream ended early', uri: url);
}
yield* res;
}
@override
@@ -200,6 +244,38 @@ class RelayWindow {
}
}
/// One byte stream of a relayed media: [kind] is `video` or `audio` when
/// a media has several, empty for a single file.
class RelayTrack {
final String kind;
final RelayBytes bytes;
const RelayTrack(this.kind, this.bytes);
}
/// What a relay serves: one track, or several played together (a page's
/// separate video and audio).
abstract interface class RelayMedia {
/// The tracks, in order; a link is resolved on the first call. Throws
/// with the reason when it cannot be relayed.
Future<List<RelayTrack>> open();
Future<void> close();
/// One file.
factory RelayMedia.single(RelayBytes bytes) = _SingleRelayMedia;
}
class _SingleRelayMedia implements RelayMedia {
final RelayBytes bytes;
_SingleRelayMedia(this.bytes);
@override
Future<List<RelayTrack>> open() async => [RelayTrack('', bytes)];
@override
Future<void> close() => bytes.close();
}
class _Pull {
final int id;
var sent = 0, acked = 0;
@@ -213,14 +289,14 @@ class _Pull {
/// the current media": the source stops instead of reconnecting.
const relayCloseNotSource = 4001;
/// Serves [bytes] on the relay channel at [url] (`ws://host/r/<sid>/<token>/source`)
/// Serves [media] on the relay channel at [url] (`ws://host/r/<sid>/<token>/source`)
/// until [close]. Reconnects with backoff when the channel drops; stops for
/// good when the server closes it with [relayCloseNotSource].
class RelaySource {
final Uri url;
final RelayBytes bytes;
final RelayMedia media;
final Duration Function(int attempt) backoff;
RelaySource(this.url, this.bytes, {Duration Function(int attempt)? backoff})
RelaySource(this.url, this.media, {Duration Function(int attempt)? backoff})
: backoff = backoff ?? ((a) => Duration(milliseconds: min(8000, 500 * (1 << min(a, 4)))));
final window = RelayWindow();
@@ -235,12 +311,26 @@ class RelaySource {
/// Bytes sent to the server, for stats.
int bytesSent = 0;
var _tracks = const <RelayTrack>[];
/// Opens the media (a page is resolved) and starts serving; throws
/// when it cannot be read.
Future<void> start() async {
final size = await bytes.size();
unawaited(_loop(size));
final tracks = await media.open();
final sizes = [for (final t in tracks) await t.bytes.size()];
_tracks = tracks;
unawaited(_loop({
't': 'info',
if (tracks.length == 1)
'size': sizes.single
else
'tracks': [
for (var i = 0; i < tracks.length; i++) {'kind': tracks[i].kind, 'size': sizes[i]},
],
}));
}
Future<void> _loop(int size) async {
Future<void> _loop(Map<String, Object?> info) async {
var attempt = 0;
while (!_closed) {
try {
@@ -251,7 +341,7 @@ class RelaySource {
}
_ws = ws;
attempt = 0;
ws.add(jsonEncode({'t': 'info', 'size': size}));
ws.add(jsonEncode(info));
await for (final m in ws) {
if (m is String) _onText(ws, m);
}
@@ -268,7 +358,7 @@ class RelaySource {
await Future<void>.delayed(backoff(attempt++));
}
_abandonAll();
await bytes.close();
await media.close();
if (!_done.isCompleted) _done.complete();
}
@@ -283,11 +373,12 @@ class RelaySource {
if (id is! int) return;
switch (j['t']) {
case 'pull':
final start = j['start'], end = j['end'];
final start = j['start'], end = j['end'], track = j['track'] ?? 0;
if (start is! int || end is! int || start < 0 || end < start) return;
if (track is! int || track < 0 || track >= _tracks.length) return;
final p = _Pull(id);
_pulls[id] = p;
unawaited(_serve(ws, p, start, end));
unawaited(_serve(ws, p, _tracks[track].bytes, start, end));
case 'ack':
final p = _pulls[id];
final n = j['n'];
@@ -307,7 +398,8 @@ class RelaySource {
p.cancelled = true;
window.writeOff(p.sent - p.acked);
p.acked = p.sent;
unawaited(p.it?.cancel());
// A read cut off mid-request (the client closing) errors here: moot.
unawaited(p.it?.cancel().catchError((_) {}));
}
void _abandonAll() {
@@ -317,7 +409,7 @@ class RelaySource {
_pulls.clear();
}
Future<void> _serve(WebSocket ws, _Pull p, int start, int end) async {
Future<void> _serve(WebSocket ws, _Pull p, RelayBytes bytes, int start, int end) async {
final it = p.it = StreamIterator(bytes.read(start, end));
final pending = BytesBuilder(copy: false);
const frame = RelayConstants.relayFrameBytes;
@@ -352,7 +444,7 @@ class RelaySource {
} finally {
p.finished = true;
if (p.settled) _pulls.remove(p.id);
await it.cancel();
await it.cancel().catchError((_) {});
}
}
+140 -40
View File
@@ -31,9 +31,32 @@ class YtDlpFormat {
final Map<String, String> headers;
final int? height;
final String ext;
const YtDlpFormat({required this.id, required this.url, this.headers = const {}, this.height, this.ext = ''});
/// yt-dlp's `downloader_options.http_chunk_size`: the longest GET the
/// site serves at full speed.
final int? chunkSize;
const YtDlpFormat(
{required this.id, required this.url, this.headers = const {}, this.height, this.ext = '', this.chunkSize});
static YtDlpFormat _of(Map f) {
final headers = f['http_headers'];
final opts = f['downloader_options'];
final chunk = opts is Map ? opts['http_chunk_size'] : null;
final h = f['height'] is num ? (f['height'] as num).toInt() : 0;
return YtDlpFormat(
id: '${f['format_id'] ?? ''}',
url: Uri.parse(f['url'] as String),
headers: headers is Map ? {for (final e in headers.entries) '${e.key}': '${e.value}'} : const {},
height: h == 0 ? null : h,
ext: '${f['ext']}',
chunkSize: chunk is num && chunk > 0 ? chunk.toInt() : null,
);
}
}
/// A format a relay plays as one of a media's tracks ([kind] `video` or
/// `audio`; empty for a single file holding both).
typedef YtDlpTrack = ({String kind, YtDlpFormat format});
/// What `yt-dlp -J` says about a page.
class YtDlpInfo {
final String title;
@@ -43,10 +66,15 @@ class YtDlpInfo {
/// Seconds; null when unknown (a live stream).
final double? duration;
final String? thumbnail;
/// The best format holding video and audio in one http(s) file (what a
/// relay can range-read); null when the page only has split or HLS
/// streams.
final YtDlpFormat? single;
/// A live stream (never relayed).
final bool isLive;
/// What a relay range-reads (see [pickTracks]): one file, or a video and
/// an audio file; empty when the page has neither (only streaming
/// playlists, or live).
final List<YtDlpTrack> tracks;
/// Every plain http(s) format by id, to find [tracks] again after a
/// re-resolve.
final Map<String, YtDlpFormat> formats;
const YtDlpInfo({
required this.title,
this.uploader = '',
@@ -54,7 +82,9 @@ class YtDlpInfo {
this.webpageUrl = '',
this.duration,
this.thumbnail,
this.single,
this.isLive = false,
this.tracks = const [],
this.formats = const {},
});
/// Parses `yt-dlp -J` output; a playlist answer uses its first entry.
@@ -67,6 +97,7 @@ class YtDlpInfo {
}
String str(String k) => j[k] is String ? (j[k] as String).trim() : '';
final formats = j['formats'] is List ? (j['formats'] as List) : [if (j['url'] is String) j];
final isLive = j['is_live'] == true || j['live_status'] == 'is_live' || j['live_status'] == 'is_upcoming';
return YtDlpInfo(
title: str('title').isNotEmpty ? str('title') : str('fulltitle'),
uploader: str('uploader').isNotEmpty ? str('uploader') : str('channel'),
@@ -74,53 +105,122 @@ class YtDlpInfo {
webpageUrl: str('webpage_url'),
duration: j['duration'] is num ? (j['duration'] as num).toDouble() : null,
thumbnail: str('thumbnail').isEmpty ? null : str('thumbnail'),
single: pickSingleFile(formats),
isLive: isLive,
tracks: isLive ? const [] : pickTracks(formats),
formats: {
for (final f in formats)
if (_httpUrl(f) && f is Map) '${f['format_id'] ?? ''}': YtDlpFormat._of(f),
},
);
}
}
const _singleFileExts = {'mp4', 'webm', 'mov', 'm4v', 'mkv'};
const _audioExts = {'m4a', 'mp4', 'webm', 'opus', 'ogg', 'mp3'};
/// A plain http(s) format with a URL, not DRM-protected.
bool _httpUrl(Object? f) {
if (f is! Map || f['has_drm'] == true) return false;
final url = f['url'];
final protocol = f['protocol'];
if (url is! String || (protocol != 'http' && protocol != 'https')) return false;
final uri = Uri.tryParse(url);
return uri != null && (uri.isScheme('http') || uri.isScheme('https'));
}
int _height(Map f) => f['height'] is num ? (f['height'] as num).toInt() : 0;
double _tbr(Map f) =>
f['tbr'] is num ? (f['tbr'] as num).toDouble() : (f['abr'] is num ? (f['abr'] as num).toDouble() : 0);
/// Height order: up to [maxHeight] the tallest first, then the shortest
/// above it.
int _byHeight(int ha, int hb, int maxHeight) {
final ina = ha <= maxHeight, inb = hb <= maxHeight;
if (ina != inb) return ina ? -1 : 1;
return ha == hb ? 0 : (ina ? hb.compareTo(ha) : ha.compareTo(hb));
}
/// The best single-file format of [formats] (yt-dlp format maps): video
/// and audio both present (or not known to be absent), plain http(s), a
/// video container; the tallest up to [maxHeight], else the shortest above
/// it; mp4/webm and higher bitrate break ties.
YtDlpFormat? pickSingleFile(List<Object?> formats, {int maxHeight = 1080}) {
final ok = <(Map, int)>[];
for (final f in formats) {
if (f is! Map) continue;
final url = f['url'];
final protocol = f['protocol'];
if (url is! String || (protocol != 'http' && protocol != 'https')) continue;
if (f['vcodec'] == 'none' || f['acodec'] == 'none') continue;
if (!_singleFileExts.contains(f['ext'])) continue;
final uri = Uri.tryParse(url);
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) continue;
ok.add((f, f['height'] is num ? (f['height'] as num).toInt() : 0));
}
final ok = [
for (final f in formats)
if (_httpUrl(f) && (f as Map)['vcodec'] != 'none' && f['acodec'] != 'none' && _singleFileExts.contains(f['ext']))
f,
];
if (ok.isEmpty) return null;
double tbr(Map f) => f['tbr'] is num ? (f['tbr'] as num).toDouble() : 0;
int rank((Map, int) a, (Map, int) b) {
final (fa, ha) = a;
final (fb, hb) = b;
final ina = ha <= maxHeight, inb = hb <= maxHeight;
if (ina != inb) return ina ? -1 : 1;
if (ha != hb) return ina ? hb.compareTo(ha) : ha.compareTo(hb);
final ma = fa['ext'] == 'mp4' || fa['ext'] == 'webm', mb = fb['ext'] == 'mp4' || fb['ext'] == 'webm';
if (ma != mb) return ma ? -1 : 1;
return tbr(fb).compareTo(tbr(fa));
}
bool mp4(Map f) => f['ext'] == 'mp4' || f['ext'] == 'webm';
ok.sort((a, b) {
final h = _byHeight(_height(a), _height(b), maxHeight);
if (h != 0) return h;
if (mp4(a) != mp4(b)) return mp4(a) ? -1 : 1;
return _tbr(b).compareTo(_tbr(a));
});
return YtDlpFormat._of(ok.first);
}
ok.sort(rank);
final (f, h) = ok.first;
final headers = f['http_headers'];
return YtDlpFormat(
id: '${f['format_id'] ?? ''}',
url: Uri.parse(f['url'] as String),
headers: headers is Map ? {for (final e in headers.entries) '${e.key}': '${e.value}'} : const {},
height: h == 0 ? null : h,
ext: '${f['ext']}',
);
int _videoCodecRank(Map f) {
final c = '${f['vcodec']}'.toLowerCase();
if (c.startsWith('avc') || c.startsWith('h264')) return 0;
if (c.startsWith('vp9') || c.startsWith('vp09')) return 1;
if (c.startsWith('av01')) return 2;
return 3;
}
/// The best video-only + audio-only pair of plain http(s) files (YouTube's
/// DASH formats), or null. Video: the tallest up to [maxHeight] (else the
/// shortest above), avc1 before vp9 before av01 (hardware decoding), then
/// bitrate. Audio: the original language, not a `-drc` variant, m4a/aac
/// before others (opus), then bitrate.
(YtDlpFormat, YtDlpFormat)? pickSplit(List<Object?> formats, {int maxHeight = 1080}) {
final video = [
for (final f in formats)
if (_httpUrl(f) && (f as Map)['acodec'] == 'none' && f['vcodec'] is String && f['vcodec'] != 'none' &&
_singleFileExts.contains(f['ext']))
f,
];
final audio = [
for (final f in formats)
if (_httpUrl(f) && (f as Map)['vcodec'] == 'none' && f['acodec'] is String && f['acodec'] != 'none' &&
_audioExts.contains(f['ext']))
f,
];
if (video.isEmpty || audio.isEmpty) return null;
video.sort((a, b) {
final h = _byHeight(_height(a), _height(b), maxHeight);
if (h != 0) return h;
final c = _videoCodecRank(a).compareTo(_videoCodecRank(b));
return c != 0 ? c : _tbr(b).compareTo(_tbr(a));
});
bool drc(Map f) => '${f['format_id']}'.contains('drc');
int lang(Map f) => f['language_preference'] is num ? (f['language_preference'] as num).toInt() : 0;
bool aac(Map f) => f['ext'] == 'm4a' || '${f['acodec']}'.startsWith('mp4a');
audio.sort((a, b) {
if (drc(a) != drc(b)) return drc(a) ? 1 : -1;
final l = lang(b).compareTo(lang(a));
if (l != 0) return l;
if (aac(a) != aac(b)) return aac(a) ? -1 : 1;
return _tbr(b).compareTo(_tbr(a));
});
return (YtDlpFormat._of(video.first), YtDlpFormat._of(audio.first));
}
/// What a relay reads of [formats]: the single file ([pickSingleFile])
/// unless a video + audio pair ([pickSplit]) is taller (both by the
/// [maxHeight] rule); empty when there is neither.
List<YtDlpTrack> pickTracks(List<Object?> formats, {int maxHeight = 1080}) {
final single = pickSingleFile(formats, maxHeight: maxHeight);
final split = pickSplit(formats, maxHeight: maxHeight);
if (split != null &&
(single == null ||
(single.height != null &&
split.$1.height != null &&
_byHeight(split.$1.height!, single.height!, maxHeight) < 0))) {
return [(kind: 'video', format: split.$1), (kind: 'audio', format: split.$2)];
}
return single == null ? const [] : [(kind: '', format: single)];
}
/// The official standalone build for this platform, or null if there is
+77 -6
View File
@@ -1,19 +1,21 @@
// yt-dlp output parsing, the single-file pick and the link classifier.
// yt-dlp output parsing, the relay track pick, chunked upstream reads and
// the link classifier.
// The live test (real download from GitHub, a real tweet) runs only with
// YTDLP_LIVE=1.
library;
import 'dart:ffi' show Abi;
import 'dart:io';
import 'dart:math';
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
Map<String, Object?> _f(String id, String protocol, Object? vcodec, Object? acodec, int? height,
{String ext = 'mp4', num tbr = 0}) =>
{String ext = 'mp4', num tbr = 0, Map<String, Object?> extra = const {}}) =>
{
'format_id': id, 'protocol': protocol, 'vcodec': vcodec, 'acodec': acodec, 'height': height, //
'ext': ext, 'tbr': tbr, 'url': 'https://cdn.example/$id', 'http_headers': {'User-Agent': 'UA'},
'ext': ext, 'tbr': tbr, 'url': 'https://cdn.example/$id', 'http_headers': {'User-Agent': 'UA'}, ...extra,
};
void main() {
@@ -68,11 +70,79 @@ void main() {
},
],
});
expect((info.title, info.uploader, info.duration, info.thumbnail, info.extractor, info.single?.id),
expect((info.title, info.uploader, info.duration, info.thumbnail, info.extractor, info.tracks.single.format.id),
('a girl. - https://t.co/x', 'a girl.', 6.3, 'https://pbs/x.jpg', 'Twitter', 'http-2176'));
});
});
group('relay tracks', () {
// YouTube now: only video-only and audio-only https files (+ HLS).
final youtube = [
_f('139', 'https', 'none', 'mp4a.40.5', null, ext: 'm4a', tbr: 49),
_f('140', 'https', 'none', 'mp4a.40.2', null, ext: 'm4a', tbr: 129),
_f('140-drc', 'https', 'none', 'mp4a.40.2', null, ext: 'm4a', tbr: 129),
_f('251', 'https', 'none', 'opus', null, ext: 'webm', tbr: 135),
_f('248', 'https', 'vp9', 'none', 1080, ext: 'webm', tbr: 2600),
_f('137', 'https', 'avc1.640028', 'none', 1080, tbr: 4400),
_f('299', 'https', 'avc1.64002a', 'none', 1080, tbr: 6000,
extra: {'downloader_options': {'http_chunk_size': 10485760}}),
_f('401', 'https', 'av01.0.12M.08', 'none', 2160, tbr: 18000),
_f('96', 'm3u8_native', 'avc1', 'mp4a', 1080),
];
test('YouTube: avc1 video up to 1080p (highest bitrate) + m4a audio, with the chunk size', () {
final t = pickTracks(youtube);
expect([for (final x in t) (x.kind, x.format.id)], [('video', '299'), ('audio', '140')]);
expect(t.first.format.chunkSize, 10485760);
expect(YtDlpInfo.parse({'title': 'x', 'formats': youtube}).formats['137']?.height, 1080);
});
test('a single file wins unless the pair is taller; nothing relayable: empty; live: none', () {
final single = _f('22', 'https', 'avc1', 'mp4a', 720);
expect(pickTracks([single, ...youtube]).map((x) => x.kind), ['video', 'audio']);
expect(pickTracks([_f('18', 'https', 'avc1', 'mp4a', 1080), ...youtube]).single.format.id, '18');
expect(pickTracks([_f('96', 'm3u8_native', 'avc1', 'mp4a', 1080)]), isEmpty);
final live = YtDlpInfo.parse({'title': 'x', 'is_live': true, 'formats': youtube});
expect(live.isLive, isTrue);
expect(live.tracks, isEmpty);
expect(LinkProbe.page(Uri.parse('https://x'), info: live).relayProblem, liveStreamText);
});
test('vp9 when there is no avc1; the drc variant only when alone', () {
final t = pickTracks([youtube[2], youtube[4]]);
expect([for (final x in t) x.format.id], ['248', '140-drc']);
});
});
test('viewer URL: one track plain, several an mpv EDL with %length% framing', () {
expect(relayViewerUrl('http://h:1/r/default/t/3', 'a/b c'), 'http://h:1/r/default/t/3/a_b%20c');
const v = 'http://h:1/r/default/t/3/0/x', a = 'http://h:1/r/default/t/3/1/x';
expect(relayViewerUrl('http://h:1/r/default/t/3', 'x', tracks: 2),
'edl://!no_clip;%${v.length}%$v;!new_stream;!no_clip;%${a.length}%$a');
expect(mpvEdl(['http://x/a;b,c']), 'edl://!no_clip;%14%http://x/a;b,c');
});
test('upstream reads go in GETs of at most the chunk size, back to back', () async {
final data = List.generate(2500, (i) => i % 251);
final seen = <String>[];
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(() => server.close(force: true));
server.listen((req) async {
final m = RegExp(r'bytes=(\d+)-(\d+)').firstMatch(req.headers.value('range')!)!;
final s = int.parse(m.group(1)!), e = min(int.parse(m.group(2)!), data.length - 1);
seen.add('$s-$e');
req.response
..statusCode = 206
..headers.set('content-range', 'bytes $s-$e/${data.length}')
..add(data.sublist(s, e + 1));
await req.response.close();
});
final b = HttpRelayBytes(Uri.parse('http://127.0.0.1:${server.port}/f.mp4'), chunkSize: 1000);
addTearDown(b.close);
expect([for (final c in await b.read(100, 2400).toList()) ...c], data.sublist(100, 2401));
expect(seen, ['100-1099', '1100-2099', '2100-2400']);
});
test('link classifier: media types and media file names are files; pages, HLS, errors are not', () {
bool file(int status, String type, String url) =>
looksLikeMediaFile(status: status, contentType: type, url: Uri.parse(url));
@@ -120,13 +190,14 @@ void main() {
expect(probe.isPage, isTrue);
expect(probe.title, startsWith('a girl.'));
expect(probe.relayProblem, isNull);
final bytes = LinkRelayBytes(url, ytDlp: y, probe: probe);
final media = LinkRelayMedia(url, ytDlp: y, probe: probe);
final bytes = (await media.open()).single.bytes;
final size = await bytes.size();
var n = 0;
await for (final c in bytes.read(0, size - 1)) {
n += c.length;
}
await bytes.close();
await media.close();
expect(n, size);
}, timeout: const Timeout(Duration(minutes: 3)), skip: Platform.environment['YTDLP_LIVE'] == '1' ? false : 'YTDLP_LIVE not set');
}