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

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