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:
foxyzandClaude Opus 5.5 committed 2026-10-11 06:40:26 +03:00
1 parent 95cd668cfb
commit 6b10cae4e6
50 files changed
+4239 -157

No files matched your search

+1
View File
@@ -4,6 +4,7 @@ export 'src/logging.dart';
export 'src/clock_sync.dart';
export 'src/config.dart';
export 'src/protocol.dart';
export 'src/queue.dart';
export 'src/relay.dart';
export 'src/relay_pieces.dart';
export 'src/link_probe.dart';
+9 -1
View File
@@ -1,5 +1,5 @@
abstract class RelayConstants {
static const int protoVersion = 11;
static const int protoVersion = 12;
// Relay (`/r/...`): a member's bytes through the server's buffer.
static const int relaySliceBytes = 1 << 20;
@@ -56,4 +56,12 @@ abstract class RelayConstants {
/// minute; the server drops the rest silently.
static const int nudgesPerSec = 3;
static const int nudgesPerMin = 20;
/// Up next (`queue`): at most this many items; a title or second line
/// is cut to [maxQueueTextLength] characters; an item's picture is
/// capped like a poster ([maxPosterBytes]). A play request nobody acts on
/// ends after [queuePlayTimeoutSecs].
static const int maxQueueItems = 100;
static const int maxQueueTextLength = 200;
static const int queuePlayTimeoutSecs = 30;
}
+57 -7
View File
@@ -1,5 +1,6 @@
import 'constants.dart';
import 'errors.dart';
import 'queue.dart';
/// Reads an optional JSON number as a finite double. JSON like 1e400 decodes to
/// Infinity, which would later break jsonEncode on every broadcast.
@@ -179,12 +180,22 @@ class RelayTrackSpec {
/// [tracks]: the tracks of a media relayed as several played together, or
/// as one segmented playlist; empty for one file. A member swap names
/// them, the server fills them in for [fetch] once it has read the link.
/// [rate] and [need] are the server's measures, 0 while unknown: bytes per
/// second the source (or the server's own fetch) delivers while the
/// buffer asks for bytes, and the media's average bitrate.
class MediaRelay {
final String source;
final bool fetch;
final bool live;
final List<RelayTrackSpec> tracks;
const MediaRelay({this.source = '', this.fetch = false, this.live = false, this.tracks = const []});
final int rate;
final int need;
const MediaRelay(
{this.source = '', this.fetch = false, this.live = false, this.tracks = const [], this.rate = 0, this.need = 0});
/// The source is measured slower than the media plays (both known):
/// below 85 % of [need], so a rate close to it does not flicker.
bool get slow => rate > 0 && need > 0 && rate < need * 0.85;
static const trackKinds = {'video', 'audio'};
static const maxTracks = 4;
@@ -193,14 +204,21 @@ class MediaRelay {
static bool validTracks(List<RelayTrackSpec> t) =>
t.length <= maxTracks && (t.length > 1 || (t.length == 1 && t.single.pieces));
MediaRelay copyWith({bool? live, List<RelayTrackSpec>? tracks}) =>
MediaRelay(source: source, fetch: fetch, live: live ?? this.live, tracks: tracks ?? this.tracks);
MediaRelay copyWith({bool? live, List<RelayTrackSpec>? tracks, int? rate, int? need}) => MediaRelay(
source: source,
fetch: fetch,
live: live ?? this.live,
tracks: tracks ?? this.tracks,
rate: rate ?? this.rate,
need: need ?? this.need);
Map<String, Object?> toJson() => {
if (source.isNotEmpty) 'source': source,
if (fetch) 'fetch': true,
if (live) 'live': true,
if (validTracks(tracks)) 'tracks': [for (final t in tracks) t.toJson()],
if (rate > 0) 'rate': rate,
if (need > 0) 'need': need,
};
/// Unknown kinds, a single byte track or too many make it one file.
@@ -212,8 +230,13 @@ class MediaRelay {
source: json['source'] is String ? json['source'] as String : '',
fetch: json['fetch'] == true,
live: json['live'] == true,
tracks: ok ? List.unmodifiable(specs.cast<RelayTrackSpec>()) : const []);
tracks: ok ? List.unmodifiable(specs.cast<RelayTrackSpec>()) : const [],
rate: _byteRate(json['rate']),
need: _byteRate(json['need']));
}
/// A bytes-per-second field: a finite non-negative number, else 0.
static int _byteRate(Object? v) => v is num && v.isFinite && v > 0 ? v.round() : 0;
}
class HelloMessage {
@@ -412,15 +435,23 @@ class SeekIntent {
class SwapIntent {
final HelloMedia media;
const SwapIntent({required this.media});
/// The Up next item this swap puts up (`queue[].id`); the server takes
/// it out of the queue with the swap, and ignores the swap when it is no
/// longer queued (someone else put it up first).
final String? queueId;
const SwapIntent({required this.media, this.queueId});
Map<String, Object?> toJson() => {'t': 'swap', 'media': media.toJson()};
Map<String, Object?> toJson() =>
{'t': 'swap', 'media': media.toJson(), if (queueId != null) 'queueId': queueId};
factory SwapIntent.fromJson(Map<String, Object?> json) {
if (json['t'] != 'swap') throw const ProtocolException('not a swap intent');
final media = json['media'];
if (media is! Map) throw const ProtocolException('swap missing media');
return SwapIntent(media: HelloMedia.fromJson(Map<String, Object?>.from(media)));
final queueId = json['queueId'];
return SwapIntent(
media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
queueId: queueId is String && queueId.isNotEmpty ? queueId : null);
}
}
@@ -600,6 +631,9 @@ class MemberView {
/// Why its player cannot open the current media (`report.error`); empty
/// while it can. Errored: outside the ready gate.
final String error;
/// Bytes per second the server sent this member lately (its file, the
/// Jellyfin relay or the relay); 0 when none.
final int rate;
const MemberView({
required this.id,
required this.name,
@@ -612,6 +646,7 @@ class MemberView {
this.host = false,
this.out = false,
this.error = '',
this.rate = 0,
});
Map<String, Object?> toJson() => {
@@ -626,6 +661,7 @@ class MemberView {
if (host) 'host': true,
if (out) 'out': true,
if (error.isNotEmpty) 'error': error,
if (rate > 0) 'rate': rate,
};
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
@@ -640,6 +676,7 @@ class MemberView {
host: json['host'] == true,
out: json['out'] == true,
error: memberErrorFromJson(json['error']),
rate: MediaRelay._byteRate(json['rate']),
);
}
@@ -674,6 +711,11 @@ class StateBroadcast {
/// The server can relay the current Jellyfin media (`/j/...`) for viewers
/// without their own access to it.
final bool relay;
/// Up next, in order (see `QueueItem`); empty when nothing is queued.
final List<QueueItem> queue;
/// The queued item someone asked to play now, waiting for the member
/// who can put it up; null when none.
final String? queuePlay;
const StateBroadcast({
required this.eventId,
required this.transport,
@@ -685,6 +727,8 @@ class StateBroadcast {
this.seekBy,
this.seekSeq,
this.relay = false,
this.queue = const [],
this.queuePlay,
});
Map<String, Object?> toJson() => {
@@ -699,6 +743,8 @@ class StateBroadcast {
if (seekBy != null) 'seekBy': seekBy,
if (seekSeq != null) 'seekSeq': seekSeq,
if (relay) 'relay': true,
if (queue.isNotEmpty) 'queue': [for (final q in queue) q.toJson()],
if (queuePlay != null) 'queuePlay': queuePlay,
};
factory StateBroadcast.fromJson(Map<String, Object?> json) {
@@ -721,6 +767,10 @@ class StateBroadcast {
seekBy: json['seekBy'] as String?,
seekSeq: json['seekSeq'] == null ? null : finiteInt(json['seekSeq'], 'seekSeq'),
relay: (json['relay'] as bool?) ?? false,
queue: json['queue'] is List
? (json['queue'] as List).map(QueueItem.fromJson).whereType<QueueItem>().toList()
: const [],
queuePlay: json['queuePlay'] is String ? json['queuePlay'] as String : null,
);
}
}
+336
View File
@@ -0,0 +1,336 @@
import 'dart:typed_data';
import 'constants.dart';
import 'errors.dart';
import 'protocol.dart';
import 'sync_engine.dart';
// Up next: what plays after the current media, held by the server for
// everyone (see PROTOCOL.md "Queue"). Wire types, and [EngineQueue], the
// server's list (pure, so the engine and tests share it).
/// How a queued item plays, and so who can put it up when it comes up.
enum QueueHow {
/// A link everyone opens themselves: anyone puts it up.
direct,
/// A link the server fetches: anyone puts it up (the server keeps the URL).
server,
/// A link the adder's app reads and passes on: only the adder.
through,
/// A file on the adder's computer: only the adder.
file,
/// A Jellyfin item: someone with an account on that server.
jellyfin;
static QueueHow? parse(Object? v) {
for (final h in values) {
if (h.name == v) return h;
}
return null;
}
/// Only the adder's app has what it needs (its file, its cookies).
bool get adderOnly => this == through || this == file;
}
/// One entry of Up next as everyone sees it. [media] is what the swap
/// puts up, without a URL for [QueueHow.server], [QueueHow.through] and
/// [QueueHow.file] (the server, or the adder's app, keeps it). [key]: the
/// adder's own handle for what it keeps (adder-only items).
class QueueItem {
final String id;
final HelloMedia media;
final QueueHow how;
final String title;
/// A second line: the site, uploader or show; may be empty.
final String sub;
/// Seconds, 0 when unknown.
final double duration;
/// Picture hash, served at `/i/...`; empty when none.
final String thumb;
/// Member id and name of who added it (the name as it was then).
final String by;
final String byName;
final String key;
const QueueItem({
this.id = '',
required this.media,
required this.how,
required this.title,
this.sub = '',
this.duration = 0,
this.thumb = '',
this.by = '',
this.byName = '',
this.key = '',
});
QueueItem copyWith({String? id, HelloMedia? media, String? title, String? sub, double? duration,
String? thumb, String? by, String? byName, String? key}) =>
QueueItem(
id: id ?? this.id,
media: media ?? this.media,
how: how,
title: title ?? this.title,
sub: sub ?? this.sub,
duration: duration ?? this.duration,
thumb: thumb ?? this.thumb,
by: by ?? this.by,
byName: byName ?? this.byName,
key: key ?? this.key,
);
Map<String, Object?> toJson() => {
if (id.isNotEmpty) 'id': id,
'media': media.toJson(),
'how': how.name,
'title': title,
if (sub.isNotEmpty) 'sub': sub,
if (duration > 0) 'duration': duration,
if (thumb.isNotEmpty) 'thumb': thumb,
if (by.isNotEmpty) 'by': by,
if (byName.isNotEmpty) 'byName': byName,
if (key.isNotEmpty) 'key': key,
};
/// Null when [j] is not an item this side can read.
static QueueItem? fromJson(Object? j) {
if (j is! Map) return null;
final media = j['media'];
final how = QueueHow.parse(j['how']);
if (media is! Map || how == null) return null;
final d = j['duration'];
return QueueItem(
id: j['id'] is String ? j['id'] as String : '',
media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
how: how,
title: j['title'] is String ? j['title'] as String : '',
sub: j['sub'] is String ? j['sub'] as String : '',
duration: d is num && d.isFinite && d > 0 ? d.toDouble() : 0,
thumb: j['thumb'] is String ? j['thumb'] as String : '',
by: j['by'] is String ? j['by'] as String : '',
byName: j['byName'] is String ? j['byName'] as String : '',
key: j['key'] is String ? j['key'] as String : '',
);
}
}
/// Client to server: one change to Up next. [op] `add` (with [item],
/// [next] = right after what plays instead of at the end, [thumb] base64
/// picture), `move` ([id] to index [to]), `remove` ([id]; [why] set when it
/// is skipped because it cannot play), `play` ([id] now) or `skip` (the
/// first item now).
class QueueIntent {
final String op;
final QueueItem? item;
final bool next;
final String thumb;
final String id;
final int to;
final String why;
const QueueIntent._(this.op,
{this.item, this.next = false, this.thumb = '', this.id = '', this.to = 0, this.why = ''});
const QueueIntent.add(QueueItem item, {bool next = false, String thumb = ''})
: this._('add', item: item, next: next, thumb: thumb);
const QueueIntent.move(String id, int to) : this._('move', id: id, to: to);
const QueueIntent.remove(String id, {String why = ''}) : this._('remove', id: id, why: why);
const QueueIntent.play(String id) : this._('play', id: id);
const QueueIntent.skip() : this._('skip');
static const ops = {'add', 'move', 'remove', 'play', 'skip'};
Map<String, Object?> toJson() => {
't': 'queue',
'op': op,
if (item != null) 'item': item!.toJson(),
if (op == 'add') 'at': next ? 'next' : 'end',
if (thumb.isNotEmpty) 'thumb': thumb,
if (id.isNotEmpty) 'id': id,
if (op == 'move') 'to': to,
if (why.isNotEmpty) 'why': why,
};
factory QueueIntent.fromJson(Map<String, Object?> json) {
if (json['t'] != 'queue') throw const ProtocolException('not a queue intent');
final op = json['op'];
if (op is! String || !ops.contains(op)) throw const ProtocolException('unknown queue op');
final item = QueueItem.fromJson(json['item']);
if (op == 'add' && item == null) throw const ProtocolException('queue add needs an item');
return QueueIntent._(op,
item: item,
next: json['at'] == 'next',
thumb: json['thumb'] is String ? json['thumb'] as String : '',
id: json['id'] is String ? json['id'] as String : '',
to: finiteInt(json['to'], 'to'),
why: json['why'] is String ? _cut(json['why'] as String, 120) : '');
}
}
String _cut(String s, int max) {
final line = s.replaceAll(RegExp(r'\s+'), ' ').trim();
return line.length > max ? line.substring(0, max) : line;
}
/// Display text of a queued item: one line, at most
/// [RelayConstants.maxQueueTextLength] characters.
String cleanQueueText(String s) => _cut(s, RelayConstants.maxQueueTextLength);
class _Entry {
QueueItem item;
/// A server-fetched link's URL: never broadcast.
final String? privateUrl;
final ({String hash, Uint8List bytes})? thumb;
_Entry(this.item, this.privateUrl, this.thumb);
}
/// The server's Up next: items in order with server-assigned ids, and at
/// most one pending [play] request (an item someone asked to play now,
/// waiting for the member who can put it up). In memory only.
class EngineQueue {
final List<_Entry> _items = [];
int _nextId = 1;
String? play;
DateTime? _playAt;
List<QueueItem> get items => [for (final e in _items) e.item];
bool get isEmpty => _items.isEmpty;
int get length => _items.length;
int _index(String id) => _items.indexWhere((e) => e.item.id == id);
bool has(String id) => _index(id) >= 0;
QueueItem? item(String id) {
final i = _index(id);
return i < 0 ? null : _items[i].item;
}
/// The URL a server-fetched item keeps to itself.
String? privateUrlOf(String id) {
final i = _index(id);
return i < 0 ? null : _items[i].privateUrl;
}
/// Adds [item] (already cleaned) with a fresh id, at the end or, with
/// [next], first. Null when full ([RelayConstants.maxQueueItems]).
QueueItem? add(QueueItem item, {bool next = false, String? privateUrl, Uint8List? thumb}) {
if (_items.length >= RelayConstants.maxQueueItems) return null;
final t = thumb == null ? null : ArtImage(thumb);
final added = item.copyWith(id: 'q${_nextId++}', thumb: t?.hash ?? '');
final e = _Entry(added, privateUrl, t == null ? null : (hash: t.hash, bytes: t.bytes));
if (next) {
_items.insert(0, e);
} else {
_items.add(e);
}
return added;
}
/// Moves [id] to [to] (clamped). False when unknown or unchanged.
bool move(String id, int to) {
final i = _index(id);
if (i < 0) return false;
final target = to.clamp(0, _items.length - 1);
if (target == i) return false;
final e = _items.removeAt(i);
_items.insert(target, e);
return true;
}
/// Removes [id]; returns it, or null when unknown.
QueueItem? remove(String id) {
final i = _index(id);
if (i < 0) return null;
final e = _items.removeAt(i);
if (play == id) _clearPlay();
return e.item;
}
/// Someone asked to play [id] now: it moves first and waits for the
/// member who can put it up ([play]). False when unknown.
bool requestPlay(String id, DateTime now) {
final i = _index(id);
if (i < 0) return false;
if (i > 0) _items.insert(0, _items.removeAt(i));
play = id;
_playAt = now;
return true;
}
/// A swap happened: [id] (when it came from Up next) leaves the list,
/// and any pending play request is over.
void swapped(String? id) {
if (id != null) {
final i = _index(id);
if (i >= 0) _items.removeAt(i);
}
_clearPlay();
}
/// A play request nobody acted on ends after
/// [RelayConstants.queuePlayTimeoutSecs]. Returns whether it changed.
bool tick(DateTime now) {
final at = _playAt;
if (play == null || at == null) return false;
if (now.difference(at).inSeconds < RelayConstants.queuePlayTimeoutSecs) return false;
_clearPlay();
return true;
}
void _clearPlay() {
play = null;
_playAt = null;
}
/// Bytes of an item's picture by hash.
Uint8List? thumbFor(String hash) {
for (final e in _items) {
if (e.thumb?.hash == hash) return e.thumb!.bytes;
}
return null;
}
}
/// The `queue` messages applied to the engine's [SyncEngine.queue], with
/// their feed lines (`queue-add`, `queue-remove`, `queue-skip`). Each
/// returns whether the queue changed; [applyQueueAdd] an error text for the
/// sender instead (null when added).
extension EngineQueueOps on SyncEngine {
/// [item] is already cleaned by the server (see `cleanQueueAdd`).
String? applyQueueAdd(String token, QueueItem item,
{bool next = false, String? privateUrl, Uint8List? thumb}) {
final member = members[token];
if (member == null) return null;
final added = queue.add(item.copyWith(by: member.id, byName: member.name),
next: next, privateUrl: privateUrl, thumb: thumb);
if (added == null) return 'Up next is full (${RelayConstants.maxQueueItems} items)';
feed.add(FeedMessage(actor: member.name, action: 'queue-add', target: added.title, at: now));
return null;
}
bool applyQueueMove(String token, String id, int to) =>
members.containsKey(token) && queue.move(id, to);
/// [why] set: skipped because it cannot play (`queue-skip`, with the
/// reason), else removed by hand (`queue-remove`).
bool applyQueueRemove(String token, String id, {String why = ''}) {
final member = members[token];
if (member == null) return false;
final gone = queue.remove(id);
if (gone == null) return false;
feed.add(FeedMessage(
actor: member.name,
action: why.isEmpty ? 'queue-remove' : 'queue-skip',
target: gone.title,
reason: why,
at: now));
return true;
}
/// `play` ([id]) or `skip` (the first item, [id] empty).
bool applyQueuePlay(String token, String id) {
if (!members.containsKey(token)) return false;
final target = id.isEmpty ? (queue.isEmpty ? null : queue.items.first.id) : id;
if (target == null || queue.play == target) return false;
return queue.requestPlay(target, now);
}
}
+14 -2
View File
@@ -5,6 +5,7 @@ import 'package:crypto/crypto.dart';
import 'constants.dart';
import 'protocol.dart';
import 'queue.dart';
typedef SyncClock = DateTime Function();
@@ -59,6 +60,8 @@ class EngineMember {
/// Its player cannot open the current media (`report.error`): outside
/// the ready gate until a report clears it or the media changes.
String error = '';
/// Bytes per second the server sent it lately (set by the server).
int rate = 0;
DateTime lastSeen;
EngineMember({
required this.id,
@@ -101,6 +104,7 @@ class EngineMember {
host: host,
out: out,
error: error,
rate: rate,
);
}
@@ -154,6 +158,9 @@ class SyncEngine {
/// ride in `state.media.art`. Every swap clears it.
ArtImage? poster;
ArtImage? banner;
/// Up next ([EngineQueue]): in `state.queue`, edited by `queue`
/// messages, taken from by swaps that name an item.
final EngineQueue queue = EngineQueue();
int _nextId = 1;
// Who sent the seek that produced [_seekEventId], for the echo check.
String? _seekBy;
@@ -211,6 +218,7 @@ class SyncEngine {
/// Heartbeat: stale sweep, end of media.
void tick() {
sweepStale();
queue.tick(now);
evaluate();
}
@@ -272,7 +280,7 @@ class SyncEngine {
if (hash.isEmpty) return null;
if (poster?.hash == hash) return poster!.bytes;
if (banner?.hash == hash) return banner!.bytes;
return null;
return queue.thumbFor(hash);
}
/// `state.media.art`; null when there is none.
@@ -397,9 +405,11 @@ class SyncEngine {
}
/// Returns false (no state change) for media with non-finite numbers.
bool applySwap(String actorToken, HelloMedia next) {
/// [queueId]: the Up next item this swap puts up; it leaves the queue.
bool applySwap(String actorToken, HelloMedia next, {String? queueId}) {
if (!next.duration.isFinite || next.size < 0) return false;
final actor = members[actorToken];
queue.swapped(queueId);
mediaId++;
media = next;
swapBy = actorToken;
@@ -544,6 +554,8 @@ class SyncEngine {
seekBy: bySeek ? _seekBy : null,
seekSeq: bySeek ? _seekSeq : null,
relay: relay,
queue: queue.items,
queuePlay: queue.play,
);
}
}
+121
View File
@@ -0,0 +1,121 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
void main() {
late DateTime t;
late SyncEngine engine;
setUp(() {
t = DateTime.utc(2026, 1, 1);
engine = SyncEngine(clock: () => t);
engine.applyJoin('a', 'alice', '');
engine.applyJoin('b', 'bob', '');
});
QueueItem link(String title) => QueueItem(
media: HelloMedia(name: title, size: 0, duration: 0, url: 'https://example.com/$title'),
how: QueueHow.direct,
title: title);
List<String> titles() => [for (final q in engine.queue.items) q.title];
String idOf(String title) => engine.queue.items.firstWhere((q) => q.title == title).id;
test('add at the end or next, with who added it, a feed line and stable ids', () {
engine.applyQueueAdd('a', link('one'));
engine.applyQueueAdd('b', link('two'));
engine.applyQueueAdd('b', link('first'), next: true);
expect(titles(), ['first', 'one', 'two']);
final one = engine.queue.items[1];
expect((one.by, one.byName), ('m1', 'alice'));
final ids = {for (final q in engine.queue.items) q.id};
expect(ids.length, 3);
final feed = engine.feed.drain().where((f) => f.action == 'queue-add').toList();
expect([for (final f in feed) '${f.actor}:${f.target}'], ['alice:one', 'bob:two', 'bob:first']);
});
test('move and remove by id; unknown ids change nothing', () {
for (final s in ['a1', 'a2', 'a3']) {
engine.applyQueueAdd('a', link(s));
}
expect(engine.applyQueueMove('b', idOf('a3'), 0), isTrue);
expect(titles(), ['a3', 'a1', 'a2']);
expect(engine.applyQueueMove('b', idOf('a3'), 99), isTrue);
expect(titles(), ['a1', 'a2', 'a3']);
expect(engine.applyQueueMove('b', 'q99', 0), isFalse);
expect(engine.applyQueueRemove('b', idOf('a2')), isTrue);
expect(engine.applyQueueRemove('b', 'q99'), isFalse);
expect(titles(), ['a1', 'a3']);
final skip = engine.applyQueueRemove('a', idOf('a1'), why: 'bob left');
expect(skip, isTrue);
final lines = engine.feed.drain().where((f) => f.action.startsWith('queue-r') || f.action == 'queue-skip');
expect([for (final f in lines) '${f.action}:${f.target}:${f.reason}'],
['queue-remove:a2:', 'queue-skip:a1:bob left']);
});
test('a swap naming an item takes it out; any swap ends a play request', () {
engine.applyQueueAdd('a', link('one'));
engine.applyQueueAdd('a', link('two'));
expect(engine.applyQueuePlay('b', idOf('two')), isTrue);
expect(titles(), ['two', 'one']);
expect(engine.broadcast().queuePlay, idOf('two'));
engine.applySwap('a', const HelloMedia(name: 'manual', size: 0, duration: 0, url: 'https://x/y'));
expect(engine.queue.play, isNull);
expect(titles(), ['two', 'one'], reason: 'a manual swap leaves the queue alone');
engine.applySwap('a', const HelloMedia(name: 'two', size: 0, duration: 0, url: 'https://x/two'),
queueId: idOf('two'));
expect(titles(), ['one']);
});
test('skip asks for the first item; a play request times out', () {
expect(engine.applyQueuePlay('a', ''), isFalse, reason: 'nothing queued');
engine.applyQueueAdd('a', link('one'));
expect(engine.applyQueuePlay('a', ''), isTrue);
expect(engine.queue.play, idOf('one'));
t = t.add(const Duration(seconds: RelayConstants.queuePlayTimeoutSecs + 1));
engine.tick();
expect(engine.queue.play, isNull);
});
test('full at the cap', () {
for (var i = 0; i < RelayConstants.maxQueueItems; i++) {
expect(engine.applyQueueAdd('a', link('$i')), isNull);
}
expect(engine.applyQueueAdd('a', link('over')), contains('full'));
expect(engine.queue.length, RelayConstants.maxQueueItems);
});
test('thumbnails are served by hash while queued', () {
final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, 1, 2, 3]);
engine.applyQueueAdd('a', link('one'), thumb: png);
final hash = engine.queue.items.single.thumb;
expect(hash, isNotEmpty);
expect(engine.artFor(hash), png);
engine.applyQueueRemove('a', engine.queue.items.single.id);
expect(engine.artFor(hash), isNull);
});
test('state round trip keeps the queue and the play request', () {
engine.applyQueueAdd('a', link('one'));
engine.applyQueueAdd('b', QueueItem(
media: const HelloMedia(name: 'f', size: 3, duration: 0),
how: QueueHow.file,
title: 'f',
sub: 'S1E2',
duration: 1440,
key: 'abcdefgh12'));
engine.applyQueuePlay('a', idOf('f'));
final back = StateBroadcast.fromJson(
Map<String, Object?>.from(jsonDecode(jsonEncode(engine.broadcast().toJson())) as Map));
expect([for (final q in back.queue) q.title], ['f', 'one']);
final f = back.queue.first;
expect((f.how, f.sub, f.duration, f.key, f.byName), (QueueHow.file, 'S1E2', 1440.0, 'abcdefgh12', 'bob'));
expect(back.queuePlay, f.id);
final intent = QueueIntent.fromJson(Map<String, Object?>.from(
jsonDecode(jsonEncode(QueueIntent.add(link('x'), next: true).toJson())) as Map));
expect((intent.op, intent.next, intent.item!.title), ('add', true, 'x'));
expect(() => QueueIntent.fromJson(const {'t': 'queue', 'op': 'nope'}), throwsA(isA<ProtocolException>()));
});
}
+1
View File
@@ -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';
+13 -1
View File
@@ -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();
+93
View File
@@ -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();
}
+14 -1
View File
@@ -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();
+34
View File
@@ -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();
}
+103
View File
@@ -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;
}
}
+55 -5
View File
@@ -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}');
+1
View File
@@ -148,6 +148,7 @@ class SyncServer {
hub.engine.tick();
hub.sweepClosedSockets();
hub.dropSilentSockets();
hub.sampleRates();
hub.broadcastState();
hub.broadcastFeed();
});
+123
View File
@@ -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');
});
}
+118
View File
@@ -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();
});
}
+2 -2
View File
@@ -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);