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:
1 parent
05f9e794a9
commit
fe5d6c3b3f
18 files changed
+1593
-207
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: () {});
|
||||
|
||||
@@ -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'));
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user