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

+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'),
);