Relay HLS/DASH-only pages as pieces tracks (protocol 11)

A track can be served as pieces: the server writes a VOD playlist whose
entries are its own piece URLs (segments, init maps, AES-128 keys; byte
ranges folded in; yt-dlp's DASH fragments become the same list). A piece
buffer per track fills like the byte buffer. Picks a single file, then a
split byte pair, then pieces. DRM, live, master playlists are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 23:35:31 +03:00
1 parent 05f9e794a9
commit fe5d6c3b3f
18 files changed
+1593 -207

No files matched your search

+368
View File
@@ -0,0 +1,368 @@
import 'dart:async';
import 'dart:math';
import 'dart:typed_data';
import 'package:sync_relay_core/core.dart';
import 'relay_buffer.dart';
final _log = createLogger('relay');
class _PiecePull {
final int id, piece;
final buf = BytesBuilder(copy: false);
_PiecePull(this.id, this.piece);
}
class _Viewer {
int pos = -1; // its newest requested segment
int? waiting; // that segment, while it is missing
int waitSince = 0;
int open = 0;
DateTime lastActive = DateTime.now();
}
/// The server side of a pieces track (see relay_pieces.dart): pieces by
/// index in memory, each filled by one whole pull, served whole (a Range
/// inside a piece is the route's job).
///
/// - Fill order: a viewer blocked on a piece (oldest wait first), the init
/// and key pieces the viewers' segments use, then segments ahead of each
/// viewer, slowest first, up to [ahead] bytes.
/// - A viewer's position is its newest requested segment; a superseded
/// request still gets its piece but no longer drives the feed, and a pull
/// nobody waits on gives way to a blocked viewer at once.
/// - Kept: every init and key piece, plus segments by distance from the
/// viewers ([ahead] + [back] bytes; behind a viewer costs 4x), never LRU.
/// Sizes before a piece arrives are estimated from the bytes per second
/// of the ones that did, else [rate].
class PieceBuffer implements TrackBuffer {
@override
int ahead, back;
final Duration waitLimit;
/// Bytes per second assumed before any segment has arrived.
double rate;
final int Function() _newId;
PieceBuffer({
this.ahead = RelayConstants.relayAheadBytes,
this.back = RelayConstants.relayBackBytes,
this.waitLimit = const Duration(seconds: RelayConstants.relayWaitSecs),
this.rate = 500000,
int Function()? nextId,
}) : _newId = nextId ?? _counter();
static int Function() _counter() {
var n = 0;
return () => ++n;
}
RelayFeed? _feed;
/// The track's shape, once the feed has connected.
RelayPieceList? list;
String? _playlist;
final _pieces = <int, Uint8List>{};
final _sizes = <int, int>{}; // known sizes, kept after eviction
var _knownSecs = 0.0;
var _knownBytes = 0;
final _waiters = <int, List<Completer<void>>>{};
final _aux = <int, int>{}; // init/key piece a request waits on -> since (us)
final _viewers = <String, _Viewer>{};
_PiecePull? _pull;
DateTime? _retryAt;
var _disposed = false;
Completer<void> _kick = Completer();
final Completer<void> _ready = Completer();
@override
int bytesFromFeed = 0, bytesToViewers = 0;
@override
bool get live => _feed != null && list != null;
@override
int get cachedBytes => _pieces.values.fold(0, (a, p) => a + p.length);
/// The media playlist viewers open (see [RelayPieceList.playlist]).
String? get playlist => _playlist ??= list?.playlist();
/// Attaches a (re)connected feed for a track of [list]'s shape. A
/// different shape means different content: the buffer starts over.
void attach(RelayFeed feed, RelayPieceList list) {
if (_disposed) return;
final old = this.list;
if (old != null && !old.sameShape(list)) {
_log.info('relay source changed pieces ${old.length} -> ${list.length}, dropping buffer');
_pieces.clear();
_sizes.clear();
_knownSecs = 0;
_knownBytes = 0;
_viewers.clear();
_aux.clear();
}
this.list = list;
_playlist = null;
_feed = feed;
_pull = null;
if (!_ready.isCompleted) _ready.complete();
if (_fillerRunning) {
_wake();
} else {
unawaited(_filler());
}
}
@override
void detach(RelayFeed feed) {
if (!identical(_feed, feed)) return;
_feed = null;
_pull = null;
_wake();
}
@override
void dispose() {
_disposed = true;
_feed = null;
_pull = null;
_pieces.clear();
for (final list in _waiters.values) {
for (final c in list) {
if (!c.isCompleted) c.complete();
}
}
_waiters.clear();
if (!_ready.isCompleted) _ready.complete();
_wake();
}
void _wake() {
if (!_kick.isCompleted) _kick.complete();
}
// ------------------------------------------------------------ feed input
@override
void feedBytes(int id, Uint8List data) {
final pl = _pull;
if (pl == null || pl.id != id || _disposed) return;
_feed?.arrived(id, data.length);
bytesFromFeed += data.length;
pl.buf.add(data);
}
@override
void feedEnd(int id) {
final pl = _pull;
if (pl == null || pl.id != id || _disposed) return;
_pull = null;
final data = pl.buf.takeBytes();
final i = pl.piece;
_pieces[i] = data;
final p = list!.pieces[i];
if (p.isSegment && !_sizes.containsKey(i)) {
_knownSecs += p.d;
_knownBytes += data.length;
}
_sizes[i] = data.length;
for (final c in _waiters.remove(i) ?? const <Completer<void>>[]) {
c.complete();
}
_wake();
}
@override
void feedFail(int id, String reason) {
if (_pull?.id != id) return;
_log.warning('relay piece pull $id failed: $reason');
_pull = null;
_retryAt = DateTime.now().add(const Duration(seconds: 1));
_wake();
}
// ------------------------------------------------------------- scheduling
double _est(int i) {
final known = _sizes[i];
if (known != null) return known.toDouble();
final p = list!.pieces[i];
if (!p.isSegment) return 0;
return (_knownSecs > 0 ? _knownBytes / _knownSecs : rate) * p.d;
}
Iterable<_Viewer> get _active {
final now = DateTime.now();
return _viewers.values.where(
(v) => v.pos >= 0 && (v.open > 0 || now.difference(v.lastActive) < const Duration(seconds: 60)));
}
/// Estimated byte offset of every piece (prefix sums), for distances.
List<double> _offsets() {
final l = list!;
final out = List<double>.filled(l.length + 1, 0);
for (var i = 0; i < l.length; i++) {
out[i + 1] = out[i] + _est(i);
}
return out;
}
double _distance(int i, List<double> off, List<_Viewer> active) {
var d = double.infinity;
for (final v in active) {
d = min(d, i >= v.pos ? off[i] - off[v.pos] : (off[v.pos] - off[i]) * 4);
}
return d;
}
bool _waited(int i) => _aux.containsKey(i) || _viewers.values.any((v) => v.waiting == i);
/// Cached segments past the budget, farthest first; returns the
/// distance of the farthest segment kept (a fill farther than it would
/// only be evicted again).
double _evict(List<double> off, List<_Viewer> active) {
final l = list!;
final cached = [for (final i in _pieces.keys) if (l.pieces[i].isSegment) i];
var total = cached.fold(0, (a, i) => a + _pieces[i]!.length);
cached.sort((a, b) => _distance(b, off, active).compareTo(_distance(a, off, active)));
final limit = ahead + back;
var k = 0;
for (; k < cached.length && total > limit; k++) {
final i = cached[k];
if (_waited(i)) continue;
total -= _pieces.remove(i)!.length;
}
_cachedSegBytes = total;
return k < cached.length ? _distance(cached[k], off, active) : double.infinity;
}
var _cachedSegBytes = 0;
int? _nextMissing() {
final l = list!;
final active = _active.toList()..sort((a, b) => a.pos.compareTo(b.pos));
final off = _offsets();
final keep = _evict(off, active);
final blocked = <(int, int)>[
for (final v in _viewers.values)
if (v.waiting != null && !_pieces.containsKey(v.waiting)) (v.waiting!, v.waitSince),
for (final e in _aux.entries)
if (!_pieces.containsKey(e.key)) (e.key, e.value),
]..sort((a, b) => a.$2.compareTo(b.$2));
if (blocked.isNotEmpty) return blocked.first.$1;
if (active.isEmpty) return null; // nobody watches: fetch nothing
for (final v in active) {
var budget = 0.0;
for (var i = v.pos; i < l.length; i++) {
final p = l.pieces[i];
if (!p.isSegment) continue;
for (final dep in [p.map, p.key]) {
if (dep != null && !_pieces.containsKey(dep)) return dep;
}
if (!_pieces.containsKey(i)) {
final room = _cachedSegBytes + _est(i) <= ahead + back;
if (i == v.pos || room || _distance(i, off, active) < keep) return i;
break;
}
budget += _est(i);
if (budget > ahead) break;
}
}
return null;
}
var _fillerRunning = false;
Future<void> _filler() async {
_fillerRunning = true;
try {
while (!_disposed) {
final feed = _feed;
final retry = _retryAt;
if (feed == null || _pull != null || list == null) {
await _sleep();
continue;
}
if (retry != null && DateTime.now().isBefore(retry)) {
await _sleep(retry.difference(DateTime.now()));
continue;
}
_retryAt = null;
final i = _nextMissing();
if (i == null) {
await _sleep();
continue;
}
final pl = _pull = _PiecePull(_newId(), i);
feed.pullPiece(pl.id, i);
}
} finally {
_fillerRunning = false;
}
}
Future<void> _sleep([Duration limit = const Duration(seconds: 5)]) async {
_kick = Completer();
// Also wake periodically: viewers age out of [_active].
await _kick.future.timeout(limit, onTimeout: () {});
}
// ---------------------------------------------------------------- viewers
@override
Future<bool> ready() async {
if (list != null) return true;
await _ready.future.timeout(waitLimit, onTimeout: () {});
return list != null && !_disposed;
}
/// [viewer] opened the playlist: a new viewer starts at the first
/// segment, so its first pieces are fetched before it asks.
void opened(String viewer) {
final l = list;
if (l == null) return;
final v = _viewers.putIfAbsent(viewer, _Viewer.new);
if (v.pos < 0) v.pos = l.pieces.indexWhere((p) => p.isSegment);
v.lastActive = DateTime.now();
_wake();
}
/// Piece [index] for [viewer], once it is in; null when it does not
/// arrive within [waitLimit] (or the buffer is gone).
Future<Uint8List?> piece(String viewer, int index) async {
final l = list;
if (l == null || index < 0 || index >= l.length) return null;
final v = _viewers.putIfAbsent(viewer, _Viewer.new);
final segment = l.pieces[index].isSegment;
final now = DateTime.now().microsecondsSinceEpoch;
v.open++;
v.lastActive = DateTime.now();
if (segment) {
v.pos = index;
v.waiting = _pieces.containsKey(index) ? null : index;
v.waitSince = now;
} else if (!_pieces.containsKey(index)) {
_aux.putIfAbsent(index, () => now);
}
// A pull nobody waits on gives way to this one at once.
final pl = _pull;
if (!_pieces.containsKey(index) && pl != null && pl.piece != index && !_waited(pl.piece)) {
_pull = null;
_feed?.cancel(pl.id);
}
_wake();
try {
final deadline = DateTime.now().add(waitLimit);
while (!_pieces.containsKey(index)) {
if (_disposed || DateTime.now().isAfter(deadline)) return null;
final c = Completer<void>();
_waiters.putIfAbsent(index, () => []).add(c);
await c.future.timeout(const Duration(seconds: 2), onTimeout: () {});
}
return _pieces[index];
} finally {
v.open--;
v.lastActive = DateTime.now();
if (segment && v.waiting == index) v.waiting = null;
if (!segment) _aux.remove(index);
}
}
}
+52 -11
View File
@@ -7,28 +7,54 @@ import 'package:sync_relay_core/core.dart';
final _log = createLogger('relay');
/// What feeds a [RelayBuffer]: a member's relay channel, or the server
/// One track's buffer on the server: a byte stream ([RelayBuffer]) or
/// pieces ([PieceBuffer]).
abstract interface class TrackBuffer {
/// Budgets, set by the relay when a media's tracks share them.
abstract int ahead, back;
bool get live;
int get cachedBytes;
int get bytesFromFeed;
int get bytesToViewers;
/// Waits for the feed's first connect, up to the wait limit.
Future<bool> ready();
void detach(RelayFeed feed);
void dispose();
void feedBytes(int id, Uint8List data);
void feedEnd(int id);
void feedFail(int id, String reason);
}
/// What feeds a [TrackBuffer]: a member's relay channel, or the server
/// itself fetching a link ([LocalRelayFeed]).
abstract class RelayFeed {
/// Ask for bytes [start]..[end]; they arrive via [RelayBuffer.feedBytes]
/// in order, then [RelayBuffer.feedEnd].
/// Ask for bytes [start]..[end]; they arrive via [TrackBuffer.feedBytes]
/// in order, then [TrackBuffer.feedEnd].
void pull(int id, int start, int end);
/// Ask for piece [index] of a pieces track, whole (arrives like [pull]).
void pullPiece(int id, int index);
void cancel(int id);
/// Bytes of [id] reached the buffer (the remote feed acks them).
void arrived(int id, int n) {}
}
/// Fetches from [bytes] in this process (the "server fetches it" mode).
/// Fetches [track] in this process (the "server fetches it" mode).
class LocalRelayFeed extends RelayFeed {
final RelayBytes bytes;
final RelayBuffer buffer;
final RelayTrack track;
final TrackBuffer buffer;
final _subs = <int, StreamSubscription<List<int>>>{};
LocalRelayFeed(this.bytes, this.buffer);
LocalRelayFeed(this.track, this.buffer);
@override
void pull(int id, int start, int end) {
_subs[id] = bytes.read(start, end).listen(
void pull(int id, int start, int end) => _listen(id, track.bytes!.read(start, end));
@override
void pullPiece(int id, int index) => _listen(id, track.pieces!.read(index));
void _listen(int id, Stream<List<int>> data) {
_subs[id] = data.listen(
(c) => buffer.feedBytes(id, c is Uint8List ? c : Uint8List.fromList(c)),
onError: (Object e) {
unawaited(_subs.remove(id)?.cancel().catchError((_) {}));
@@ -51,7 +77,8 @@ class LocalRelayFeed extends RelayFeed {
await s.cancel().catchError((_) {});
}
_subs.clear();
await bytes.close();
await track.bytes?.close();
await track.pieces?.close();
}
}
@@ -66,6 +93,10 @@ class ChannelRelayFeed extends RelayFeed {
void pull(int id, int start, int end) =>
send(jsonEncode({'t': 'pull', 'id': id, if (track != null) 'track': track, 'start': start, 'end': end}));
@override
void pullPiece(int id, int index) =>
send(jsonEncode({'t': 'pull', 'id': id, if (track != null) 'track': track, 'piece': index}));
@override
void cancel(int id) => send(jsonEncode({'t': 'cancel', 'id': id}));
@@ -102,8 +133,9 @@ class _Viewer {
/// - Only a viewer's newest request moves its position or asks the feed
/// for anything; older ones still get bytes once they are buffered.
/// Everything lives in memory: dropping the buffer is the cleanup.
class RelayBuffer {
class RelayBuffer implements TrackBuffer {
/// Set by the relay when a media's tracks share the budget.
@override
int ahead, back;
final int pin;
final Duration waitLimit;
@@ -137,9 +169,12 @@ class RelayBuffer {
final Completer<void> _ready = Completer();
/// Totals, for logs and tests.
@override
int bytesFromFeed = 0, bytesToViewers = 0;
@override
bool get live => _feed != null && size > 0;
@override
int get cachedBytes => _slices.values.fold(0, (a, s) => a + s.length);
int get _nSlices => (size + slice - 1) ~/ slice;
int _sliceEnd(int s) => min((s + 1) * slice, size) - 1;
@@ -163,6 +198,7 @@ class RelayBuffer {
}
/// The feed went away; buffered slices keep serving.
@override
void detach(RelayFeed feed) {
if (!identical(_feed, feed)) return;
_feed = null;
@@ -170,6 +206,7 @@ class RelayBuffer {
_wake();
}
@override
void dispose() {
_disposed = true;
_feed = null;
@@ -191,6 +228,7 @@ class RelayBuffer {
// ------------------------------------------------------------ feed input
@override
void feedBytes(int id, Uint8List data) {
final pl = _pull;
if (pl == null || pl.id != id || _disposed) return;
@@ -221,6 +259,7 @@ class RelayBuffer {
}
}
@override
void feedEnd(int id) {
final pl = _pull;
if (pl != null && pl.id == id) {
@@ -229,6 +268,7 @@ class RelayBuffer {
}
}
@override
void feedFail(int id, String reason) {
if (_pull?.id != id) return;
_log.warning('relay pull $id failed: $reason');
@@ -376,6 +416,7 @@ class RelayBuffer {
// ---------------------------------------------------------------- viewers
/// Waits for the size (the feed's first connect), up to [waitLimit].
@override
Future<bool> ready() async {
if (size > 0) return true;
await _ready.future.timeout(waitLimit, onTimeout: () {});
+120 -46
View File
@@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart';
import 'package:web_socket/web_socket.dart';
import 'media_endpoint.dart';
import 'piece_buffer.dart';
import 'relay_buffer.dart';
import 'session.dart';
@@ -25,6 +26,15 @@ List<(int, int)> relayBudgets(List<int> sizes,
return [for (final n in sizes) (share(ahead, n), share(back, n))];
}
/// Bytes per second a pieces track is assumed to need before its first
/// pieces arrive (its share of the budget, its distances): 4 Mbit/s for
/// video, 160 kbit/s for audio.
double _piecesRate(String kind) => kind == 'audio' ? 20000 : 500000;
/// One track as the source (or the fetcher) described it: a byte size,
/// or a piece list.
typedef _TrackInfo = ({int? size, RelayPieceList? pieces});
/// 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).
@@ -35,11 +45,11 @@ class ActiveRelay {
/// server fetches the link itself.
final String? sourceToken;
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 [];
/// One per track, in track order ([RelayBuffer] for bytes, [PieceBuffer]
/// for pieces); empty until the server has read a fetched link.
List<TrackBuffer> buffers = const [];
/// `media.relay.tracks`: empty for one file.
List<RelayTrackSpec> tracks = const [];
final _known = Completer<void>();
var _ids = 0;
RelayMedia? _media;
@@ -51,12 +61,43 @@ class ActiveRelay {
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)];
void _setTracks(List<RelayTrackSpec> tracks) {
this.tracks = MediaRelay.validTracks(tracks) ? List.unmodifiable(tracks) : const [];
buffers = this.tracks.isEmpty
? [RelayBuffer(nextId: _nextId)]
: [
for (final t in this.tracks)
t.pieces ? PieceBuffer(nextId: _nextId, rate: _piecesRate(t.kind)) : RelayBuffer(nextId: _nextId),
];
if (!_known.isCompleted) _known.complete();
}
/// The info of every track matches [tracks] (bytes for a byte track,
/// pieces for a pieces one).
bool _matches(List<_TrackInfo> infos) {
if (infos.length != buffers.length) return false;
for (var i = 0; i < infos.length; i++) {
if ((infos[i].pieces != null) != (buffers[i] is PieceBuffer)) return false;
}
return true;
}
/// Attaches every buffer to its feed.
void _attachAll(List<RelayFeed> feeds, List<_TrackInfo> infos) {
_budget([
for (var i = 0; i < infos.length; i++)
infos[i].size ?? (_piecesRate(tracks[i].kind) * infos[i].pieces!.duration).round(),
]);
for (var i = 0; i < buffers.length; i++) {
final b = buffers[i];
if (b is PieceBuffer) {
b.attach(feeds[i], infos[i].pieces!);
} else if (b is RelayBuffer) {
b.attach(feeds[i], infos[i].size!);
}
}
}
void _budget(List<int> sizes) {
final b = relayBudgets(sizes);
for (var i = 0; i < buffers.length; i++) {
@@ -69,12 +110,12 @@ class ActiveRelay {
/// 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,
{List<String> tracks = const [], required void Function() onLiveChanged}) =>
{List<RelayTrackSpec> 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] (one file, or video + audio tracks). [onFail] gets
/// the reason when it cannot.
/// through [ytDlp] (one file, video + audio tracks, or segmented
/// playlists). [onFail] gets the reason when it cannot.
static ActiveRelay fetch(int mediaId, String name, Uri url,
{YtDlp? ytDlp, required void Function() onLiveChanged, void Function(String reason)? onFail}) {
final r = ActiveRelay._(mediaId, name, null, onLiveChanged);
@@ -82,14 +123,14 @@ class ActiveRelay {
unawaited(() async {
try {
final tracks = await media.open();
final sizes = [for (final t in tracks) await t.bytes.size()];
final infos = <_TrackInfo>[
for (final t in tracks)
t.pieces != null ? (size: null, pieces: await t.pieces!.list()) : (size: await t.bytes!.size(), pieces: null),
];
if (r._disposed) return;
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]);
}
r._setTracks(relayTrackSpecs(tracks));
r._local = [for (var i = 0; i < tracks.length; i++) LocalRelayFeed(tracks[i], r.buffers[i])];
r._attachAll(r._local, infos);
onLiveChanged();
} catch (e) {
_log.warning('relay fetch: cannot read the link: $e');
@@ -113,10 +154,10 @@ class ActiveRelay {
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;
/// The buffer of [track]; null (the single-file URL) only for one file.
/// Null when there is no such track.
TrackBuffer? bufferAt(int? track) {
if (track == null) return tracks.isEmpty && buffers.length == 1 ? buffers.single : null;
return track >= 0 && track < buffers.length ? buffers[track] : null;
}
@@ -139,18 +180,15 @@ class ActiveRelay {
if (ch != null) unawaited(_closeNotSource(ch));
}
void _attachChannel(RelicWebSocket ws, List<int> sizes) {
void _attachChannel(RelicWebSocket ws, List<_TrackInfo> infos) {
final old = _channel;
if (old != null && !identical(old, ws)) unawaited(old.tryClose());
_channel = ws;
final multi = buffers.length > 1;
final named = tracks.isNotEmpty;
final feeds = _channelFeeds = [
for (var i = 0; i < buffers.length; i++) ChannelRelayFeed((t) => ws.trySendText(t), track: multi ? i : null),
for (var i = 0; i < buffers.length; i++) ChannelRelayFeed((t) => ws.trySendText(t), track: named ? i : null),
];
_budget(sizes);
for (var i = 0; i < buffers.length; i++) {
buffers[i].attach(feeds[i], sizes[i]);
}
_attachAll(feeds, infos);
onLiveChanged();
}
@@ -166,17 +204,25 @@ class ActiveRelay {
}
}
/// The track sizes of a channel's `info` frame (`size`, or `tracks[].size`),
/// or null when malformed.
List<int>? _infoSizes(Map j) {
/// The tracks of a channel's `info` frame (`size`, or `tracks[]` each
/// with a `size` or, `"as":"pieces"`, a piece list), or null when
/// malformed.
List<_TrackInfo>? _infoTracks(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>[];
if (size is int) return size > 0 ? [(size: size, pieces: null)] : null;
if (tracks is! List || tracks.isEmpty || tracks.length > MediaRelay.maxTracks) return null;
final out = <_TrackInfo>[];
for (final t in tracks) {
final n = t is Map ? t['size'] : null;
if (n is! int || n <= 0) return null;
out.add(n);
if (t is! Map) return null;
if (t['as'] == 'pieces') {
final list = RelayPieceList.fromJson(t);
if (list == null) return null;
out.add((size: null, pieces: list));
} else {
final n = t['size'];
if (n is! int || n <= 0) return null;
out.add((size: n, pieces: null));
}
}
return out;
}
@@ -192,7 +238,9 @@ 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 file, Range as for `/m`; `.../<mediaId>/<track>/<name>` one
/// track of a media relayed as several. [relay] gives the current relay.
/// track of a media with tracks (a pieces track's media playlist), and
/// `.../<mediaId>/<track>/p/<i>` piece `i` of a pieces track. [relay]
/// gives the current relay.
void mountRelayRoutes(
RelicApp app,
SessionMap sessions,
@@ -230,16 +278,17 @@ void mountRelayRoutes(
switch (j['t']) {
case 'info':
if (attached) continue;
final sizes = _infoSizes(j);
if (sizes == null || sizes.length != r.buffers.length) {
final infos = _infoTracks(j);
if (infos == null || !r._matches(infos)) {
// 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);
_log.info('relay channel up (${r.name}, '
'${infos.map((t) => t.size != null ? '${t.size} bytes' : '${t.pieces!.length} pieces').join(' + ')})');
r._attachChannel(ws, infos);
case 'end':
if (id is int) {
for (final b in r.buffers) {
@@ -272,7 +321,7 @@ void mountRelayRoutes(
});
});
Future<Response> viewer(Request req, {required bool tracked}) async {
Future<Response> viewer(Request req, {required bool tracked, bool piece = false}) async {
final stopwatch = Stopwatch()..start();
final sid = req.pathParameters.raw[#sid] ?? '';
final token = req.pathParameters.raw[#token] ?? '';
@@ -294,15 +343,39 @@ void mountRelayRoutes(
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 (buffer == null || (piece && buffer is! PieceBuffer)) 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) {
final x = typed.ranges.first;
rangeHeader = '${typed.unit}=${x.start?.toString() ?? ''}-${x.end?.toString() ?? ''}';
}
if (buffer is PieceBuffer) {
if (!piece) {
buffer.opened(token);
return Response(200,
body: Body.fromString(buffer.playlist ?? '', mimeType: MimeType('application', 'vnd.apple.mpegurl')),
headers: Headers.build((h) => h['cache-control'] = ['no-store']));
}
final index = int.tryParse(req.pathParameters.raw[#piece] ?? '');
if (index == null || index < 0 || index >= buffer.list!.length) return _plain(404);
final data = await buffer.piece(token, index);
if (data == null) return notConnected;
final res = resolveMediaRequest(rangeHeader: rangeHeader, size: data.length, filename: 'piece');
final headers = Headers.build((h) => res.headers.forEach((k, v) => h[k] = [v]));
if (res.status != 200 && res.status != 206) {
return Response(res.status, body: Body.fromString('status ${res.status}'), headers: headers);
}
final end = res.end ?? data.length - 1;
final n = end - res.start + 1;
buffer.bytesToViewers += n;
session.addBytes(token, n);
return Response(res.status,
body: Body.fromData(Uint8List.sublistView(data, res.start, end + 1), mimeType: MimeType.octetStream),
headers: headers);
}
final size = (buffer as RelayBuffer).size;
final res = resolveMediaRequest(rangeHeader: rangeHeader, size: size, filename: r.name);
final headers = Headers.build((h) => res.headers.forEach((k, v) => h[k] = [v]));
if (res.status != 200 && res.status != 206) {
@@ -337,6 +410,7 @@ void mountRelayRoutes(
// 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));
app.get('/r/:sid/:token/:mediaId/:seg/p/:piece', (final req) => viewer(req, tracked: true, piece: true));
}
Response _plain(int status) => Response(status, body: Body.fromString('status $status'));
+6 -5
View File
@@ -58,8 +58,8 @@ class ServerHub {
final m = engine.media;
if (!identical(relay, r) || m?.relay == null) return;
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));
if (was.live == r.live && was.tracks.join(',') == r.tracks.join(',')) return;
engine.media = m.copyWith(relay: was.copyWith(live: r.live, tracks: r.tracks));
changed();
}
@@ -244,7 +244,8 @@ 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, int tracks, String host)? relayMediaUrlBuilder,
String Function(String sid, String token, int mediaId, String name, List<RelayTrackSpec> tracks, String host)?
relayMediaUrlBuilder,
}) {
app.get('/sync', (final req) {
// Host the client dialled (Host header), so file URLs work for remote joiners.
@@ -551,7 +552,7 @@ void mountSyncRoute(
relayMediaUrlBuilder != null
? (relayed.known
? relayMediaUrlBuilder(
'default', activeToken, relayed.mediaId, relayed.name, relayed.buffers.length, dialledHost)
'default', activeToken, relayed.mediaId, relayed.name, relayed.tracks, dialledHost)
: null)
: media?.file != null
? urlBuilder('default', activeToken, media, dialledHost)
@@ -577,7 +578,7 @@ void mountSyncRoute(
});
}
ActiveRelay _memberRelay(ServerHub hub, String token, String name, List<String> tracks) {
ActiveRelay _memberRelay(ServerHub hub, String token, String name, List<RelayTrackSpec> tracks) {
late final ActiveRelay r;
r = ActiveRelay.fromMember(hub.engine.mediaId, name, token,
tracks: tracks, onLiveChanged: () => hub.relayLiveChanged(r));