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');
}
+24 -10
View File
@@ -31,7 +31,7 @@ class LocalRelayFeed extends RelayFeed {
_subs[id] = bytes.read(start, end).listen(
(c) => buffer.feedBytes(id, c is Uint8List ? c : Uint8List.fromList(c)),
onError: (Object e) {
_subs.remove(id)?.cancel();
unawaited(_subs.remove(id)?.cancel().catchError((_) {}));
buffer.feedFail(id, '$e');
},
onDone: () {
@@ -42,26 +42,29 @@ class LocalRelayFeed extends RelayFeed {
);
}
/// A read cut off mid-request errors on cancel: moot.
@override
void cancel(int id) => _subs.remove(id)?.cancel();
void cancel(int id) => unawaited(_subs.remove(id)?.cancel().catchError((_) {}));
Future<void> close() async {
for (final s in _subs.values) {
await s.cancel();
await s.cancel().catchError((_) {});
}
_subs.clear();
await bytes.close();
}
}
/// A relay channel (`/r/.../source`) as a feed.
/// A relay channel (`/r/.../source`) as a feed: [track] of the source's
/// media (null: a single file, the pull names no track).
class ChannelRelayFeed extends RelayFeed {
final void Function(String text) send;
ChannelRelayFeed(this.send);
final int? track;
ChannelRelayFeed(this.send, {this.track});
@override
void pull(int id, int start, int end) =>
send(jsonEncode({'t': 'pull', 'id': id, 'start': start, 'end': end}));
send(jsonEncode({'t': 'pull', 'id': id, if (track != null) 'track': track, 'start': start, 'end': end}));
@override
void cancel(int id) => send(jsonEncode({'t': 'cancel', 'id': id}));
@@ -100,14 +103,24 @@ class _Viewer {
/// for anything; older ones still get bytes once they are buffered.
/// Everything lives in memory: dropping the buffer is the cleanup.
class RelayBuffer {
final int ahead, back, pin;
/// Set by the relay when a media's tracks share the budget.
int ahead, back;
final int pin;
final Duration waitLimit;
/// Pull ids; the tracks of one media share one sequence (one channel).
final int Function() _newId;
RelayBuffer({
this.ahead = RelayConstants.relayAheadBytes,
this.back = RelayConstants.relayBackBytes,
this.pin = RelayConstants.relayPinBytes,
this.waitLimit = const Duration(seconds: RelayConstants.relayWaitSecs),
});
int Function()? nextId,
}) : _newId = nextId ?? _counter();
static int Function() _counter() {
var n = 0;
return () => ++n;
}
static const slice = RelayConstants.relaySliceBytes;
@@ -118,7 +131,7 @@ class RelayBuffer {
final _blocked = <int, int>{}; // slice -> wait start (us)
final _viewers = <String, _Viewer>{};
_Pull? _pull;
var _nextId = 1, _reqSeq = 0;
var _reqSeq = 0;
var _disposed = false;
Completer<void> _kick = Completer();
final Completer<void> _ready = Completer();
@@ -217,6 +230,7 @@ class RelayBuffer {
}
void feedFail(int id, String reason) {
if (_pull?.id != id) return;
_log.warning('relay pull $id failed: $reason');
feedEnd(id);
}
@@ -323,7 +337,7 @@ class RelayBuffer {
while (e + 1 < _nSlices && !_slices.containsKey(e + 1) && _wanted(e + 1)) {
e++;
}
final pl = _Pull(_nextId++, s * slice, _sliceEnd(e));
final pl = _Pull(_newId(), s * slice, _sliceEnd(e));
_pull = pl;
feed.pull(pl.id, pl.start, pl.end);
}
+165 -49
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:relic/relic.dart';
@@ -12,43 +13,83 @@ import 'session.dart';
final _log = createLogger('relay');
/// The relay of the current media: its buffer and where the bytes come
/// from. One per relayed swap; [dispose] on the next swap or server stop
/// (the buffer is memory only, so that is the whole cleanup).
/// Each track's (ahead, back) bytes when a media's [sizes] share one
/// buffer budget: by size (the tracks have one duration, so by bitrate),
/// never under [RelayConstants.relayTrackFloorBytes]. One track keeps the
/// whole budget.
List<(int, int)> relayBudgets(List<int> sizes,
{int ahead = RelayConstants.relayAheadBytes, int back = RelayConstants.relayBackBytes}) {
if (sizes.length <= 1) return [for (final _ in sizes) (ahead, back)];
final total = sizes.fold(0, (a, n) => a + n);
int share(int all, int n) => max(RelayConstants.relayTrackFloorBytes, (all * (n / total)).round());
return [for (final n in sizes) (share(ahead, n), share(back, n))];
}
/// The relay of the current media: one buffer per track and where the
/// bytes come from. One per relayed swap; [dispose] on the next swap or
/// server stop (the buffers are memory only, so that is the whole cleanup).
class ActiveRelay {
final int mediaId;
final String name;
/// Token of the member whose machine serves the bytes; null when the
/// server fetches the link itself ([local]).
/// server fetches the link itself.
final String? sourceToken;
final RelayBuffer buffer;
final LocalRelayFeed? local;
final void Function() onLiveChanged;
/// One per track, in track order; empty until the server has read a
/// fetched link.
List<RelayBuffer> buffers = const [];
/// `media.relay.tracks`: the kinds when there are several tracks.
List<String> kinds = const [];
final _known = Completer<void>();
var _ids = 0;
RelayMedia? _media;
var _local = const <LocalRelayFeed>[];
RelicWebSocket? _channel;
ChannelRelayFeed? _channelFeed;
var _channelFeeds = const <ChannelRelayFeed>[];
ActiveRelay._(this.mediaId, this.name, this.sourceToken, this.buffer, this.local, this.onLiveChanged);
ActiveRelay._(this.mediaId, this.name, this.sourceToken, this.onLiveChanged);
/// Bytes from member [sourceToken] over its relay channel.
int _nextId() => ++_ids;
void _setTracks(List<String> kinds) {
this.kinds = kinds.length > 1 ? List.unmodifiable(kinds) : const [];
buffers = [for (var i = 0; i < max(1, kinds.length); i++) RelayBuffer(nextId: _nextId)];
if (!_known.isCompleted) _known.complete();
}
void _budget(List<int> sizes) {
final b = relayBudgets(sizes);
for (var i = 0; i < buffers.length; i++) {
buffers[i]
..ahead = b[i].$1
..back = b[i].$2;
}
}
/// Bytes from member [sourceToken] over its relay channel, as the
/// [tracks] its swap named (empty: one file).
factory ActiveRelay.fromMember(int mediaId, String name, String sourceToken,
{RelayBuffer? buffer, required void Function() onLiveChanged}) =>
ActiveRelay._(mediaId, name, sourceToken, buffer ?? RelayBuffer(), null, onLiveChanged);
{List<String> tracks = const [], required void Function() onLiveChanged}) =>
ActiveRelay._(mediaId, name, sourceToken, onLiveChanged).._setTracks(tracks);
/// The server reads [url] itself: a media file as is, a web page
/// through [ytDlp]. [onFail] gets the reason when it cannot.
/// through [ytDlp] (one file, or video + audio tracks). [onFail] gets
/// the reason when it cannot.
static ActiveRelay fetch(int mediaId, String name, Uri url,
{RelayBuffer? buffer,
YtDlp? ytDlp,
required void Function() onLiveChanged,
void Function(String reason)? onFail}) {
final b = buffer ?? RelayBuffer();
final local = LocalRelayFeed(LinkRelayBytes(url, ytDlp: ytDlp), b);
final r = ActiveRelay._(mediaId, name, null, b, local, onLiveChanged);
{YtDlp? ytDlp, required void Function() onLiveChanged, void Function(String reason)? onFail}) {
final r = ActiveRelay._(mediaId, name, null, onLiveChanged);
final media = r._media = LinkRelayMedia(url, ytDlp: ytDlp);
unawaited(() async {
try {
final size = await local.bytes.size();
final tracks = await media.open();
final sizes = [for (final t in tracks) await t.bytes.size()];
if (r._disposed) return;
b.attach(local, size);
r._setTracks([for (final t in tracks) t.kind]);
r._budget(sizes);
r._local = [for (var i = 0; i < tracks.length; i++) LocalRelayFeed(tracks[i].bytes, r.buffers[i])];
for (var i = 0; i < tracks.length; i++) {
r.buffers[i].attach(r._local[i], sizes[i]);
}
onLiveChanged();
} catch (e) {
_log.warning('relay fetch: cannot read the link: $e');
@@ -59,13 +100,40 @@ class ActiveRelay {
}
var _disposed = false;
bool get live => buffer.live;
bool get live => buffers.isNotEmpty && buffers.every((b) => b.live);
/// The tracks are known (a fetched link has been read).
bool get known => _known.isCompleted && !_disposed;
/// Waits until the tracks are known, up to [RelayConstants.relayWaitSecs].
Future<bool> whenKnown() async {
if (!_known.isCompleted) {
await _known.future.timeout(const Duration(seconds: RelayConstants.relayWaitSecs), onTimeout: () {});
}
return known;
}
/// The buffer of [track]; null (the single-file URL) only for a media of
/// one track. Null when there is no such track.
RelayBuffer? bufferAt(int? track) {
if (track == null) return buffers.length == 1 ? buffers.single : null;
return track >= 0 && track < buffers.length ? buffers[track] : null;
}
void dispose() {
if (_disposed) return;
_disposed = true;
buffer.dispose();
unawaited(local?.close());
for (final b in buffers) {
b.dispose();
}
if (!_known.isCompleted) _known.complete();
final local = _local, media = _media;
unawaited(() async {
for (final f in local) {
await f.close();
}
await media?.close();
}());
final ch = _channel;
_channel = null;
if (ch != null) unawaited(_closeNotSource(ch));
@@ -78,25 +146,48 @@ class ActiveRelay {
if (ch != null) unawaited(_closeNotSource(ch));
}
void _attachChannel(RelicWebSocket ws, int size) {
void _attachChannel(RelicWebSocket ws, List<int> sizes) {
final old = _channel;
if (old != null && !identical(old, ws)) unawaited(old.tryClose());
_channel = ws;
final feed = _channelFeed = ChannelRelayFeed((t) => ws.trySendText(t));
buffer.attach(feed, size);
final multi = buffers.length > 1;
final feeds = _channelFeeds = [
for (var i = 0; i < buffers.length; i++) ChannelRelayFeed((t) => ws.trySendText(t), track: multi ? i : null),
];
_budget(sizes);
for (var i = 0; i < buffers.length; i++) {
buffers[i].attach(feeds[i], sizes[i]);
}
onLiveChanged();
}
void _detachChannel(RelicWebSocket ws) {
if (!identical(_channel, ws)) return;
_channel = null;
final feed = _channelFeed;
_channelFeed = null;
if (feed != null) buffer.detach(feed);
final feeds = _channelFeeds;
_channelFeeds = const [];
for (var i = 0; i < feeds.length && i < buffers.length; i++) {
buffers[i].detach(feeds[i]);
}
onLiveChanged();
}
}
/// The track sizes of a channel's `info` frame (`size`, or `tracks[].size`),
/// or null when malformed.
List<int>? _infoSizes(Map j) {
final size = j['size'], tracks = j['tracks'];
if (size is int) return size > 0 ? [size] : null;
if (tracks is! List || tracks.isEmpty) return null;
final out = <int>[];
for (final t in tracks) {
final n = t is Map ? t['size'] : null;
if (n is! int || n <= 0) return null;
out.add(n);
}
return out;
}
Future<void> _closeNotSource(RelicWebSocket ws) async {
try {
await ws.close(relayCloseNotSource, 'not the source');
@@ -107,7 +198,8 @@ Future<void> _closeNotSource(RelicWebSocket ws) async {
/// `GET /r/<sid>/<token>/source` (WebSocket): the source member's relay
/// channel. `GET /r/<sid>/<token>/<mediaId>/<name>`: a viewer reads the
/// relayed media, Range as for `/m`. [relay] gives the current relay.
/// relayed file, Range as for `/m`; `.../<mediaId>/<track>/<name>` one
/// track of a media relayed as several. [relay] gives the current relay.
void mountRelayRoutes(
RelicApp app,
SessionMap sessions,
@@ -144,21 +236,37 @@ void mountRelayRoutes(
final id = j['id'];
switch (j['t']) {
case 'info':
final size = j['size'];
if (size is int && size > 0 && !attached) {
attached = true;
_log.info('relay channel up (${r.name}, $size bytes)');
r._attachChannel(ws, size);
if (attached) continue;
final sizes = _infoSizes(j);
if (sizes == null || sizes.length != r.buffers.length) {
// Not the media this swap named: the source stops.
_log.warning('relay channel: info does not match the media (${r.name})');
await _closeNotSource(ws);
return;
}
attached = true;
_log.info('relay channel up (${r.name}, ${sizes.join(' + ')} bytes)');
r._attachChannel(ws, sizes);
case 'end':
if (id is int) r.buffer.feedEnd(id);
if (id is int) {
for (final b in r.buffers) {
b.feedEnd(id);
}
}
case 'fail':
if (id is int) r.buffer.feedFail(id, '${j['reason']}');
if (id is int) {
for (final b in r.buffers) {
b.feedFail(id, '${j['reason']}');
}
}
}
} else if (e is BinaryDataReceived && attached) {
try {
// Pull ids are unique across tracks: only the owner takes it.
final (id, payload) = decodeRelayFrame(e.data);
r.buffer.feedBytes(id, payload);
for (final b in r.buffers) {
b.feedBytes(id, payload);
}
} catch (_) {}
}
}
@@ -171,11 +279,12 @@ void mountRelayRoutes(
});
});
app.get('/r/:sid/:token/:mediaId/:name', (final req) async {
Future<Response> viewer(Request req, {required bool tracked}) async {
final stopwatch = Stopwatch()..start();
final sid = req.pathParameters.raw[#sid] ?? '';
final token = req.pathParameters.raw[#token] ?? '';
final mediaId = int.tryParse(req.pathParameters.raw[#mediaId] ?? '');
final track = tracked ? int.tryParse(req.pathParameters.raw[#seg] ?? '') ?? -1 : null;
final session = sessions.get(sid);
if (session == null) return _plain(410);
switch (session.gate(token)) {
@@ -189,12 +298,14 @@ void mountRelayRoutes(
final r = relay();
// A player still on an older media must not get the new one's bytes.
if (r == null || r.mediaId != mediaId) return _plain(404);
if (!await r.buffer.ready()) {
return Response(503,
body: Body.fromString('relay source not connected'),
headers: Headers.build((h) => h['retry-after'] = ['2']));
}
final size = r.buffer.size;
final notConnected = Response(503,
body: Body.fromString('relay source not connected'),
headers: Headers.build((h) => h['retry-after'] = ['2']));
if (!await r.whenKnown()) return notConnected;
final buffer = r.bufferAt(track);
if (buffer == null) return _plain(404);
if (!await buffer.ready()) return notConnected;
final size = buffer.size;
final typed = req.headers.range;
String? rangeHeader;
if (typed != null && typed.ranges.isNotEmpty) {
@@ -212,14 +323,14 @@ void mountRelayRoutes(
token,
MediaLog(
method: req.method.name,
path: '/r/$sid/$shortToken/$mediaId/${r.name}',
path: '/r/$sid/$shortToken/$mediaId/${track == null ? '' : '$track/'}${r.name}',
rangeHeader: rangeHeader,
status: res.status,
bytesWritten: end - res.start + 1,
durationMs: stopwatch.elapsedMilliseconds,
));
final Stream<Uint8List> body =
r.buffer.read(token, res.start, end, onSent: (n) => session.addBytes(token, n));
buffer.read(token, res.start, end, onSent: (n) => session.addBytes(token, n));
MimeType mime;
try {
mime = MimeType.parse(res.headers['content-type'] ?? '');
@@ -229,7 +340,12 @@ void mountRelayRoutes(
return Response(res.status,
body: Body.fromDataStream(body, mimeType: mime, contentLength: end - res.start + 1),
headers: headers);
});
}
// `:seg` is the name of a single file, the track of several (the router
// wants one parameter name per level).
app.get('/r/:sid/:token/:mediaId/:seg', (final req) => viewer(req, tracked: false));
app.get('/r/:sid/:token/:mediaId/:seg/:name', (final req) => viewer(req, tracked: true));
}
Response _plain(int status) => Response(status, body: Body.fromString('status $status'));
+22 -13
View File
@@ -46,16 +46,20 @@ class ServerHub {
relay = next;
}
/// `media.relay` for a relayed swap by [token]: who serves the bytes.
MediaRelay relayFor(String token, MediaRelay requested) => MediaRelay(
source: requested.fetch ? '' : (engine.members[token]?.id ?? ''), fetch: requested.fetch);
/// `media.relay` for a relayed swap by [token]: who serves the bytes
/// and, from a member, its tracks (the server finds a fetched link's).
MediaRelay relayFor(String token, MediaRelay requested) => requested.fetch
? const MediaRelay(fetch: true)
: MediaRelay(source: engine.members[token]?.id ?? '', tracks: requested.tracks);
/// The relay's channel came up or went down: `media.relay.live` follows.
/// The relay's channel came up or went down, or a fetched link was read:
/// `media.relay.live` and `tracks` follow.
void relayLiveChanged(ActiveRelay r) {
final m = engine.media;
if (!identical(relay, r) || m?.relay == null) return;
if (m!.relay!.live == r.live) return;
engine.media = m.copyWith(relay: m.relay!.copyWith(live: r.live));
final was = m!.relay!;
if (was.live == r.live && was.tracks.join(',') == r.kinds.join(',')) return;
engine.media = m.copyWith(relay: was.copyWith(live: r.live, tracks: r.kinds));
changed();
}
@@ -243,7 +247,7 @@ void mountSyncRoute(
String Function(String sid, String token, SessionMedia? media, String host)
urlBuilder, {
String Function(String sid, String token, String itemId, String host)? relayUrlBuilder,
String Function(String sid, String token, int mediaId, String name, String host)? relayMediaUrlBuilder,
String Function(String sid, String token, int mediaId, String name, int tracks, String host)? relayMediaUrlBuilder,
}) {
app.get('/sync', (final req) {
// Host the client dialled (Host header), so file URLs work for remote joiners.
@@ -310,7 +314,7 @@ void mountSyncRoute(
size: parsed.size,
duration: parsed.duration,
jellyfin: ref,
relay: relayReq == null ? null : hub.relayFor(token, relayReq));
relay: relayReq == null ? null : hub.relayFor(token, const MediaRelay()));
if (media == null || !hub.engine.applySwap(token, media)) {
wsErr(ws, 'bad swap media');
return;
@@ -321,7 +325,7 @@ void mountSyncRoute(
duration: media.duration,
jellyfin: ref,
mediaId: hub.engine.mediaId);
hub.setRelay(relayReq == null ? null : _memberRelay(hub, token, media.name));
hub.setRelay(relayReq == null ? null : _memberRelay(hub, token, media.name, const []));
hub.changed();
return;
}
@@ -361,7 +365,7 @@ void mountSyncRoute(
ytDlp: hub.ytDlp,
onLiveChanged: () => hub.relayLiveChanged(active),
onFail: (why) => wsErr(ws, "The server can't fetch this link: $why"))
: _memberRelay(hub, token, name);
: _memberRelay(hub, token, name, media.relay!.tracks);
hub.setRelay(active);
hub.changed();
return;
@@ -556,13 +560,17 @@ void mountSyncRoute(
// Jellyfin media resolves per member (own account first); only
// relayed files and links start from the /r/ URL, and never for
// the source itself (it plays its own copy).
// A fetched link not read yet has no URL: the state's `live` opens it.
mediaUrl: relayed != null &&
media != null &&
media.jellyfin == null &&
relayed.sourceToken != activeToken &&
relayed.mediaId == media.mediaId &&
relayMediaUrlBuilder != null
? relayMediaUrlBuilder('default', activeToken, relayed.mediaId, relayed.name, dialledHost)
? (relayed.known
? relayMediaUrlBuilder(
'default', activeToken, relayed.mediaId, relayed.name, relayed.buffers.length, dialledHost)
: null)
: media?.file != null
? urlBuilder('default', activeToken, media, dialledHost)
: media?.url,
@@ -587,8 +595,9 @@ void mountSyncRoute(
});
}
ActiveRelay _memberRelay(ServerHub hub, String token, String name) {
ActiveRelay _memberRelay(ServerHub hub, String token, String name, List<String> tracks) {
late final ActiveRelay r;
r = ActiveRelay.fromMember(hub.engine.mediaId, name, token, onLiveChanged: () => hub.relayLiveChanged(r));
r = ActiveRelay.fromMember(hub.engine.mediaId, name, token,
tracks: tracks, onLiveChanged: () => hub.relayLiveChanged(r));
return r;
}
+5 -4
View File
@@ -48,11 +48,12 @@ class SyncServer {
/// Upstream connections of the Jellyfin relay; created on first use.
HttpClient? _relayClient;
SyncServer(this.config) {
/// [ytDlp] replaces the server's own (tests).
SyncServer(this.config, {YtDlp? ytDlp}) {
hub = ServerHub(sessions, config.secret,
hostKey: config.hostKey,
jellyfin: config.jellyfin,
ytDlp: YtDlp(serverYtDlpDir(config.ytDlpDir), enabled: config.ytDlp));
ytDlp: ytDlp ?? YtDlp(serverYtDlpDir(config.ytDlpDir), enabled: config.ytDlp));
}
bool get isRunning => _server != null;
@@ -103,8 +104,8 @@ class SyncServer {
return 'http://${authority(dialledHost)}/m/$sid/$token/$name';
}, relayUrlBuilder: (sid, token, itemId, dialledHost) =>
'http://${authority(dialledHost)}/j/$sid/$token/${Uri.encodeComponent(itemId)}',
relayMediaUrlBuilder: (sid, token, mediaId, name, dialledHost) =>
'http://${authority(dialledHost)}/r/$sid/$token/$mediaId/${relayUrlName(name)}');
relayMediaUrlBuilder: (sid, token, mediaId, name, tracks, dialledHost) =>
relayViewerUrl('http://${authority(dialledHost)}/r/$sid/$token/$mediaId', name, tracks: tracks));
_app.fallback = (final req) => Response.notFound(
body: Body.fromString('not found'),
);
+98 -4
View File
@@ -92,10 +92,10 @@ void main() {
return m;
}
Future<RelaySource> startSource(_Member m, {RelayBytes? from}) async {
Future<RelaySource> startSource(_Member m, {RelayMedia? from}) async {
final src = RelaySource(
Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/${m.welcome.token}/source'),
from ?? FileRelayBytes(file),
from ?? RelayMedia.single(FileRelayBytes(file)),
backoff: (_) => const Duration(milliseconds: 100));
await src.start();
cleanup.add(src.close);
@@ -207,7 +207,7 @@ void main() {
final src = await startSource(alice);
final oldId = mediaIdOf(alice);
expect((await get(viewerUrl(bob, oldId), range: 'bytes=0-9')).$1, 206);
final buf = server.hub.relay!.buffer;
final buf = server.hub.relay!.buffers.single;
alice.send({
't': 'swap',
'media': {'name': 'x', 'size': 0, 'duration': 0, 'url': 'http://example.invalid/x.mkv'},
@@ -222,7 +222,7 @@ void main() {
test('server stop drops the relay', () async {
final alice = await join('alice');
await swapRelay(alice);
final buf = server.hub.relay!.buffer;
final buf = server.hub.relay!.buffers.single;
await server.stop();
expect(server.hub.relay, isNull);
expect(buf.cachedBytes, 0);
@@ -250,6 +250,39 @@ void main() {
expect(body, bytes.sublist(RelayBuffer.slice * 4));
});
test('two tracks: each byte-exact at its own URL, welcome is an EDL of both', () async {
final alice = await join('alice');
final bob = await join('bob');
final audio = Uint8List.fromList(List.generate(RelayBuffer.slice + 1234, (i) => i * 7 % 256));
final audioFile = File('${tmp.path}/a.m4a')..writeAsBytesSync(audio);
await swapRelay(alice, relay: {
'tracks': [
{'kind': 'video'},
{'kind': 'audio'},
],
});
expect((alice.media!['relay'] as Map)['tracks'], [
{'kind': 'video'},
{'kind': 'audio'},
]);
await startSource(alice,
from: _Tracks([RelayTrack('video', FileRelayBytes(file)), RelayTrack('audio', FileRelayBytes(audioFile))]));
final id = mediaIdOf(alice);
final base = 'http://127.0.0.1:${server.boundPort}/r/default/${bob.welcome.token}/$id';
final name = Uri.encodeComponent('ep 01.mkv');
final (vs, _, v) = await get('$base/0/$name', range: 'bytes=1000-');
final (aStatus, _, a) = await get('$base/1/$name');
expect((vs, aStatus), (206, 200));
expect(v, bytes.sublist(1000));
expect(a, audio);
// The single-file URL and a track past the end are not this media.
expect((await get('$base/$name')).$1, 404);
expect((await get('$base/2/$name')).$1, 404);
final carol = await join('carol');
final carolBase = base.replaceFirst(bob.welcome.token, carol.welcome.token);
expect(carol.welcome.mediaUrl, mpvEdl(['$carolBase/0/$name', '$carolBase/1/$name']));
});
test('Jellyfin swap can carry a relay', () async {
final alice = await join('alice');
final before = alice.raw.length;
@@ -271,9 +304,19 @@ void main() {
group('server fetches the link', () {
late HttpServer origin;
final ranges = <(String, int)>[];
setUp(() async {
origin = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
ranges.clear();
origin.listen((req) async {
if (req.uri.path == '/watch') {
req.response.headers.contentType = ContentType.html;
req.response.write('<title>page</title>');
await req.response.close();
return;
}
final m = RegExp(r'bytes=(\d+)-(\d+)').firstMatch(req.headers.value('range') ?? '');
if (m != null) ranges.add((req.uri.path, int.parse(m.group(2)!) - int.parse(m.group(1)!) + 1));
final r = resolveMediaRequest(
rangeHeader: req.headers.value('range'), size: bytes.length, filename: 'x.mkv');
req.response.statusCode = r.status;
@@ -300,6 +343,39 @@ void main() {
expect(body, bytes.sublist(1000));
});
test('a page with separate video and audio: two tracks, read in chunks', () async {
final alice = await join('alice');
final y = _FakeYtDlp(tmp.path, [
(
kind: 'video',
format: YtDlpFormat(id: 'v', url: Uri.parse('http://127.0.0.1:${origin.port}/v.mp4'), chunkSize: 300000),
),
(kind: 'audio', format: YtDlpFormat(id: 'a', url: Uri.parse('http://127.0.0.1:${origin.port}/a.m4a'))),
]);
await server.stop();
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 't'), ytDlp: y);
await server.start();
final m = await _Member.join(server.boundPort, 'alice');
cleanup.add(() => m.ws.close());
await alice.ws.close();
await swapRelay(m, relay: {'fetch': true}, url: 'http://127.0.0.1:${origin.port}/watch?v=1');
await _until(() => (m.media?['relay'] as Map?)?['live'] == true, 'live');
expect((m.media!['relay'] as Map)['tracks'], [
{'kind': 'video'},
{'kind': 'audio'},
]);
final bob = await _Member.join(server.boundPort, 'bob');
cleanup.add(() => bob.ws.close());
final url = bob.welcome.mediaUrl!;
expect(url, startsWith('edl://!no_clip;%'));
final parts = RegExp(r'%\d+%([^;]+)').allMatches(url).map((x) => x.group(1)!).toList();
expect(parts, hasLength(2));
expect((await get(parts[0])).$3, bytes);
expect((await get(parts[1], range: 'bytes=5-')).$3, bytes.sublist(5));
// The 300 000-byte chunk size: no upstream GET for the video asked for more.
expect(ranges.where((r) => r.$1 == '/v.mp4').every((r) => r.$2 <= 300000), isTrue);
});
test('an open server (no password) refuses to fetch', () async {
await server.stop();
server = SyncServer(const AppConfig(port: 0, secret: '', name: 'open'));
@@ -384,3 +460,21 @@ class _CountingBytes implements RelayBytes {
@override
Future<void> close() => inner.close();
}
class _Tracks implements RelayMedia {
final List<RelayTrack> tracks;
_Tracks(this.tracks);
@override
Future<List<RelayTrack>> open() async => tracks;
@override
Future<void> close() async {}
}
/// yt-dlp that "resolves" any page to [tracks].
class _FakeYtDlp extends YtDlp {
final List<YtDlpTrack> tracks;
_FakeYtDlp(super.dir, this.tracks);
@override
Future<YtDlpInfo> resolve(String url, {Duration timeout = const Duration(seconds: 25)}) async =>
YtDlpInfo(title: 'page', tracks: tracks, formats: {for (final t in tracks) t.format.id: t.format});
}
+2 -2
View File
@@ -80,8 +80,8 @@ void main() {
});
group('core welcome + proto', () {
test('proto 10: avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, 10);
test('proto 11: avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, 11);
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false);
expect(v.toJson().containsKey('avatarHash'), isFalse);
expect(v.toJson().containsKey('avatar'), isFalse);