forked from Foxyz/sync_replay_project
Protocol 12: Up next (a shared queue) and where the video comes from
Up next: a server-side queue anyone edits by item id (add at end or next, move, remove, play now, skip); each item says who added it and how it plays. At the real end the first item swaps in after the end card's countdown, put up by whoever can (its adder for through-me links and files, someone with access for Jellyfin, else anyone); items that can't play are skipped with the reason. The next item's link is resolved ahead. Greyed "Continues with" episodes below. A chip with the controls opens it as a popover (leaf_widgets, local path dependency for now); "Add to queue" next to Put it up everywhere. Delivery: the server measures the source's rate, the media's bitrate and each member's read rate; the members card shows a source line (who passes it on, through what, keeping up or not, source left) and each member's route and buffer. A slow source is told once per media. A failed server fetch is announced to everyone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
95cd668cfb
commit
6b10cae4e6
50 files changed
+4239
-157
No files matched your search
@@ -6,3 +6,4 @@ export 'src/sync_server.dart';
|
||||
export 'src/jellyfin_relay.dart';
|
||||
export 'src/relay_buffer.dart';
|
||||
export 'src/relay_routes.dart';
|
||||
export 'src/relay_stats.dart';
|
||||
@@ -5,6 +5,7 @@ import 'dart:typed_data';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'relay_buffer.dart';
|
||||
import 'relay_stats.dart';
|
||||
|
||||
final _log = createLogger('relay');
|
||||
|
||||
@@ -67,7 +68,18 @@ class PieceBuffer implements TrackBuffer {
|
||||
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;
|
||||
@override
|
||||
FeedClock clock = FeedClock();
|
||||
_PiecePull? _pullNow;
|
||||
_PiecePull? get _pull => _pullNow;
|
||||
set _pull(_PiecePull? p) {
|
||||
if ((_pullNow == null) != (p == null)) p == null ? clock.stop() : clock.start();
|
||||
_pullNow = p;
|
||||
}
|
||||
|
||||
/// The track's average bytes per second from the segments in so far;
|
||||
/// null before a minute of it has arrived.
|
||||
double? get bytesPerSec => _knownSecs >= 60 ? _knownBytes / _knownSecs : null;
|
||||
DateTime? _retryAt;
|
||||
var _disposed = false;
|
||||
Completer<void> _kick = Completer();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:relic/relic.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'server_hub.dart';
|
||||
|
||||
// `queue` messages (Up next, see PROTOCOL.md "Queue"): what an added item
|
||||
// may carry, and the ops applied to the engine's queue.
|
||||
|
||||
final _key = RegExp(r'^[A-Za-z0-9_-]{8,64}$');
|
||||
|
||||
bool _httpUrl(String? url) {
|
||||
final u = Uri.tryParse(url ?? '');
|
||||
return u != null && (u.isScheme('http') || u.isScheme('https')) && u.host.isNotEmpty;
|
||||
}
|
||||
|
||||
/// An added item as the server keeps it: names cleaned like a swap's,
|
||||
/// how it plays checked against what it carries, a server-fetched link's
|
||||
/// URL taken out ([privateUrl]: never broadcast). Throws
|
||||
/// [ProtocolException] with the reason for the sender.
|
||||
({QueueItem item, String? privateUrl}) cleanQueueAdd(QueueItem raw, {required bool serverHasPassword}) {
|
||||
final media = raw.media;
|
||||
var name = sanitizeMediaName(media.name);
|
||||
final ref = media.jellyfin;
|
||||
final QueueItem item;
|
||||
String? privateUrl;
|
||||
if (ref != null || raw.how == QueueHow.jellyfin) {
|
||||
final clean = ref == null ? null : sanitizeJellyfinRef(ref);
|
||||
if (clean == null) throw const ProtocolException('bad queue item');
|
||||
if (name.isEmpty) name = clean.title;
|
||||
item = QueueItem(
|
||||
media: HelloMedia(name: name, size: 0, duration: 0, jellyfin: clean), how: QueueHow.jellyfin, title: '');
|
||||
} else {
|
||||
switch (raw.how) {
|
||||
case QueueHow.direct:
|
||||
if (!_httpUrl(media.url)) throw const ProtocolException('a queued link needs an http(s) url');
|
||||
item = QueueItem(media: HelloMedia(name: name, size: 0, duration: 0, url: media.url), how: raw.how, title: '');
|
||||
case QueueHow.server:
|
||||
if (!_httpUrl(media.url)) throw const ProtocolException('relay fetch needs an http(s) url');
|
||||
if (!serverHasPassword) throw const ProtocolException('server fetch needs a server password');
|
||||
privateUrl = media.url;
|
||||
item = QueueItem(
|
||||
media: HelloMedia(name: name, size: 0, duration: 0, relay: const MediaRelay(fetch: true)),
|
||||
how: raw.how,
|
||||
title: '');
|
||||
case QueueHow.through:
|
||||
case QueueHow.file:
|
||||
if (!_key.hasMatch(raw.key)) throw const ProtocolException('a queued item of yours needs a key');
|
||||
item = QueueItem(
|
||||
media: HelloMedia(name: name, size: media.size < 0 ? 0 : media.size, duration: 0),
|
||||
how: raw.how,
|
||||
title: '',
|
||||
key: raw.key);
|
||||
case QueueHow.jellyfin:
|
||||
throw const ProtocolException('bad queue item');
|
||||
}
|
||||
}
|
||||
var title = cleanQueueText(sanitizeMediaName(raw.title));
|
||||
if (title.isEmpty) title = cleanQueueText(item.media.name.isEmpty ? 'media' : item.media.name);
|
||||
return (
|
||||
item: item.copyWith(
|
||||
title: title, sub: cleanQueueText(sanitizeMediaName(raw.sub)), duration: raw.duration),
|
||||
privateUrl: privateUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/// Applies one `queue` message from [token]; a refused add answers the
|
||||
/// sender with `err` (the socket stays open).
|
||||
void handleQueueIntent(ServerHub hub, String token, RelicWebSocket ws, Map<String, Object?> decoded) {
|
||||
final intent = QueueIntent.fromJson(decoded);
|
||||
final engine = hub.engine;
|
||||
var changed = false;
|
||||
switch (intent.op) {
|
||||
case 'add':
|
||||
final cleaned = cleanQueueAdd(intent.item!, serverHasPassword: hub.session.secret.isNotEmpty);
|
||||
final thumb = intent.thumb.isEmpty ? null : decodePicture(intent.thumb, RelayConstants.maxPosterBytes);
|
||||
final error = engine.applyQueueAdd(token, cleaned.item,
|
||||
next: intent.next, privateUrl: cleaned.privateUrl, thumb: thumb);
|
||||
if (error != null) {
|
||||
wsErr(ws, error);
|
||||
return;
|
||||
}
|
||||
changed = true;
|
||||
case 'move':
|
||||
changed = engine.applyQueueMove(token, intent.id, intent.to);
|
||||
case 'remove':
|
||||
changed = engine.applyQueueRemove(token, intent.id, why: intent.why);
|
||||
case 'play':
|
||||
changed = engine.applyQueuePlay(token, intent.id);
|
||||
case 'skip':
|
||||
changed = engine.applyQueuePlay(token, '');
|
||||
}
|
||||
if (changed) hub.changed();
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'relay_stats.dart';
|
||||
|
||||
final _log = createLogger('relay');
|
||||
|
||||
/// One track's buffer on the server: a byte stream ([RelayBuffer]) or
|
||||
@@ -16,6 +18,9 @@ abstract interface class TrackBuffer {
|
||||
int get cachedBytes;
|
||||
int get bytesFromFeed;
|
||||
int get bytesToViewers;
|
||||
/// Runs while this buffer has a pull in flight; the relay gives its
|
||||
/// tracks one, for the source's rate.
|
||||
abstract FeedClock clock;
|
||||
/// Waits for the feed's first connect, up to the wait limit.
|
||||
Future<bool> ready();
|
||||
void detach(RelayFeed feed);
|
||||
@@ -162,7 +167,15 @@ class RelayBuffer implements TrackBuffer {
|
||||
final _waiters = <int, List<Completer<void>>>{};
|
||||
final _blocked = <int, int>{}; // slice -> wait start (us)
|
||||
final _viewers = <String, _Viewer>{};
|
||||
_Pull? _pull;
|
||||
@override
|
||||
FeedClock clock = FeedClock();
|
||||
_Pull? _pullNow;
|
||||
_Pull? get _pull => _pullNow;
|
||||
set _pull(_Pull? p) {
|
||||
if ((_pullNow == null) != (p == null)) p == null ? clock.stop() : clock.start();
|
||||
_pullNow = p;
|
||||
}
|
||||
|
||||
var _reqSeq = 0;
|
||||
var _disposed = false;
|
||||
Completer<void> _kick = Completer();
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:web_socket/web_socket.dart';
|
||||
import 'media_endpoint.dart';
|
||||
import 'piece_buffer.dart';
|
||||
import 'relay_buffer.dart';
|
||||
import 'relay_stats.dart';
|
||||
import 'session.dart';
|
||||
|
||||
final _log = createLogger('relay');
|
||||
@@ -56,9 +57,39 @@ class ActiveRelay {
|
||||
var _local = const <LocalRelayFeed>[];
|
||||
RelicWebSocket? _channel;
|
||||
var _channelFeeds = const <ChannelRelayFeed>[];
|
||||
/// Shared by the tracks: time any of them waits on the source.
|
||||
final clock = FeedClock();
|
||||
final _rate = SourceRate();
|
||||
|
||||
ActiveRelay._(this.mediaId, this.name, this.sourceToken, this.onLiveChanged);
|
||||
|
||||
/// Takes the bytes the source sent so far, for [rate]; the server calls
|
||||
/// it once a second.
|
||||
void sampleRate() => _rate.sample(buffers.fold(0, (a, b) => a + b.bytesFromFeed), clock.busy);
|
||||
|
||||
/// Bytes per second the source delivers while the buffer asks for bytes
|
||||
/// (`media.relay.rate`); 0 until measured.
|
||||
int get rate => _rate.rate;
|
||||
|
||||
/// The media's average bytes per second for a [duration] in seconds
|
||||
/// (`media.relay.need`): byte tracks by size, pieces tracks from the
|
||||
/// pieces in so far; 0 while any track is unknown.
|
||||
int need(double duration) {
|
||||
if (buffers.isEmpty) return 0;
|
||||
var total = 0.0;
|
||||
for (final b in buffers) {
|
||||
if (b is RelayBuffer) {
|
||||
if (b.size <= 0 || duration <= 0) return 0;
|
||||
total += b.size / duration;
|
||||
} else if (b is PieceBuffer) {
|
||||
final r = b.bytesPerSec;
|
||||
if (r == null) return 0;
|
||||
total += r;
|
||||
}
|
||||
}
|
||||
return roundRate(total);
|
||||
}
|
||||
|
||||
int _nextId() => ++_ids;
|
||||
|
||||
void _setTracks(List<RelayTrackSpec> tracks) {
|
||||
@@ -69,6 +100,9 @@ class ActiveRelay {
|
||||
for (final t in this.tracks)
|
||||
t.pieces ? PieceBuffer(nextId: _nextId, rate: _piecesRate(t.kind)) : RelayBuffer(nextId: _nextId),
|
||||
];
|
||||
for (final b in buffers) {
|
||||
b.clock = clock;
|
||||
}
|
||||
if (!_known.isCompleted) _known.complete();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/// How fast bytes move through the server, coarse enough for `state`
|
||||
/// (sent every second): the relay source's delivery rate and each
|
||||
/// member's read rate.
|
||||
library;
|
||||
|
||||
/// [bytesPerSec] kept to two significant digits (under 10 kB/s: 0), so
|
||||
/// the state does not change with every byte.
|
||||
int roundRate(double bytesPerSec) {
|
||||
if (!bytesPerSec.isFinite || bytesPerSec < 10000) return 0;
|
||||
var unit = 1;
|
||||
while (bytesPerSec / unit >= 100) {
|
||||
unit *= 10;
|
||||
}
|
||||
return (bytesPerSec / unit).round() * unit;
|
||||
}
|
||||
|
||||
/// Time a relay's feed had at least one pull in flight. The tracks of a
|
||||
/// media share one (they share the source's uplink), so its time counts
|
||||
/// once however many tracks pull at the same moment.
|
||||
class FeedClock {
|
||||
final Stopwatch _watch;
|
||||
var _open = 0;
|
||||
FeedClock([Stopwatch? watch]) : _watch = watch ?? Stopwatch();
|
||||
|
||||
void start() {
|
||||
if (_open++ == 0) _watch.start();
|
||||
}
|
||||
|
||||
void stop() {
|
||||
if (_open == 0) return;
|
||||
if (--_open == 0) _watch.stop();
|
||||
}
|
||||
|
||||
Duration get busy => _watch.elapsed;
|
||||
}
|
||||
|
||||
/// The source's delivery rate: bytes over the time a pull was in flight,
|
||||
/// so a buffer that is full (the source idles) does not read as a slow
|
||||
/// source. Smoothed over about [window] of busy time; null until
|
||||
/// [settle] of busy time has been measured.
|
||||
class SourceRate {
|
||||
final Duration window;
|
||||
final Duration settle;
|
||||
SourceRate({this.window = const Duration(seconds: 4), this.settle = const Duration(seconds: 2)});
|
||||
|
||||
int _bytes = 0;
|
||||
Duration _busy = Duration.zero;
|
||||
Duration _measured = Duration.zero;
|
||||
double? _rate;
|
||||
|
||||
/// Takes the feed's totals now: all bytes so far, all busy time so far.
|
||||
/// Gaps under a quarter second of busy time wait for the next sample
|
||||
/// (a pull's first-byte wait would dominate them).
|
||||
void sample(int bytes, Duration busy) {
|
||||
final db = bytes - _bytes;
|
||||
final dt = busy - _busy;
|
||||
if (db < 0 || dt < Duration.zero) {
|
||||
// Totals went back (a new feed): start over.
|
||||
_bytes = bytes;
|
||||
_busy = busy;
|
||||
return;
|
||||
}
|
||||
if (dt < const Duration(milliseconds: 250)) return;
|
||||
_bytes = bytes;
|
||||
_busy = busy;
|
||||
final secs = dt.inMicroseconds / 1e6;
|
||||
final now = db / secs;
|
||||
final r = _rate;
|
||||
final w = (secs / (window.inMicroseconds / 1e6)).clamp(0.0, 1.0);
|
||||
_rate = r == null ? now : r + (now - r) * w;
|
||||
_measured += dt;
|
||||
}
|
||||
|
||||
/// Bytes per second, rounded ([roundRate]); 0 while not settled.
|
||||
int get rate => _measured < settle || _rate == null ? 0 : roundRate(_rate!);
|
||||
}
|
||||
|
||||
/// Each member's read rate from the server: the change of its
|
||||
/// `bytesServed` over the last [window].
|
||||
class ReadRates {
|
||||
final Duration window;
|
||||
ReadRates({this.window = const Duration(seconds: 5)});
|
||||
|
||||
final _seen = <String, List<(DateTime, int)>>{};
|
||||
|
||||
/// Records [served] totals by token at [now]; returns each token's rate
|
||||
/// (rounded). Tokens not in [served] are forgotten.
|
||||
Map<String, int> sample(DateTime now, Map<String, int> served) {
|
||||
_seen.removeWhere((t, _) => !served.containsKey(t));
|
||||
final out = <String, int>{};
|
||||
for (final e in served.entries) {
|
||||
final list = _seen.putIfAbsent(e.key, () => []);
|
||||
list.add((now, e.value));
|
||||
while (list.length > 1 && now.difference(list[1].$1) >= window) {
|
||||
list.removeAt(0);
|
||||
}
|
||||
final first = list.first;
|
||||
final secs = now.difference(first.$1).inMicroseconds / 1e6;
|
||||
out[e.key] = secs <= 0 ? 0 : roundRate((e.value - first.$2) / secs);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import 'package:relic/relic.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:web_socket/web_socket.dart';
|
||||
|
||||
import 'queue_ops.dart';
|
||||
import 'relay_routes.dart';
|
||||
import 'relay_stats.dart';
|
||||
import 'session.dart';
|
||||
|
||||
final _hubLog = createLogger('server_hub');
|
||||
@@ -80,6 +82,29 @@ class ServerHub {
|
||||
changed();
|
||||
}
|
||||
|
||||
final _readRates = ReadRates();
|
||||
|
||||
/// Once a second: each member's read rate (`members[].rate`) and the
|
||||
/// relay's source rate and bitrate (`media.relay.rate` / `need`). Goes
|
||||
/// out with the next state.
|
||||
void sampleRates() {
|
||||
final tokens = session.tokens;
|
||||
final rates = _readRates.sample(engine.now, {
|
||||
for (final t in engine.members.keys) t: tokens[t]?.bytesServed ?? 0,
|
||||
});
|
||||
for (final m in engine.members.values) {
|
||||
m.rate = rates[m.token] ?? 0;
|
||||
}
|
||||
final r = relay, m = engine.media;
|
||||
final was = m?.relay;
|
||||
if (r == null || m == null || was == null || r.mediaId != engine.mediaId) return;
|
||||
r.sampleRate();
|
||||
final rate = r.rate, need = r.need(m.duration);
|
||||
if (rate != was.rate || need != was.need) {
|
||||
engine.media = m.copyWith(relay: was.copyWith(rate: rate, need: need));
|
||||
}
|
||||
}
|
||||
|
||||
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
|
||||
|
||||
/// The member a reconnecting client may take over with its old token:
|
||||
@@ -151,6 +176,13 @@ class ServerHub {
|
||||
broadcastFeed();
|
||||
}
|
||||
|
||||
/// The server's own fetch of [name] failed: everyone hears it (the
|
||||
/// swapper also gets the reason as an error), not just "getting it…".
|
||||
void fetchFailed(String name) {
|
||||
engine.feed.add(FeedMessage(actor: 'server', action: 'fetch-fail', target: name, at: engine.now));
|
||||
broadcastFeed();
|
||||
}
|
||||
|
||||
void changed() {
|
||||
broadcastState();
|
||||
broadcastFeed();
|
||||
@@ -314,7 +346,19 @@ void mountSyncRoute(
|
||||
hub.changed();
|
||||
case 'swap':
|
||||
if (token == null) return;
|
||||
final parsed = SwapIntent.fromJson(decoded).media;
|
||||
final swap = SwapIntent.fromJson(decoded);
|
||||
var parsed = swap.media;
|
||||
final queueId = swap.queueId;
|
||||
if (queueId != null) {
|
||||
// Already put up by someone else: nothing to do.
|
||||
if (!hub.engine.queue.has(queueId)) return;
|
||||
// A server-fetched item's link never left the server.
|
||||
final hidden = hub.engine.queue.privateUrlOf(queueId);
|
||||
if (hidden != null && (parsed.url ?? '').isEmpty) {
|
||||
parsed = HelloMedia(
|
||||
name: parsed.name, size: parsed.size, duration: parsed.duration, url: hidden, relay: parsed.relay);
|
||||
}
|
||||
}
|
||||
if (parsed.jellyfin != null) {
|
||||
// By reference only: no URL (it could carry a token) is
|
||||
// stored or broadcast for Jellyfin media.
|
||||
@@ -330,7 +374,7 @@ void mountSyncRoute(
|
||||
duration: parsed.duration,
|
||||
jellyfin: ref,
|
||||
relay: relayReq == null ? null : hub.relayFor(token, const MediaRelay()));
|
||||
if (media == null || !hub.engine.applySwap(token, media)) {
|
||||
if (media == null || !hub.engine.applySwap(token, media, queueId: queueId)) {
|
||||
wsErr(ws, 'bad swap media');
|
||||
return;
|
||||
}
|
||||
@@ -368,7 +412,7 @@ void mountSyncRoute(
|
||||
size: parsed.size,
|
||||
duration: parsed.duration,
|
||||
relay: hub.relayFor(token, relayReq));
|
||||
if (!hub.engine.applySwap(token, media)) {
|
||||
if (!hub.engine.applySwap(token, media, queueId: queueId)) {
|
||||
wsErr(ws, 'bad swap media');
|
||||
return;
|
||||
}
|
||||
@@ -379,7 +423,10 @@ void mountSyncRoute(
|
||||
? ActiveRelay.fetch(hub.engine.mediaId, name, link!,
|
||||
ytDlp: hub.ytDlp,
|
||||
onLiveChanged: () => hub.relayLiveChanged(active),
|
||||
onFail: (why) => wsErr(ws, hub.fetchFailText(why)))
|
||||
onFail: (why) {
|
||||
wsErr(ws, hub.fetchFailText(why));
|
||||
hub.fetchFailed(name);
|
||||
})
|
||||
: _memberRelay(hub, token, name, media.relay!.tracks);
|
||||
hub.setRelay(active);
|
||||
hub.changed();
|
||||
@@ -399,7 +446,7 @@ void mountSyncRoute(
|
||||
wsErr(ws, 'swap needs a url or the hosted file');
|
||||
return;
|
||||
}
|
||||
if (!hub.engine.applySwap(token, intent.media)) {
|
||||
if (!hub.engine.applySwap(token, intent.media, queueId: queueId)) {
|
||||
wsErr(ws, 'bad swap media');
|
||||
return;
|
||||
}
|
||||
@@ -464,6 +511,9 @@ void mountSyncRoute(
|
||||
case 'nudge':
|
||||
if (token == null) return;
|
||||
hub.nudge(token, NudgeIntent.fromJson(decoded).target);
|
||||
case 'queue':
|
||||
if (token == null) return;
|
||||
handleQueueIntent(hub, token, ws, decoded);
|
||||
}
|
||||
} on ProtocolException catch (e) {
|
||||
wsErr(ws, 'bad message: ${e.message}');
|
||||
|
||||
@@ -148,6 +148,7 @@ class SyncServer {
|
||||
hub.engine.tick();
|
||||
hub.sweepClosedSockets();
|
||||
hub.dropSilentSockets();
|
||||
hub.sampleRates();
|
||||
hub.broadcastState();
|
||||
hub.broadcastFeed();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
class _Peer {
|
||||
final WebSocket ws;
|
||||
final List<Map<String, Object?>> msgs = [];
|
||||
_Peer(this.ws) {
|
||||
ws.listen((m) => msgs.add(Map<String, Object?>.from(jsonDecode('$m') as Map)));
|
||||
}
|
||||
|
||||
static Future<_Peer> join(int port, String name) async {
|
||||
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
|
||||
final p = _Peer(ws);
|
||||
p.send(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 's',
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||
.toJson());
|
||||
await p.until((m) => m['t'] == 'welcome');
|
||||
return p;
|
||||
}
|
||||
|
||||
void send(Map<String, Object?> json) => ws.add(jsonEncode(json));
|
||||
|
||||
Future<Map<String, Object?>> until(bool Function(Map<String, Object?> m) test) async {
|
||||
final end = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
for (final m in msgs) {
|
||||
if (test(m)) {
|
||||
msgs.remove(m);
|
||||
return m;
|
||||
}
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
fail('nothing matched in $msgs');
|
||||
}
|
||||
|
||||
/// The next state whose queue passes [test].
|
||||
Future<StateBroadcast> state(bool Function(StateBroadcast s) test) async {
|
||||
final m = await until((m) => m['t'] == 'state' && test(StateBroadcast.fromJson(m)));
|
||||
return StateBroadcast.fromJson(m);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late SyncServer server;
|
||||
|
||||
setUp(() async {
|
||||
server = SyncServer(const AppConfig(port: 0, secret: 's', name: 't'));
|
||||
await server.start();
|
||||
});
|
||||
|
||||
tearDown(() => server.stop());
|
||||
|
||||
test('queued items reach everyone; a server-fetched link stays on the server; a swap takes its item', () async {
|
||||
final a = await _Peer.join(server.boundPort, 'alice');
|
||||
final b = await _Peer.join(server.boundPort, 'bob');
|
||||
a.send(const QueueIntent.add(QueueItem(
|
||||
media: HelloMedia(name: 'https://example.com/v?token=secret', size: 0, duration: 0,
|
||||
url: 'https://example.com/v?token=secret'),
|
||||
how: QueueHow.server,
|
||||
title: 'A video',
|
||||
sub: 'Example'))
|
||||
.toJson());
|
||||
a.send(const QueueIntent.add(
|
||||
QueueItem(
|
||||
media: HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://example.com/x.mp4'),
|
||||
how: QueueHow.direct,
|
||||
title: 'Direct'),
|
||||
next: true)
|
||||
.toJson());
|
||||
final s = await b.state((s) => s.queue.length == 2);
|
||||
expect([for (final q in s.queue) q.title], ['Direct', 'A video']);
|
||||
final fetched = s.queue[1];
|
||||
expect(fetched.byName, 'alice');
|
||||
expect(jsonEncode(s.toJson()), isNot(contains('secret')), reason: 'the fetched URL is never broadcast');
|
||||
expect(fetched.media.relay?.fetch, isTrue);
|
||||
|
||||
// Bob puts the fetched item up without knowing its URL; the server fills it in.
|
||||
b.send(SwapIntent(
|
||||
media: const HelloMedia(name: 'A video', size: 0, duration: 0, relay: MediaRelay(fetch: true)),
|
||||
queueId: fetched.id)
|
||||
.toJson());
|
||||
final after = await a.state((s) => s.media != null);
|
||||
expect(after.media?['name'], 'A video');
|
||||
expect(after.queue.single.title, 'Direct');
|
||||
final mediaId = after.mediaId;
|
||||
|
||||
// A second swap of the same item (a race) is ignored.
|
||||
a.send(SwapIntent(
|
||||
media: const HelloMedia(name: 'A video', size: 0, duration: 0, relay: MediaRelay(fetch: true)),
|
||||
queueId: fetched.id)
|
||||
.toJson());
|
||||
a.msgs.clear();
|
||||
final later = await a.state((s) => true);
|
||||
expect(later.mediaId, mediaId);
|
||||
});
|
||||
|
||||
test('a bad add answers the sender; adder-only items need a key', () async {
|
||||
final a = await _Peer.join(server.boundPort, 'alice');
|
||||
a.send(const QueueIntent.add(QueueItem(
|
||||
media: HelloMedia(name: 'f.mkv', size: 10, duration: 0), how: QueueHow.file, title: 'f'))
|
||||
.toJson());
|
||||
final err = await a.until((m) => m['t'] == 'err');
|
||||
expect('${err['message']}', contains('key'));
|
||||
a.send(const QueueIntent.add(QueueItem(
|
||||
media: HelloMedia(name: 'f.mkv', size: 10, duration: 0, url: 'file:///home/me/f.mkv'),
|
||||
how: QueueHow.file,
|
||||
title: 'f',
|
||||
key: 'k1234567890'))
|
||||
.toJson());
|
||||
final s = await a.state((s) => s.queue.isNotEmpty);
|
||||
expect(s.queue.single.media.url, isNull, reason: 'an adder-only item carries no path or link');
|
||||
expect(s.queue.single.key, 'k1234567890');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// A feed that only records pulls; the test answers them.
|
||||
class _Feed extends RelayFeed {
|
||||
final pulls = <int>[];
|
||||
@override
|
||||
void pull(int id, int start, int end) => pulls.add(id);
|
||||
@override
|
||||
void pullPiece(int id, int index) => pulls.add(id);
|
||||
@override
|
||||
void cancel(int id) {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('rates keep two significant digits; under 10 kB/s is 0', () {
|
||||
expect(roundRate(3249999), 3200000);
|
||||
expect(roundRate(87654), 88000);
|
||||
expect(roundRate(9999), 0);
|
||||
expect(roundRate(double.nan), 0);
|
||||
});
|
||||
|
||||
test('the source rate counts busy time only, and settles first', () {
|
||||
final r = SourceRate();
|
||||
r.sample(2000000, const Duration(seconds: 1));
|
||||
expect(r.rate, 0, reason: 'under 2 s of busy time');
|
||||
r.sample(4000000, const Duration(seconds: 2));
|
||||
expect(r.rate, 2000000);
|
||||
// The buffer is full: no pull, no bytes, no busy time: the rate holds.
|
||||
r.sample(4000000, const Duration(seconds: 2));
|
||||
expect(r.rate, 2000000);
|
||||
// Slower now (0.5 MB/s): moves toward it over the window.
|
||||
r.sample(5000000, const Duration(seconds: 4));
|
||||
expect(r.rate, inExclusiveRange(500000, 2000000));
|
||||
// A new feed (totals back to 0) starts over without a bogus sample.
|
||||
final before = r.rate;
|
||||
r.sample(0, Duration.zero);
|
||||
expect(r.rate, before);
|
||||
});
|
||||
|
||||
test('read rates: bytesServed over the last 5 s, forgotten tokens drop', () {
|
||||
final rates = ReadRates();
|
||||
final t0 = DateTime.utc(2026);
|
||||
expect(rates.sample(t0, {'a': 0, 'b': 0}), {'a': 0, 'b': 0});
|
||||
for (var i = 1; i <= 10; i++) {
|
||||
final out = rates.sample(t0.add(Duration(seconds: i)), {'a': i * 2000000, 'b': 0});
|
||||
expect(out['a'], 2000000);
|
||||
expect(out['b'], 0);
|
||||
}
|
||||
// a stops reading: its rate falls to 0 once the window has passed.
|
||||
var out = <String, int>{};
|
||||
for (var i = 11; i <= 16; i++) {
|
||||
out = rates.sample(t0.add(Duration(seconds: i)), {'a': 20000000});
|
||||
}
|
||||
expect(out, {'a': 0});
|
||||
});
|
||||
|
||||
test('a buffer runs its clock while a pull is in flight', () async {
|
||||
final clock = FeedClock();
|
||||
final buf = RelayBuffer()..clock = clock;
|
||||
final feed = _Feed();
|
||||
buf.attach(feed, 3 * RelayBuffer.slice);
|
||||
// A viewer asks: the buffer pulls.
|
||||
final read = buf.read('v', 0, 9).toList();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(feed.pulls, isNotEmpty);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
final busy = clock.busy;
|
||||
expect(busy, greaterThan(Duration.zero));
|
||||
final id = feed.pulls.first;
|
||||
buf.feedBytes(id, Uint8List(3 * RelayBuffer.slice));
|
||||
buf.feedEnd(id);
|
||||
await read;
|
||||
final after = clock.busy;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
expect(clock.busy, after, reason: 'idle: the clock stopped');
|
||||
buf.dispose();
|
||||
});
|
||||
|
||||
test('the hub puts each member\'s read rate in the state', () {
|
||||
var now = DateTime.utc(2026);
|
||||
final hub = ServerHub(SessionMap(), 'pw', engine: SyncEngine(clock: () => now));
|
||||
final token = hub.session.issueToken('bob', '');
|
||||
hub.engine.applyJoin(token, 'bob', '');
|
||||
hub.sampleRates();
|
||||
hub.session.addBytes(token, 3000000);
|
||||
now = now.add(const Duration(seconds: 1));
|
||||
hub.sampleRates();
|
||||
final json = hub.stateBroadcast().toJson();
|
||||
final members = (json['members'] as List).cast<Map<String, Object?>>();
|
||||
expect(members.single['rate'], 3000000);
|
||||
expect(StateBroadcast.fromJson(json).members.single.rate, 3000000);
|
||||
});
|
||||
|
||||
test('media.relay rate and need round-trip; slow only with both known', () {
|
||||
const r = MediaRelay(source: 'm1', live: true, rate: 1200000, need: 2300000);
|
||||
final back = MediaRelay.fromJson(r.toJson());
|
||||
expect(back.rate, 1200000);
|
||||
expect(back.need, 2300000);
|
||||
expect(back.slow, isTrue);
|
||||
expect(const MediaRelay(rate: 1200000).slow, isFalse);
|
||||
expect(const MediaRelay().toJson().containsKey('rate'), isFalse);
|
||||
expect(MediaRelay.fromJson({'rate': double.infinity, 'need': -1}).rate, 0);
|
||||
});
|
||||
|
||||
test('a relay\'s need: byte tracks by size over the duration', () {
|
||||
final r = ActiveRelay.fromMember(1, 'x', 't', onLiveChanged: () {});
|
||||
expect(r.need(100), 0, reason: 'size unknown until the source connects');
|
||||
(r.buffers.single as RelayBuffer).attach(_Feed(), 230000000);
|
||||
expect(r.need(100), 2300000);
|
||||
expect(r.need(0), 0, reason: 'duration unknown');
|
||||
r.dispose();
|
||||
});
|
||||
|
||||
}
|
||||
@@ -64,8 +64,8 @@ void main() {
|
||||
});
|
||||
|
||||
group('core welcome + proto', () {
|
||||
test('proto 11: avatars by hash, profile intent', () {
|
||||
expect(RelayConstants.protoVersion, 11);
|
||||
test('proto 12: avatars by hash, profile intent', () {
|
||||
expect(RelayConstants.protoVersion, 12);
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user