Viewers who cannot reach the media get it through the server: a file on
any member's computer, a link passed on by a member ("Through me") or
fetched by the server ("Server fetches it", password-protected servers
only), and Jellyfin items for guests without an account, sourced by
whoever put them up with their own sign-in (or a pasted link's key),
which never leaves their machine. Fixes guests on a dedicated server
without a Jellyfin login being blocked.
The source dials /r/.../source (works behind NAT) and answers pulls with
an adaptive in-flight window (2 x rate x min RTT, 256 KiB..8 MiB). The
server keeps a memory-only buffer of 1 MiB slices sized like a viewer's
mpv buffer (pinned head/tail, 64 MiB back, 256 MiB ahead), fills it with
one sequential pull at a time (blocked viewers first, newest request
only), fetches nothing while nobody watches through it, and drops it on
the next swap or stop. Measured with mpv through shaped links
(tool/relay_e2e.dart): 400 Mbit/s seeks ~0.03 s; 40 Mbit/s no stalls,
3 viewers cost the source ~1x the bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
72 lines
3.1 KiB
Dart
72 lines
3.1 KiB
Dart
// Relay harness: a real SyncServer, a member that swaps a file in as
|
|
// relayed media, and that member's RelaySource dialling the server through
|
|
// [--via] (e.g. a link shaper). Prints `URL <viewer url>` for players, and
|
|
// `STATS ...` every 5 s. Usage:
|
|
// dart run tool/relay_e2e.dart --file x.mkv [--port 9100] [--via 9200]
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:args/args.dart';
|
|
import 'package:sync_relay_core/core.dart';
|
|
import 'package:sync_relay_server/server.dart';
|
|
|
|
Future<void> main(List<String> argv) async {
|
|
final a = (ArgParser()
|
|
..addOption('file', mandatory: true)
|
|
..addOption('port', defaultsTo: '9100')
|
|
..addOption('via')
|
|
..addOption('viewers', defaultsTo: '1'))
|
|
.parse(argv);
|
|
final file = File(a['file'] as String);
|
|
final port = int.parse(a['port'] as String);
|
|
final server = SyncServer(AppConfig(port: port, secret: '', name: 'e2e'));
|
|
await server.start();
|
|
|
|
Future<(WebSocket, WelcomeMessage)> join(String name) async {
|
|
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
|
|
final welcome = Completer<WelcomeMessage>();
|
|
ws.listen((f) {
|
|
final j = jsonDecode('$f') as Map;
|
|
if (j['t'] == 'welcome' && !welcome.isCompleted) {
|
|
welcome.complete(WelcomeMessage.fromJson(Map<String, Object?>.from(j)));
|
|
}
|
|
});
|
|
ws.add(jsonEncode(HelloMessage(
|
|
proto: RelayConstants.protoVersion,
|
|
secret: '',
|
|
user: HelloUser(name: name),
|
|
media: const HelloMedia(name: '', size: 0, duration: 0))
|
|
.toJson()));
|
|
// Stay alive like a real client (silent members get dropped).
|
|
Timer.periodic(const Duration(seconds: 1), (_) => ws.add(jsonEncode({'t': 'report', 'position': 0, 'buffered': 0, 'eventId': 0})));
|
|
return (ws, await welcome.future);
|
|
}
|
|
|
|
final (srcWs, srcW) = await join('source');
|
|
final name = file.uri.pathSegments.last;
|
|
srcWs.add(jsonEncode(SwapIntent(
|
|
media: HelloMedia(name: name, size: await file.length(), duration: 0, relay: const MediaRelay()))
|
|
.toJson()));
|
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
|
final via = a['via'] as String? ?? '$port';
|
|
var source = RelaySource(Uri.parse('ws://127.0.0.1:$via/r/default/${srcW.token}/source'), FileRelayBytes(file));
|
|
await source.start();
|
|
final urls = <String>[];
|
|
for (var i = 0; i < int.parse(a['viewers'] as String); i++) {
|
|
urls.add((await join('viewer$i')).$2.mediaUrl!);
|
|
}
|
|
stdout.writeln('URL ${urls.join(',')}');
|
|
Timer.periodic(const Duration(seconds: 5), (_) {
|
|
final b = server.hub.relay?.buffer;
|
|
stdout.writeln('STATS sourceSent=${source.bytesSent} toViewers=${b?.bytesToViewers} '
|
|
'cached=${b?.cachedBytes} window=${source.window.window}');
|
|
});
|
|
// SIGUSR1: drop the source; SIGUSR2: bring it back (outage tests).
|
|
ProcessSignal.sigusr1.watch().listen((_) => source.close());
|
|
ProcessSignal.sigusr2.watch().listen((_) async {
|
|
source = RelaySource(Uri.parse('ws://127.0.0.1:$via/r/default/${srcW.token}/source'), FileRelayBytes(file));
|
|
await source.start();
|
|
});
|
|
}
|