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>
124 lines
4.5 KiB
Dart
124 lines
4.5 KiB
Dart
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');
|
|
});
|
|
}
|