Relay: a member's media through the server's buffer (proto 8)
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>
This commit is contained in:
1 parent
9c5733719b
commit
e7f33b5dc3
27 files changed
+2438
-116
No files matched your search
@@ -4,6 +4,7 @@ export 'src/logging.dart';
|
||||
export 'src/clock_sync.dart';
|
||||
export 'src/config.dart';
|
||||
export 'src/protocol.dart';
|
||||
export 'src/relay.dart';
|
||||
export 'src/media_source.dart';
|
||||
export 'src/sync_engine.dart';
|
||||
export 'src/jellyfin/client.dart';
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
abstract class RelayConstants {
|
||||
static const int protoVersion = 7;
|
||||
static const int protoVersion = 8;
|
||||
|
||||
static const int chunkBytes = 256 * 1024;
|
||||
static const int creditWindowChunks = 8;
|
||||
static const int maxConcurrentOpen = 16;
|
||||
// Relay (`/r/...`): a member's bytes through the server's buffer.
|
||||
static const int relaySliceBytes = 1 << 20;
|
||||
static const int relayFrameBytes = 64 * 1024;
|
||||
/// Defaults for the buffer; they mirror a viewer's mpv buffer (150 s /
|
||||
/// 256 MiB ahead, 64 MiB back), so whatever a viewer may seek to soon is
|
||||
/// already on the server.
|
||||
static const int relayAheadBytes = 256 << 20;
|
||||
static const int relayBackBytes = 64 << 20;
|
||||
/// Kept regardless of position: containers keep their index at the start
|
||||
/// or the end, and players read both on open and on seeks.
|
||||
static const int relayPinBytes = 2 << 20;
|
||||
static const int relayMinWindowBytes = 256 * 1024;
|
||||
static const int relayMaxWindowBytes = 8 << 20;
|
||||
/// A viewer request waits this long for its bytes (source away) before
|
||||
/// the server gives up on it.
|
||||
static const int relayWaitSecs = 60;
|
||||
|
||||
static const int seekDebounceMs = 300;
|
||||
static const double driftSeekThresholdSecs = 5.0;
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
import 'constants.dart';
|
||||
import 'errors.dart';
|
||||
|
||||
enum MessageKind {
|
||||
hello,
|
||||
welcome,
|
||||
state,
|
||||
event,
|
||||
chat,
|
||||
buffered,
|
||||
kicked,
|
||||
kick,
|
||||
eof,
|
||||
err,
|
||||
open,
|
||||
ack,
|
||||
cancel,
|
||||
}
|
||||
|
||||
/// Reads an optional JSON number as a finite double. JSON like 1e400 decodes to
|
||||
/// Infinity, which would later break jsonEncode on every broadcast.
|
||||
double finiteDouble(Object? value, String field, {double fallback = 0.0}) {
|
||||
@@ -111,11 +95,23 @@ class HelloMedia {
|
||||
final String? url;
|
||||
/// Set for Jellyfin media; then there is no [url] (the server drops one).
|
||||
final JellyfinRef? jellyfin;
|
||||
/// Relayed media (bytes from the source member through the server).
|
||||
final MediaRelay? relay;
|
||||
const HelloMedia(
|
||||
{required this.name, required this.size, required this.duration, this.url, this.jellyfin});
|
||||
{required this.name,
|
||||
required this.size,
|
||||
required this.duration,
|
||||
this.url,
|
||||
this.jellyfin,
|
||||
this.relay});
|
||||
|
||||
HelloMedia copyWith({double? duration}) => HelloMedia(
|
||||
name: name, size: size, duration: duration ?? this.duration, url: url, jellyfin: jellyfin);
|
||||
HelloMedia copyWith({double? duration, int? size, MediaRelay? relay}) => HelloMedia(
|
||||
name: name,
|
||||
size: size ?? this.size,
|
||||
duration: duration ?? this.duration,
|
||||
url: url,
|
||||
jellyfin: jellyfin,
|
||||
relay: relay ?? this.relay);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'name': name,
|
||||
@@ -123,20 +119,48 @@ class HelloMedia {
|
||||
'duration': duration,
|
||||
if (url != null) 'url': url,
|
||||
if (jellyfin != null) 'jellyfin': jellyfin!.toJson(),
|
||||
if (relay != null) 'relay': relay!.toJson(),
|
||||
};
|
||||
|
||||
factory HelloMedia.fromJson(Map<String, Object?> json) {
|
||||
final jf = json['jellyfin'];
|
||||
final relay = json['relay'];
|
||||
return HelloMedia(
|
||||
name: (json['name'] as String?) ?? '',
|
||||
size: finiteInt(json['size'], 'size', fallback: 0),
|
||||
duration: finiteDouble(json['duration'], 'duration'),
|
||||
url: json['url'] as String?,
|
||||
jellyfin: jf is Map ? JellyfinRef.fromJson(Map<String, Object?>.from(jf)) : null,
|
||||
relay: relay is Map ? MediaRelay.fromJson(Map<String, Object?>.from(relay)) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Relayed media: the bytes go through the server's buffer (`/r/...`).
|
||||
/// Either from the [source] member's machine (it dials the relay channel),
|
||||
/// or, with [fetch], the server reads the swap's link itself. A swap sends
|
||||
/// `{}` or `{"fetch":true}`; the server fills in [source] (the swapper's
|
||||
/// member id, empty with [fetch]) and whether bytes can flow ([live]).
|
||||
class MediaRelay {
|
||||
final String source;
|
||||
final bool fetch;
|
||||
final bool live;
|
||||
const MediaRelay({this.source = '', this.fetch = false, this.live = false});
|
||||
|
||||
MediaRelay copyWith({bool? live}) => MediaRelay(source: source, fetch: fetch, live: live ?? this.live);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
if (source.isNotEmpty) 'source': source,
|
||||
if (fetch) 'fetch': true,
|
||||
if (live) 'live': true,
|
||||
};
|
||||
|
||||
factory MediaRelay.fromJson(Map<String, Object?> json) => MediaRelay(
|
||||
source: json['source'] is String ? json['source'] as String : '',
|
||||
fetch: json['fetch'] == true,
|
||||
live: json['live'] == true);
|
||||
}
|
||||
|
||||
class HelloMessage {
|
||||
final int proto;
|
||||
final String secret;
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'constants.dart';
|
||||
import 'logging.dart';
|
||||
|
||||
// The relay channel: a member's machine (the source) serves the current
|
||||
// media's bytes to the server, which buffers them for every viewer (`/r/...`).
|
||||
// The source dials out (works behind NAT/CGNAT) on its own WebSocket, so
|
||||
// video bytes never queue behind `/sync` messages.
|
||||
//
|
||||
// source -> server {"t":"info","size":N} first frame
|
||||
// binary [u32 BE pull id][bytes] in order per pull
|
||||
// {"t":"end","id":1} pull complete
|
||||
// {"t":"fail","id":1,"reason":"..."} cannot read
|
||||
// server -> source {"t":"pull","id":1,"start":0,"end":N} bytes start..end
|
||||
// {"t":"ack","id":1,"n":65536} n bytes arrived
|
||||
// {"t":"cancel","id":1} stop this pull
|
||||
//
|
||||
// Flow control is on the source: one adaptive in-flight budget per
|
||||
// connection (see [RelayWindow]); a cancelled pull's unacked bytes are
|
||||
// written off.
|
||||
|
||||
final _log = createLogger('relay');
|
||||
|
||||
/// Binary relay frame: 4-byte big-endian pull id, then payload.
|
||||
Uint8List encodeRelayFrame(int id, List<int> payload) {
|
||||
final out = Uint8List(4 + payload.length);
|
||||
ByteData.sublistView(out).setUint32(0, id);
|
||||
out.setRange(4, out.length, payload);
|
||||
return out;
|
||||
}
|
||||
|
||||
(int id, Uint8List payload) decodeRelayFrame(Uint8List frame) {
|
||||
if (frame.length < 4) throw const FormatException('short relay frame');
|
||||
return (ByteData.sublistView(frame, 0, 4).getUint32(0), Uint8List.sublistView(frame, 4));
|
||||
}
|
||||
|
||||
/// Where a source reads the media from.
|
||||
abstract interface class RelayBytes {
|
||||
/// Total length in bytes.
|
||||
Future<int> size();
|
||||
|
||||
/// Bytes [start]..[end] (inclusive), in order. Cancelling the
|
||||
/// subscription stops reading.
|
||||
Stream<List<int>> read(int start, int end);
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
|
||||
class FileRelayBytes implements RelayBytes {
|
||||
final File file;
|
||||
FileRelayBytes(this.file);
|
||||
|
||||
@override
|
||||
Future<int> size() => file.length();
|
||||
|
||||
@override
|
||||
Stream<List<int>> read(int start, int end) => file.openRead(start, end + 1);
|
||||
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
}
|
||||
|
||||
/// An HTTP(S) resource with Range support, e.g. a link or a Jellyfin
|
||||
/// stream; [headers] (auth) never leave this machine.
|
||||
class HttpRelayBytes implements RelayBytes {
|
||||
final Uri url;
|
||||
final Map<String, String> headers;
|
||||
final HttpClient _client;
|
||||
int? _size;
|
||||
HttpRelayBytes(this.url, {this.headers = const {}, HttpClient? client})
|
||||
: _client = client ?? HttpClient();
|
||||
|
||||
Future<HttpClientResponse> _get(String range) async {
|
||||
final req = await _client.getUrl(url).timeout(const Duration(seconds: 20));
|
||||
headers.forEach(req.headers.set);
|
||||
req.headers
|
||||
..set(HttpHeaders.rangeHeader, range)
|
||||
..set(HttpHeaders.acceptEncodingHeader, 'identity');
|
||||
final res = await req.close().timeout(const Duration(seconds: 20));
|
||||
if (res.statusCode != 200 && res.statusCode != 206) {
|
||||
await res.drain<void>().catchError((_) {});
|
||||
throw HttpException('upstream HTTP ${res.statusCode}', uri: url);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> size() async {
|
||||
if (_size != null) return _size!;
|
||||
final res = await _get('bytes=0-0');
|
||||
await res.drain<void>().catchError((_) {});
|
||||
// A web page (YouTube and co.) only a player can resolve, not a file.
|
||||
final type = res.headers.contentType?.mimeType ?? '';
|
||||
if (type.startsWith('text/')) throw HttpException('not a video file ($type)', uri: url);
|
||||
// `bytes 0-0/12345`; a server ignoring Range answers 200 with the length.
|
||||
final total = res.headers.value(HttpHeaders.contentRangeHeader)?.split('/').last;
|
||||
final n = int.tryParse(total ?? '') ?? (res.statusCode == 200 ? res.contentLength : -1);
|
||||
if (n <= 0) throw HttpException('upstream size unknown', uri: url);
|
||||
return _size = n;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<int>> read(int start, int end) async* {
|
||||
final res = await _get('bytes=$start-$end');
|
||||
if (res.statusCode == 200 && start > 0) {
|
||||
throw HttpException('upstream ignores Range', uri: url);
|
||||
}
|
||||
yield* res;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async => _client.close(force: true);
|
||||
}
|
||||
|
||||
/// The per-connection in-flight budget: 2 x (delivery rate x min RTT),
|
||||
/// clamped to [RelayConstants.relayMinWindowBytes]..[RelayConstants.relayMaxWindowBytes].
|
||||
/// The rate is the max of the last 2 s of samples, so a pause or a burst of
|
||||
/// cancels does not collapse it (BBR's bandwidth max-filter); spike results
|
||||
/// in docs/ARCHITECTURE.md ("Relay").
|
||||
class RelayWindow {
|
||||
var window = RelayConstants.relayMinWindowBytes;
|
||||
var inflight = 0;
|
||||
var minRttUs = 1 << 40;
|
||||
var _sent = 0, _acked = 0, _writtenOff = 0;
|
||||
final _sw = Stopwatch()..start();
|
||||
final _sendTimes = Queue<(int, int)>(); // (cumulative sent, time us)
|
||||
final _acks = Queue<(int, int)>(); // (time us, cumulative acked)
|
||||
final _rates = Queue<(int, double)>(); // (time us, bytes/s)
|
||||
Completer<void>? _room;
|
||||
|
||||
bool get full => inflight >= window;
|
||||
|
||||
void sent(int n) {
|
||||
_sent += n;
|
||||
inflight += n;
|
||||
_sendTimes.add((_sent, _sw.elapsedMicroseconds));
|
||||
}
|
||||
|
||||
void acked(int n) {
|
||||
inflight -= n;
|
||||
_acked += n;
|
||||
final now = _sw.elapsedMicroseconds;
|
||||
while (_sendTimes.isNotEmpty && _sendTimes.first.$1 <= _acked + _writtenOff) {
|
||||
minRttUs = min(minRttUs, now - _sendTimes.removeFirst().$2);
|
||||
}
|
||||
_acks.add((now, _acked));
|
||||
while (_acks.length > 2 && now - _acks.first.$1 > max(2 * minRttUs, 200000)) {
|
||||
_acks.removeFirst();
|
||||
}
|
||||
if (_acks.length >= 2 && minRttUs < (1 << 40)) {
|
||||
final dt = (now - _acks.first.$1) / 1e6;
|
||||
if (dt > 0.02) {
|
||||
_rates.add((now, (_acked - _acks.first.$2) / dt));
|
||||
while (now - _rates.first.$1 > 2000000) {
|
||||
_rates.removeFirst();
|
||||
}
|
||||
final rate = _rates.map((r) => r.$2).reduce(max);
|
||||
window = (2 * rate * minRttUs / 1e6)
|
||||
.clamp(RelayConstants.relayMinWindowBytes, RelayConstants.relayMaxWindowBytes)
|
||||
.toInt();
|
||||
}
|
||||
}
|
||||
_wake();
|
||||
}
|
||||
|
||||
/// Bytes of a cancelled pull that will never be acked.
|
||||
void writeOff(int n) {
|
||||
inflight -= n;
|
||||
_writtenOff += n;
|
||||
_wake();
|
||||
}
|
||||
|
||||
void _wake() {
|
||||
final r = _room;
|
||||
_room = null;
|
||||
r?.complete();
|
||||
}
|
||||
|
||||
Future<void> room() async {
|
||||
while (full) {
|
||||
await (_room ??= Completer<void>()).future;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Pull {
|
||||
final int id;
|
||||
var sent = 0, acked = 0;
|
||||
var cancelled = false, finished = false;
|
||||
StreamIterator<List<int>>? it;
|
||||
_Pull(this.id);
|
||||
bool get settled => cancelled || (finished && acked >= sent);
|
||||
}
|
||||
|
||||
/// Close code the server uses for "you are not (or no longer) the source of
|
||||
/// the current media": the source stops instead of reconnecting.
|
||||
const relayCloseNotSource = 4001;
|
||||
|
||||
/// Serves [bytes] on the relay channel at [url] (`ws://host/r/<sid>/<token>/source`)
|
||||
/// until [close]. Reconnects with backoff when the channel drops; stops for
|
||||
/// good when the server closes it with [relayCloseNotSource].
|
||||
class RelaySource {
|
||||
final Uri url;
|
||||
final RelayBytes bytes;
|
||||
final Duration Function(int attempt) backoff;
|
||||
RelaySource(this.url, this.bytes, {Duration Function(int attempt)? backoff})
|
||||
: backoff = backoff ?? ((a) => Duration(milliseconds: min(8000, 500 * (1 << min(a, 4)))));
|
||||
|
||||
final window = RelayWindow();
|
||||
final _pulls = <int, _Pull>{};
|
||||
WebSocket? _ws;
|
||||
var _closed = false;
|
||||
final _done = Completer<void>();
|
||||
|
||||
/// Completes when the source stopped for good (closed or refused).
|
||||
Future<void> get done => _done.future;
|
||||
|
||||
/// Bytes sent to the server, for stats.
|
||||
int bytesSent = 0;
|
||||
|
||||
Future<void> start() async {
|
||||
final size = await bytes.size();
|
||||
unawaited(_loop(size));
|
||||
}
|
||||
|
||||
Future<void> _loop(int size) async {
|
||||
var attempt = 0;
|
||||
while (!_closed) {
|
||||
try {
|
||||
final ws = await WebSocket.connect(url.toString()).timeout(const Duration(seconds: 15));
|
||||
if (_closed) {
|
||||
await ws.close();
|
||||
break;
|
||||
}
|
||||
_ws = ws;
|
||||
attempt = 0;
|
||||
ws.add(jsonEncode({'t': 'info', 'size': size}));
|
||||
await for (final m in ws) {
|
||||
if (m is String) _onText(ws, m);
|
||||
}
|
||||
if (ws.closeCode == relayCloseNotSource) {
|
||||
_log.info('relay channel: not the source any more, stopping');
|
||||
_closed = true;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.fine('relay channel: $e');
|
||||
}
|
||||
_ws = null;
|
||||
_abandonAll();
|
||||
if (_closed) break;
|
||||
await Future<void>.delayed(backoff(attempt++));
|
||||
}
|
||||
_abandonAll();
|
||||
await bytes.close();
|
||||
if (!_done.isCompleted) _done.complete();
|
||||
}
|
||||
|
||||
void _onText(WebSocket ws, String text) {
|
||||
final Map j;
|
||||
try {
|
||||
j = jsonDecode(text) as Map;
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
final id = j['id'];
|
||||
if (id is! int) return;
|
||||
switch (j['t']) {
|
||||
case 'pull':
|
||||
final start = j['start'], end = j['end'];
|
||||
if (start is! int || end is! int || start < 0 || end < start) return;
|
||||
final p = _Pull(id);
|
||||
_pulls[id] = p;
|
||||
unawaited(_serve(ws, p, start, end));
|
||||
case 'ack':
|
||||
final p = _pulls[id];
|
||||
final n = j['n'];
|
||||
if (p == null || p.cancelled || n is! int) return;
|
||||
p.acked += n;
|
||||
window.acked(n);
|
||||
if (p.settled) _pulls.remove(id);
|
||||
case 'cancel':
|
||||
final p = _pulls.remove(id);
|
||||
if (p == null) return;
|
||||
_cancel(p);
|
||||
}
|
||||
}
|
||||
|
||||
void _cancel(_Pull p) {
|
||||
if (p.cancelled) return;
|
||||
p.cancelled = true;
|
||||
window.writeOff(p.sent - p.acked);
|
||||
p.acked = p.sent;
|
||||
unawaited(p.it?.cancel());
|
||||
}
|
||||
|
||||
void _abandonAll() {
|
||||
for (final p in _pulls.values.toList()) {
|
||||
_cancel(p);
|
||||
}
|
||||
_pulls.clear();
|
||||
}
|
||||
|
||||
Future<void> _serve(WebSocket ws, _Pull p, int start, int end) async {
|
||||
final it = p.it = StreamIterator(bytes.read(start, end));
|
||||
final pending = BytesBuilder(copy: false);
|
||||
const frame = RelayConstants.relayFrameBytes;
|
||||
try {
|
||||
var more = true;
|
||||
while (!p.cancelled) {
|
||||
while (more && pending.length < frame) {
|
||||
more = await it.moveNext();
|
||||
if (more) pending.add(it.current);
|
||||
}
|
||||
if (pending.isEmpty) break;
|
||||
await window.room();
|
||||
// Check and send in one synchronous step: a cancel that lands
|
||||
// during an await must not be followed by a sent frame.
|
||||
if (p.cancelled) break;
|
||||
final all = pending.takeBytes();
|
||||
final n = min(frame, all.length);
|
||||
ws.add(encodeRelayFrame(p.id, Uint8List.sublistView(all, 0, n)));
|
||||
if (n < all.length) pending.add(Uint8List.sublistView(all, n));
|
||||
p.sent += n;
|
||||
bytesSent += n;
|
||||
window.sent(n);
|
||||
}
|
||||
if (!p.cancelled) ws.add(jsonEncode({'t': 'end', 'id': p.id}));
|
||||
} catch (e) {
|
||||
if (!p.cancelled) {
|
||||
_log.warning('relay read ${p.id} failed: $e');
|
||||
try {
|
||||
ws.add(jsonEncode({'t': 'fail', 'id': p.id, 'reason': '$e'}));
|
||||
} catch (_) {}
|
||||
}
|
||||
} finally {
|
||||
p.finished = true;
|
||||
if (p.settled) _pulls.remove(p.id);
|
||||
await it.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops serving; [done] completes once the loop has wound down (a
|
||||
/// pending reconnect delay runs out first).
|
||||
Future<void> close() async {
|
||||
_closed = true;
|
||||
await _ws?.close();
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,5 @@ export 'src/server_hub.dart';
|
||||
export 'src/session.dart';
|
||||
export 'src/sync_server.dart';
|
||||
export 'src/jellyfin_relay.dart';
|
||||
export 'src/relay_buffer.dart';
|
||||
export 'src/relay_routes.dart';
|
||||
@@ -0,0 +1,408 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
final _log = createLogger('relay');
|
||||
|
||||
/// What feeds a [RelayBuffer]: a member's relay channel, or the server
|
||||
/// itself fetching a link ([LocalRelayFeed]).
|
||||
abstract class RelayFeed {
|
||||
/// Ask for bytes [start]..[end]; they arrive via [RelayBuffer.feedBytes]
|
||||
/// in order, then [RelayBuffer.feedEnd].
|
||||
void pull(int id, int start, int end);
|
||||
void cancel(int id);
|
||||
|
||||
/// Bytes of [id] reached the buffer (the remote feed acks them).
|
||||
void arrived(int id, int n) {}
|
||||
}
|
||||
|
||||
/// Fetches from [bytes] in this process (the "server fetches it" mode).
|
||||
class LocalRelayFeed extends RelayFeed {
|
||||
final RelayBytes bytes;
|
||||
final RelayBuffer buffer;
|
||||
final _subs = <int, StreamSubscription<List<int>>>{};
|
||||
LocalRelayFeed(this.bytes, this.buffer);
|
||||
|
||||
@override
|
||||
void pull(int id, int start, int end) {
|
||||
_subs[id] = bytes.read(start, end).listen(
|
||||
(c) => buffer.feedBytes(id, c is Uint8List ? c : Uint8List.fromList(c)),
|
||||
onError: (Object e) {
|
||||
_subs.remove(id)?.cancel();
|
||||
buffer.feedFail(id, '$e');
|
||||
},
|
||||
onDone: () {
|
||||
_subs.remove(id);
|
||||
buffer.feedEnd(id);
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void cancel(int id) => _subs.remove(id)?.cancel();
|
||||
|
||||
Future<void> close() async {
|
||||
for (final s in _subs.values) {
|
||||
await s.cancel();
|
||||
}
|
||||
_subs.clear();
|
||||
await bytes.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// A relay channel (`/r/.../source`) as a feed.
|
||||
class ChannelRelayFeed extends RelayFeed {
|
||||
final void Function(String text) send;
|
||||
ChannelRelayFeed(this.send);
|
||||
|
||||
@override
|
||||
void pull(int id, int start, int end) =>
|
||||
send(jsonEncode({'t': 'pull', 'id': id, 'start': start, 'end': end}));
|
||||
|
||||
@override
|
||||
void cancel(int id) => send(jsonEncode({'t': 'cancel', 'id': id}));
|
||||
|
||||
@override
|
||||
void arrived(int id, int n) => send(jsonEncode({'t': 'ack', 'id': id, 'n': n}));
|
||||
}
|
||||
|
||||
class _Pull {
|
||||
final int id, start, end;
|
||||
int pos;
|
||||
final buf = BytesBuilder(copy: false);
|
||||
_Pull(this.id, this.start, this.end) : pos = start;
|
||||
}
|
||||
|
||||
class _Viewer {
|
||||
int pos = 0;
|
||||
int open = 0; // requests in flight
|
||||
int newest = 0; // sequence of its newest request
|
||||
DateTime lastActive = DateTime.now();
|
||||
}
|
||||
|
||||
/// The server side of the relay for one media: an in-memory buffer of
|
||||
/// [RelayConstants.relaySliceBytes] slices filled from one [RelayFeed] with
|
||||
/// one sequential pull at a time, served to any number of viewers.
|
||||
///
|
||||
/// - Viewers only ever talk HTTP to the server; the feed never sees their
|
||||
/// request pattern (mpv leaves superseded requests open but unread, and
|
||||
/// jumps to the file's end for the index).
|
||||
/// - Kept: the file's first and last [pin] bytes, plus [back] behind the
|
||||
/// slowest and [ahead] past the furthest viewer. Evicted by distance from
|
||||
/// the viewers, never LRU.
|
||||
/// - Filled only while someone watches: a viewer blocked on a slice comes
|
||||
/// first (oldest wait first); then read-ahead.
|
||||
/// - Only a viewer's newest request moves its position or asks the feed
|
||||
/// for anything; older ones still get bytes once they are buffered.
|
||||
/// Everything lives in memory: dropping the buffer is the cleanup.
|
||||
class RelayBuffer {
|
||||
final int ahead, back, pin;
|
||||
final Duration waitLimit;
|
||||
RelayBuffer({
|
||||
this.ahead = RelayConstants.relayAheadBytes,
|
||||
this.back = RelayConstants.relayBackBytes,
|
||||
this.pin = RelayConstants.relayPinBytes,
|
||||
this.waitLimit = const Duration(seconds: RelayConstants.relayWaitSecs),
|
||||
});
|
||||
|
||||
static const slice = RelayConstants.relaySliceBytes;
|
||||
|
||||
RelayFeed? _feed;
|
||||
int size = 0;
|
||||
final _slices = <int, Uint8List>{};
|
||||
final _waiters = <int, List<Completer<void>>>{};
|
||||
final _blocked = <int, int>{}; // slice -> wait start (us)
|
||||
final _viewers = <String, _Viewer>{};
|
||||
_Pull? _pull;
|
||||
var _nextId = 1, _reqSeq = 0;
|
||||
var _disposed = false;
|
||||
Completer<void> _kick = Completer();
|
||||
final Completer<void> _ready = Completer();
|
||||
|
||||
/// Totals, for logs and tests.
|
||||
int bytesFromFeed = 0, bytesToViewers = 0;
|
||||
|
||||
bool get live => _feed != null && size > 0;
|
||||
int get cachedBytes => _slices.values.fold(0, (a, s) => a + s.length);
|
||||
int get _nSlices => (size + slice - 1) ~/ slice;
|
||||
int _sliceEnd(int s) => min((s + 1) * slice, size) - 1;
|
||||
|
||||
/// Attaches a (re)connected feed for a file of [size] bytes. A different
|
||||
/// size means different content: the buffer starts over.
|
||||
void attach(RelayFeed feed, int size) {
|
||||
if (_disposed) return;
|
||||
if (this.size != 0 && this.size != size) {
|
||||
_log.info('relay source changed size ${this.size} -> $size, dropping buffer');
|
||||
_slices.clear();
|
||||
}
|
||||
_feed = feed;
|
||||
this.size = size;
|
||||
if (!_ready.isCompleted) _ready.complete();
|
||||
if (_fillerRunning) {
|
||||
_wake();
|
||||
} else {
|
||||
unawaited(_filler());
|
||||
}
|
||||
}
|
||||
|
||||
/// The feed went away; buffered slices keep serving.
|
||||
void detach(RelayFeed feed) {
|
||||
if (!identical(_feed, feed)) return;
|
||||
_feed = null;
|
||||
_pull = null;
|
||||
_wake();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_feed = null;
|
||||
_pull = null;
|
||||
_slices.clear();
|
||||
for (final list in _waiters.values) {
|
||||
for (final c in list) {
|
||||
if (!c.isCompleted) c.complete();
|
||||
}
|
||||
}
|
||||
_waiters.clear();
|
||||
if (!_ready.isCompleted) _ready.complete();
|
||||
_wake();
|
||||
}
|
||||
|
||||
void _wake() {
|
||||
if (!_kick.isCompleted) _kick.complete();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ feed input
|
||||
|
||||
void feedBytes(int id, Uint8List data) {
|
||||
final pl = _pull;
|
||||
if (pl == null || pl.id != id || _disposed) return;
|
||||
_feed?.arrived(id, data.length);
|
||||
bytesFromFeed += data.length;
|
||||
var off = 0;
|
||||
while (off < data.length) {
|
||||
final s = pl.pos ~/ slice;
|
||||
final take = min(_sliceEnd(s) + 1 - pl.pos, data.length - off);
|
||||
if (take <= 0) {
|
||||
// Past the end the feed claimed: protocol violation.
|
||||
_endPull(pl);
|
||||
return;
|
||||
}
|
||||
pl.buf.add(Uint8List.sublistView(data, off, off + take));
|
||||
off += take;
|
||||
pl.pos += take;
|
||||
if (pl.pos == _sliceEnd(s) + 1) {
|
||||
_slices[s] = pl.buf.takeBytes();
|
||||
for (final c in _waiters.remove(s) ?? const <Completer<void>>[]) {
|
||||
c.complete();
|
||||
}
|
||||
if (_shouldStop(pl, s)) {
|
||||
_endPull(pl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void feedEnd(int id) {
|
||||
final pl = _pull;
|
||||
if (pl != null && pl.id == id) {
|
||||
_pull = null;
|
||||
_wake();
|
||||
}
|
||||
}
|
||||
|
||||
void feedFail(int id, String reason) {
|
||||
_log.warning('relay pull $id failed: $reason');
|
||||
feedEnd(id);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- scheduling
|
||||
|
||||
bool _shouldStop(_Pull pl, int done) {
|
||||
final next = done + 1;
|
||||
if (next >= _nSlices || next * slice > pl.end) return false; // ends naturally
|
||||
// Someone is blocked elsewhere: hand the feed over at this boundary.
|
||||
if (_blocked.keys.any((w) => w != next && w != next + 1 && !_slices.containsKey(w))) {
|
||||
return true;
|
||||
}
|
||||
if (_slices.containsKey(next)) return true;
|
||||
return !_wanted(next);
|
||||
}
|
||||
|
||||
void _endPull(_Pull pl) {
|
||||
if (!identical(_pull, pl)) return;
|
||||
_pull = null;
|
||||
if (pl.pos <= pl.end) _feed?.cancel(pl.id);
|
||||
_wake();
|
||||
}
|
||||
|
||||
Iterable<_Viewer> get _active {
|
||||
final now = DateTime.now();
|
||||
return _viewers.values
|
||||
.where((v) => v.open > 0 || now.difference(v.lastActive) < const Duration(seconds: 60));
|
||||
}
|
||||
|
||||
bool _pinned(int s) => s * slice < pin || s * slice >= size - pin;
|
||||
|
||||
bool _wanted(int s) {
|
||||
if (_pinned(s)) return true;
|
||||
final pos = _active.map((v) => v.pos).toList();
|
||||
if (pos.isEmpty) return false;
|
||||
final off = s * slice;
|
||||
return off >= pos.reduce(min) - back && off <= pos.reduce(max) + ahead;
|
||||
}
|
||||
|
||||
int _distance(int s) {
|
||||
if (_pinned(s)) return -1;
|
||||
final off = s * slice;
|
||||
var d = 1 << 62;
|
||||
for (final v in _active) {
|
||||
// Behind a viewer costs more than ahead (it was already watched).
|
||||
d = min(d, off >= v.pos ? off - v.pos : (v.pos - off) * 4);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
void _evict() {
|
||||
final limit = ahead + back + 2 * pin;
|
||||
var total = _slices.length * slice;
|
||||
if (total <= limit) return;
|
||||
final order = _slices.keys.toList()..sort((a, b) => _distance(b).compareTo(_distance(a)));
|
||||
for (final s in order) {
|
||||
if (total <= limit || _distance(s) < 0) break;
|
||||
if (_waiters.containsKey(s)) continue;
|
||||
_slices.remove(s);
|
||||
total -= slice;
|
||||
}
|
||||
}
|
||||
|
||||
int? _nextMissing() {
|
||||
final blocked = _blocked.entries.where((e) => !_slices.containsKey(e.key)).toList()
|
||||
..sort((a, b) => a.value.compareTo(b.value));
|
||||
if (blocked.isNotEmpty) return blocked.first.key;
|
||||
final active = _active.toList();
|
||||
if (active.isEmpty) return null; // nobody watches: fetch nothing
|
||||
for (var s = 0; s * slice < pin && s < _nSlices; s++) {
|
||||
if (!_slices.containsKey(s)) return s;
|
||||
}
|
||||
for (var s = max(0, (size - pin) ~/ slice); s < _nSlices; s++) {
|
||||
if (!_slices.containsKey(s)) return s;
|
||||
}
|
||||
final starts = active.map((v) => v.pos).toList()..sort();
|
||||
for (final p in starts) {
|
||||
for (var s = p ~/ slice; s * slice <= p + ahead && s < _nSlices; s++) {
|
||||
if (!_slices.containsKey(s)) return s;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var _fillerRunning = false;
|
||||
|
||||
Future<void> _filler() async {
|
||||
_fillerRunning = true;
|
||||
try {
|
||||
while (!_disposed) {
|
||||
final feed = _feed;
|
||||
if (feed == null || _pull != null) {
|
||||
await _sleep();
|
||||
continue;
|
||||
}
|
||||
_evict();
|
||||
final s = _nextMissing();
|
||||
if (s == null) {
|
||||
await _sleep();
|
||||
continue;
|
||||
}
|
||||
var e = s;
|
||||
while (e + 1 < _nSlices && !_slices.containsKey(e + 1) && _wanted(e + 1)) {
|
||||
e++;
|
||||
}
|
||||
final pl = _Pull(_nextId++, s * slice, _sliceEnd(e));
|
||||
_pull = pl;
|
||||
feed.pull(pl.id, pl.start, pl.end);
|
||||
}
|
||||
} finally {
|
||||
_fillerRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sleep() async {
|
||||
_kick = Completer();
|
||||
// Also wake periodically: viewers age out of [_active].
|
||||
await _kick.future.timeout(const Duration(seconds: 5), onTimeout: () {});
|
||||
}
|
||||
|
||||
Future<bool> _waitSlice(int s, bool drives) async {
|
||||
final deadline = DateTime.now().add(waitLimit);
|
||||
while (!_slices.containsKey(s)) {
|
||||
if (_disposed || DateTime.now().isAfter(deadline)) return false;
|
||||
if (drives) {
|
||||
_blocked.putIfAbsent(s, () => DateTime.now().microsecondsSinceEpoch);
|
||||
// A pull nobody waits on gives way at once; one serving another
|
||||
// blocked viewer hands over at its next slice boundary.
|
||||
final pl = _pull;
|
||||
final at = pl == null ? -1 : pl.pos ~/ slice;
|
||||
if (pl != null && at != s && !_blocked.containsKey(at)) _endPull(pl);
|
||||
_wake();
|
||||
}
|
||||
final c = Completer<void>();
|
||||
_waiters.putIfAbsent(s, () => []).add(c);
|
||||
await c.future.timeout(const Duration(seconds: 2), onTimeout: () {});
|
||||
}
|
||||
_blocked.remove(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- viewers
|
||||
|
||||
/// Waits for the size (the feed's first connect), up to [waitLimit].
|
||||
Future<bool> ready() async {
|
||||
if (size > 0) return true;
|
||||
await _ready.future.timeout(waitLimit, onTimeout: () {});
|
||||
return size > 0 && !_disposed;
|
||||
}
|
||||
|
||||
/// Streams [start]..[end] to [viewer]. Cancelling the subscription (the
|
||||
/// viewer went away) ends it; a slice that never arrives ends it with an
|
||||
/// error after [waitLimit].
|
||||
Stream<Uint8List> read(String viewer, int start, int end, {void Function(int n)? onSent}) async* {
|
||||
final v = _viewers.putIfAbsent(viewer, _Viewer.new);
|
||||
final tail = start >= size - pin;
|
||||
final me = ++_reqSeq;
|
||||
if (!tail) {
|
||||
v.newest = me;
|
||||
v.pos = start;
|
||||
}
|
||||
v.open++;
|
||||
v.lastActive = DateTime.now();
|
||||
_wake();
|
||||
try {
|
||||
var off = start;
|
||||
while (off <= end) {
|
||||
final s = off ~/ slice;
|
||||
if (!await _waitSlice(s, tail || v.newest == me)) {
|
||||
throw StateError('relay: bytes at $off did not arrive');
|
||||
}
|
||||
final data = _slices[s];
|
||||
if (data == null) continue; // evicted between wake and read
|
||||
final from = off - s * slice, to = min(data.length, end - s * slice + 1);
|
||||
yield Uint8List.sublistView(data, from, to);
|
||||
bytesToViewers += to - from;
|
||||
onSent?.call(to - from);
|
||||
off += to - from;
|
||||
v.lastActive = DateTime.now();
|
||||
if (!tail && v.newest == me) v.pos = off;
|
||||
_wake();
|
||||
}
|
||||
} finally {
|
||||
v.open--;
|
||||
v.lastActive = DateTime.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:relic/relic.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:web_socket/web_socket.dart';
|
||||
|
||||
import 'media_endpoint.dart';
|
||||
import 'relay_buffer.dart';
|
||||
import 'session.dart';
|
||||
|
||||
final _log = createLogger('relay');
|
||||
|
||||
/// The relay of the current media: its buffer and where the bytes come
|
||||
/// from. One per relayed swap; [dispose] on the next swap or server stop
|
||||
/// (the buffer is memory only, so that is the whole cleanup).
|
||||
class ActiveRelay {
|
||||
final int mediaId;
|
||||
final String name;
|
||||
/// Token of the member whose machine serves the bytes; null when the
|
||||
/// server fetches the link itself ([local]).
|
||||
final String? sourceToken;
|
||||
final RelayBuffer buffer;
|
||||
final LocalRelayFeed? local;
|
||||
final void Function() onLiveChanged;
|
||||
RelicWebSocket? _channel;
|
||||
ChannelRelayFeed? _channelFeed;
|
||||
|
||||
ActiveRelay._(this.mediaId, this.name, this.sourceToken, this.buffer, this.local, this.onLiveChanged);
|
||||
|
||||
/// Bytes from member [sourceToken] over its relay channel.
|
||||
factory ActiveRelay.fromMember(int mediaId, String name, String sourceToken,
|
||||
{RelayBuffer? buffer, required void Function() onLiveChanged}) =>
|
||||
ActiveRelay._(mediaId, name, sourceToken, buffer ?? RelayBuffer(), null, onLiveChanged);
|
||||
|
||||
/// The server reads [url] itself.
|
||||
static ActiveRelay fetch(int mediaId, String name, Uri url,
|
||||
{RelayBuffer? buffer, required void Function() onLiveChanged}) {
|
||||
final b = buffer ?? RelayBuffer();
|
||||
final local = LocalRelayFeed(HttpRelayBytes(url), b);
|
||||
final r = ActiveRelay._(mediaId, name, null, b, local, onLiveChanged);
|
||||
unawaited(() async {
|
||||
try {
|
||||
final size = await local.bytes.size();
|
||||
if (r._disposed) return;
|
||||
b.attach(local, size);
|
||||
onLiveChanged();
|
||||
} catch (e) {
|
||||
_log.warning('relay fetch: cannot read the link: $e');
|
||||
}
|
||||
}());
|
||||
return r;
|
||||
}
|
||||
|
||||
var _disposed = false;
|
||||
bool get live => buffer.live;
|
||||
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
buffer.dispose();
|
||||
unawaited(local?.close());
|
||||
final ch = _channel;
|
||||
_channel = null;
|
||||
if (ch != null) unawaited(_closeNotSource(ch));
|
||||
}
|
||||
|
||||
/// Source member gone for good (kicked): stop its channel.
|
||||
void dropChannel() {
|
||||
final ch = _channel;
|
||||
_channel = null;
|
||||
if (ch != null) unawaited(_closeNotSource(ch));
|
||||
}
|
||||
|
||||
void _attachChannel(RelicWebSocket ws, int size) {
|
||||
final old = _channel;
|
||||
if (old != null && !identical(old, ws)) unawaited(old.tryClose());
|
||||
_channel = ws;
|
||||
final feed = _channelFeed = ChannelRelayFeed((t) => ws.trySendText(t));
|
||||
buffer.attach(feed, size);
|
||||
onLiveChanged();
|
||||
}
|
||||
|
||||
void _detachChannel(RelicWebSocket ws) {
|
||||
if (!identical(_channel, ws)) return;
|
||||
_channel = null;
|
||||
final feed = _channelFeed;
|
||||
_channelFeed = null;
|
||||
if (feed != null) buffer.detach(feed);
|
||||
onLiveChanged();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _closeNotSource(RelicWebSocket ws) async {
|
||||
try {
|
||||
await ws.close(relayCloseNotSource, 'not the source');
|
||||
} catch (_) {
|
||||
await ws.tryClose();
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /r/<sid>/<token>/source` (WebSocket): the source member's relay
|
||||
/// channel. `GET /r/<sid>/<token>/<mediaId>/<name>`: a viewer reads the
|
||||
/// relayed media, Range as for `/m`. [relay] gives the current relay.
|
||||
void mountRelayRoutes(
|
||||
RelicApp app,
|
||||
SessionMap sessions,
|
||||
ActiveRelay? Function() relay, {
|
||||
void Function(String token, MediaLog log)? onLog,
|
||||
}) {
|
||||
app.get('/r/:sid/:token/source', (final req) {
|
||||
final sid = req.pathParameters.raw[#sid] ?? '';
|
||||
final token = req.pathParameters.raw[#token] ?? '';
|
||||
final session = sessions.get(sid);
|
||||
final r = relay();
|
||||
final ok = session != null &&
|
||||
session.gate(token) == MediaGate.ok &&
|
||||
r != null &&
|
||||
r.sourceToken == token;
|
||||
return WebSocketUpgrade((final ws) async {
|
||||
if (!ok) {
|
||||
await _closeNotSource(ws);
|
||||
return;
|
||||
}
|
||||
final r = relay()!;
|
||||
ws.pingInterval = Duration(seconds: RelayConstants.pingIntervalSecs);
|
||||
var attached = false;
|
||||
try {
|
||||
await for (final e in ws.events) {
|
||||
if (r._disposed) break;
|
||||
if (e is TextDataReceived) {
|
||||
final Map j;
|
||||
try {
|
||||
j = jsonDecode(e.text) as Map;
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
final id = j['id'];
|
||||
switch (j['t']) {
|
||||
case 'info':
|
||||
final size = j['size'];
|
||||
if (size is int && size > 0 && !attached) {
|
||||
attached = true;
|
||||
_log.info('relay channel up (${r.name}, $size bytes)');
|
||||
r._attachChannel(ws, size);
|
||||
}
|
||||
case 'end':
|
||||
if (id is int) r.buffer.feedEnd(id);
|
||||
case 'fail':
|
||||
if (id is int) r.buffer.feedFail(id, '${j['reason']}');
|
||||
}
|
||||
} else if (e is BinaryDataReceived && attached) {
|
||||
try {
|
||||
final (id, payload) = decodeRelayFrame(e.data);
|
||||
r.buffer.feedBytes(id, payload);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// channel broke
|
||||
} finally {
|
||||
r._detachChannel(ws);
|
||||
_log.info('relay channel down (${r.name})');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/r/:sid/:token/:mediaId/:name', (final req) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final sid = req.pathParameters.raw[#sid] ?? '';
|
||||
final token = req.pathParameters.raw[#token] ?? '';
|
||||
final mediaId = int.tryParse(req.pathParameters.raw[#mediaId] ?? '');
|
||||
final session = sessions.get(sid);
|
||||
if (session == null) return _plain(410);
|
||||
switch (session.gate(token)) {
|
||||
case MediaGate.ok:
|
||||
break;
|
||||
case MediaGate.forbidden:
|
||||
return _plain(403);
|
||||
default:
|
||||
return _plain(401);
|
||||
}
|
||||
final r = relay();
|
||||
// A player still on an older media must not get the new one's bytes.
|
||||
if (r == null || r.mediaId != mediaId) return _plain(404);
|
||||
if (!await r.buffer.ready()) {
|
||||
return Response(503,
|
||||
body: Body.fromString('relay source not connected'),
|
||||
headers: Headers.build((h) => h['retry-after'] = ['2']));
|
||||
}
|
||||
final size = r.buffer.size;
|
||||
final typed = req.headers.range;
|
||||
String? rangeHeader;
|
||||
if (typed != null && typed.ranges.isNotEmpty) {
|
||||
final x = typed.ranges.first;
|
||||
rangeHeader = '${typed.unit}=${x.start?.toString() ?? ''}-${x.end?.toString() ?? ''}';
|
||||
}
|
||||
final res = resolveMediaRequest(rangeHeader: rangeHeader, size: size, filename: r.name);
|
||||
final headers = Headers.build((h) => res.headers.forEach((k, v) => h[k] = [v]));
|
||||
if (res.status != 200 && res.status != 206) {
|
||||
return Response(res.status, body: Body.fromString('status ${res.status}'), headers: headers);
|
||||
}
|
||||
final end = res.end ?? size - 1;
|
||||
final shortToken = token.length > 4 ? '…${token.substring(token.length - 4)}' : token;
|
||||
onLog?.call(
|
||||
token,
|
||||
MediaLog(
|
||||
method: req.method.name,
|
||||
path: '/r/$sid/$shortToken/$mediaId/${r.name}',
|
||||
rangeHeader: rangeHeader,
|
||||
status: res.status,
|
||||
bytesWritten: end - res.start + 1,
|
||||
durationMs: stopwatch.elapsedMilliseconds,
|
||||
));
|
||||
final Stream<Uint8List> body =
|
||||
r.buffer.read(token, res.start, end, onSent: (n) => session.addBytes(token, n));
|
||||
MimeType mime;
|
||||
try {
|
||||
mime = MimeType.parse(res.headers['content-type'] ?? '');
|
||||
} catch (_) {
|
||||
mime = MimeType.octetStream;
|
||||
}
|
||||
return Response(res.status,
|
||||
body: Body.fromDataStream(body, mimeType: mime, contentLength: end - res.start + 1),
|
||||
headers: headers);
|
||||
});
|
||||
}
|
||||
|
||||
Response _plain(int status) => Response(status, body: Body.fromString('status $status'));
|
||||
@@ -5,6 +5,7 @@ import 'package:relic/relic.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:web_socket/web_socket.dart';
|
||||
|
||||
import 'relay_routes.dart';
|
||||
import 'session.dart';
|
||||
|
||||
final _hubLog = createLogger('server_hub');
|
||||
@@ -32,6 +33,29 @@ class ServerHub {
|
||||
|
||||
StateBroadcast stateBroadcast() => engine.broadcast(relay: relayAvailable);
|
||||
|
||||
/// The relay of the current media (`/r/...`), if it is relayed.
|
||||
ActiveRelay? relay;
|
||||
|
||||
/// Replaces the relay (null: the new media is not relayed). The old
|
||||
/// buffer is dropped with it.
|
||||
void setRelay(ActiveRelay? next) {
|
||||
relay?.dispose();
|
||||
relay = next;
|
||||
}
|
||||
|
||||
/// `media.relay` for a relayed swap by [token]: who serves the bytes.
|
||||
MediaRelay relayFor(String token, MediaRelay requested) => MediaRelay(
|
||||
source: requested.fetch ? '' : (engine.members[token]?.id ?? ''), fetch: requested.fetch);
|
||||
|
||||
/// The relay's channel came up or went down: `media.relay.live` follows.
|
||||
void relayLiveChanged(ActiveRelay r) {
|
||||
final m = engine.media;
|
||||
if (!identical(relay, r) || m?.relay == null) return;
|
||||
if (m!.relay!.live == r.live) return;
|
||||
engine.media = m.copyWith(relay: m.relay!.copyWith(live: r.live));
|
||||
changed();
|
||||
}
|
||||
|
||||
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
|
||||
|
||||
/// The member a reconnecting client may take over with its old token:
|
||||
@@ -177,6 +201,7 @@ void mountSyncRoute(
|
||||
String Function(String sid, String token, SessionMedia? media, String host)
|
||||
urlBuilder, {
|
||||
String Function(String sid, String token, String itemId, String host)? relayUrlBuilder,
|
||||
String Function(String sid, String token, int mediaId, String name, String host)? relayMediaUrlBuilder,
|
||||
}) {
|
||||
app.get('/sync', (final req) {
|
||||
// Host the client dialled (Host header), so file URLs work for remote joiners.
|
||||
@@ -233,13 +258,17 @@ void mountSyncRoute(
|
||||
// By reference only: no URL (it could carry a token) is
|
||||
// stored or broadcast for Jellyfin media.
|
||||
final ref = sanitizeJellyfinRef(parsed.jellyfin!);
|
||||
// Relayed for viewers without access: from the swapper's
|
||||
// machine (its own sign-in never leaves it).
|
||||
final relayReq = parsed.relay == null || parsed.relay!.fetch ? null : parsed.relay;
|
||||
final media = ref == null
|
||||
? null
|
||||
: HelloMedia(
|
||||
name: sanitizeMediaName(parsed.name.isEmpty ? ref.title : parsed.name),
|
||||
size: parsed.size,
|
||||
duration: parsed.duration,
|
||||
jellyfin: ref);
|
||||
jellyfin: ref,
|
||||
relay: relayReq == null ? null : hub.relayFor(token, relayReq));
|
||||
if (media == null || !hub.engine.applySwap(token, media)) {
|
||||
wsErr(ws, 'bad swap media');
|
||||
return;
|
||||
@@ -250,6 +279,46 @@ void mountSyncRoute(
|
||||
duration: media.duration,
|
||||
jellyfin: ref,
|
||||
mediaId: hub.engine.mediaId);
|
||||
hub.setRelay(relayReq == null ? null : _memberRelay(hub, token, media.name));
|
||||
hub.changed();
|
||||
return;
|
||||
}
|
||||
final relayReq = parsed.relay;
|
||||
if (relayReq != null) {
|
||||
// Relayed file or link: no URL is stored or broadcast (a
|
||||
// link may be private to the swapper; the server keeps a
|
||||
// fetched one to itself).
|
||||
final name = sanitizeMediaName(parsed.name.isEmpty ? 'media' : parsed.name);
|
||||
final link = Uri.tryParse(parsed.url ?? '');
|
||||
if (relayReq.fetch &&
|
||||
(link == null || !(link.isScheme('http') || link.isScheme('https')) || link.host.isEmpty)) {
|
||||
wsErr(ws, 'relay fetch needs an http(s) url');
|
||||
return;
|
||||
}
|
||||
// The server would fetch any address it can reach (its own
|
||||
// network, a VPN) for whoever asks: only for members who
|
||||
// know the password.
|
||||
if (relayReq.fetch && session.secret.isEmpty) {
|
||||
wsErr(ws, 'server fetch needs a server password');
|
||||
return;
|
||||
}
|
||||
final media = HelloMedia(
|
||||
name: name,
|
||||
size: parsed.size,
|
||||
duration: parsed.duration,
|
||||
relay: hub.relayFor(token, relayReq));
|
||||
if (!hub.engine.applySwap(token, media)) {
|
||||
wsErr(ws, 'bad swap media');
|
||||
return;
|
||||
}
|
||||
session.media = SessionMedia(
|
||||
name: name, size: parsed.size, duration: parsed.duration, mediaId: hub.engine.mediaId);
|
||||
late final ActiveRelay active;
|
||||
active = relayReq.fetch
|
||||
? ActiveRelay.fetch(hub.engine.mediaId, name, link!,
|
||||
onLiveChanged: () => hub.relayLiveChanged(active))
|
||||
: _memberRelay(hub, token, name);
|
||||
hub.setRelay(active);
|
||||
hub.changed();
|
||||
return;
|
||||
}
|
||||
@@ -271,6 +340,7 @@ void mountSyncRoute(
|
||||
wsErr(ws, 'bad swap media');
|
||||
return;
|
||||
}
|
||||
hub.setRelay(null);
|
||||
if (matchesFile) {
|
||||
session.media = SessionMedia(
|
||||
name: current.name,
|
||||
@@ -406,6 +476,7 @@ void mountSyncRoute(
|
||||
final activeToken = token;
|
||||
hub.sockets[activeToken] = ws;
|
||||
session.tokens[activeToken]!.onKick = (reason) async {
|
||||
if (hub.relay?.sourceToken == activeToken) hub.relay!.dropChannel();
|
||||
ws.trySendText(jsonEncode(KickedMessage(reason: reason).toJson()));
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
await ws.tryClose();
|
||||
@@ -413,10 +484,23 @@ void mountSyncRoute(
|
||||
};
|
||||
final media = session.media;
|
||||
final relay = hub.relayAvailable;
|
||||
final relayed = hub.relay;
|
||||
final welcome = WelcomeMessage(
|
||||
token: activeToken,
|
||||
memberId: memberId,
|
||||
mediaUrl: media?.file != null ? urlBuilder('default', activeToken, media, dialledHost) : media?.url,
|
||||
// Jellyfin media resolves per member (own account first); only
|
||||
// relayed files and links start from the /r/ URL, and never for
|
||||
// the source itself (it plays its own copy).
|
||||
mediaUrl: relayed != null &&
|
||||
media != null &&
|
||||
media.jellyfin == null &&
|
||||
relayed.sourceToken != activeToken &&
|
||||
relayed.mediaId == media.mediaId &&
|
||||
relayMediaUrlBuilder != null
|
||||
? relayMediaUrlBuilder('default', activeToken, relayed.mediaId, relayed.name, dialledHost)
|
||||
: media?.file != null
|
||||
? urlBuilder('default', activeToken, media, dialledHost)
|
||||
: media?.url,
|
||||
relayUrl: relay && relayUrlBuilder != null
|
||||
? relayUrlBuilder('default', activeToken, media!.jellyfin!.itemId, dialledHost)
|
||||
: null,
|
||||
@@ -437,3 +521,9 @@ void mountSyncRoute(
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ActiveRelay _memberRelay(ServerHub hub, String token, String name) {
|
||||
late final ActiveRelay r;
|
||||
r = ActiveRelay.fromMember(hub.engine.mediaId, name, token, onLiveChanged: () => hub.relayLiveChanged(r));
|
||||
return r;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'jellyfin_relay.dart';
|
||||
import 'media_endpoint.dart';
|
||||
import 'relay_routes.dart';
|
||||
import 'server_hub.dart';
|
||||
import 'session.dart';
|
||||
|
||||
@@ -62,6 +63,7 @@ class SyncServer {
|
||||
);
|
||||
});
|
||||
mountSessionMediaRoute(_app, sessions);
|
||||
mountRelayRoutes(_app, sessions, () => hub.relay);
|
||||
mountJellyfinRelayRoute(
|
||||
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient());
|
||||
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
|
||||
@@ -72,7 +74,9 @@ class SyncServer {
|
||||
final name = Uri.encodeComponent(media?.name ?? 'file');
|
||||
return 'http://${authority(dialledHost)}/m/$sid/$token/$name';
|
||||
}, relayUrlBuilder: (sid, token, itemId, dialledHost) =>
|
||||
'http://${authority(dialledHost)}/j/$sid/$token/${Uri.encodeComponent(itemId)}');
|
||||
'http://${authority(dialledHost)}/j/$sid/$token/${Uri.encodeComponent(itemId)}',
|
||||
relayMediaUrlBuilder: (sid, token, mediaId, name, dialledHost) =>
|
||||
'http://${authority(dialledHost)}/r/$sid/$token/$mediaId/${Uri.encodeComponent(name)}');
|
||||
_app.fallback = (final req) => Response.notFound(
|
||||
body: Body.fromString('not found'),
|
||||
);
|
||||
@@ -143,6 +147,7 @@ class SyncServer {
|
||||
_heartbeat = null;
|
||||
_relayClient?.close(force: true);
|
||||
_relayClient = null;
|
||||
hub.setRelay(null);
|
||||
await hub.closeAllSockets();
|
||||
await _app.close();
|
||||
_server = null;
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// The relay end to end inside one process: a real SyncServer, the real
|
||||
// RelaySource from core, viewers as plain HTTP range requests.
|
||||
|
||||
class _Member {
|
||||
final WebSocket ws;
|
||||
final raw = <String>[];
|
||||
late WelcomeMessage welcome;
|
||||
_Member(this.ws);
|
||||
|
||||
static Future<_Member> join(int port, String name, {String secret = 'pw'}) async {
|
||||
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
|
||||
final m = _Member(ws);
|
||||
final w = Completer<String>();
|
||||
ws.listen((f) {
|
||||
m.raw.add('$f');
|
||||
if ('$f'.contains('"t":"welcome"') && !w.isCompleted) w.complete('$f');
|
||||
}, onError: (_) {});
|
||||
ws.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: secret,
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0),
|
||||
).toJson()));
|
||||
m.welcome = WelcomeMessage.fromJson(
|
||||
Map<String, Object?>.from(jsonDecode(await w.future.timeout(const Duration(seconds: 5))) as Map));
|
||||
return m;
|
||||
}
|
||||
|
||||
void send(Map<String, Object?> j) => ws.add(jsonEncode(j));
|
||||
|
||||
Map<String, Object?>? get lastState {
|
||||
for (final f in raw.reversed) {
|
||||
final d = jsonDecode(f) as Map;
|
||||
if (d['t'] == 'state') return Map<String, Object?>.from(d);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, Object?>? get media => lastState?['media'] as Map<String, Object?>?;
|
||||
}
|
||||
|
||||
Future<void> _until(bool Function() cond, String what, {int secs = 5}) async {
|
||||
final deadline = DateTime.now().add(Duration(seconds: secs));
|
||||
while (!cond()) {
|
||||
if (DateTime.now().isAfter(deadline)) fail('timed out waiting for $what');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late Directory tmp;
|
||||
late File file;
|
||||
late Uint8List bytes;
|
||||
late SyncServer server;
|
||||
late HttpClient http;
|
||||
final cleanup = <Future<void> Function()>[];
|
||||
|
||||
setUp(() async {
|
||||
tmp = await Directory.systemTemp.createTemp('relay-test');
|
||||
final rnd = Random(7);
|
||||
// 5.5 slices: exercises the short last slice.
|
||||
bytes = Uint8List.fromList(List.generate(5 * RelayBuffer.slice + 500000, (_) => rnd.nextInt(256)));
|
||||
file = File('${tmp.path}/ep 01.mkv')..writeAsBytesSync(bytes);
|
||||
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 't'));
|
||||
await server.start();
|
||||
http = HttpClient();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
for (final c in cleanup.reversed) {
|
||||
await c();
|
||||
}
|
||||
cleanup.clear();
|
||||
http.close(force: true);
|
||||
await server.stop();
|
||||
await tmp.delete(recursive: true);
|
||||
});
|
||||
|
||||
Future<_Member> join(String name) async {
|
||||
final m = await _Member.join(server.boundPort, name);
|
||||
cleanup.add(() => m.ws.close());
|
||||
return m;
|
||||
}
|
||||
|
||||
Future<RelaySource> startSource(_Member m, {RelayBytes? from}) async {
|
||||
final src = RelaySource(
|
||||
Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/${m.welcome.token}/source'),
|
||||
from ?? FileRelayBytes(file),
|
||||
backoff: (_) => const Duration(milliseconds: 100));
|
||||
await src.start();
|
||||
cleanup.add(src.close);
|
||||
return src;
|
||||
}
|
||||
|
||||
Future<void> swapRelay(_Member m, {String name = 'ep 01.mkv', Map<String, Object?> relay = const {}, String? url}) async {
|
||||
final before = m.raw.length;
|
||||
m.send({
|
||||
't': 'swap',
|
||||
'media': {'name': name, 'size': bytes.length, 'duration': 0, if (url != null) 'url': url, 'relay': relay},
|
||||
});
|
||||
await _until(() => m.raw.skip(before).any((f) => f.contains('"relay"') && f.contains('"t":"state"')), 'relay state');
|
||||
}
|
||||
|
||||
String viewerUrl(_Member v, int mediaId) =>
|
||||
'http://127.0.0.1:${server.boundPort}/r/default/${v.welcome.token}/$mediaId/${Uri.encodeComponent('ep 01.mkv')}';
|
||||
|
||||
Future<(int, Map<String, String>, List<int>)> get(String url, {String? range}) async {
|
||||
final req = await http.getUrl(Uri.parse(url));
|
||||
if (range != null) req.headers.set('range', range);
|
||||
final res = await req.close();
|
||||
final body = <int>[for (final c in await res.toList()) ...c];
|
||||
final h = <String, String>{};
|
||||
res.headers.forEach((k, v) => h[k] = v.join(','));
|
||||
return (res.statusCode, h, body);
|
||||
}
|
||||
|
||||
int mediaIdOf(_Member m) => m.lastState!['mediaId'] as int;
|
||||
|
||||
group('member as source', () {
|
||||
test('state names the source, no URL; live once the channel is up', () async {
|
||||
final alice = await join('alice');
|
||||
await swapRelay(alice);
|
||||
expect(alice.media!['relay'], {'source': alice.welcome.memberId});
|
||||
expect(alice.media!.containsKey('url'), isFalse);
|
||||
await startSource(alice);
|
||||
await _until(() => (alice.media?['relay'] as Map?)?['live'] == true, 'live');
|
||||
});
|
||||
|
||||
test('viewers get byte-exact ranges, including the short last slice and 416', () async {
|
||||
final alice = await join('alice');
|
||||
final bob = await join('bob');
|
||||
await swapRelay(alice);
|
||||
await startSource(alice);
|
||||
final url = viewerUrl(bob, mediaIdOf(alice));
|
||||
final n = bytes.length;
|
||||
for (final (range, start, end) in [
|
||||
(null, 0, n - 1),
|
||||
('bytes=0-99', 0, 99),
|
||||
('bytes=${RelayBuffer.slice - 10}-${RelayBuffer.slice + 10}', RelayBuffer.slice - 10, RelayBuffer.slice + 10),
|
||||
('bytes=${n - 777}-', n - 777, n - 1),
|
||||
('bytes=-333', n - 333, n - 1),
|
||||
]) {
|
||||
final (status, h, body) = await get(url, range: range);
|
||||
expect(status, range == null ? 200 : 206, reason: '$range');
|
||||
expect(body, bytes.sublist(start, end + 1), reason: '$range');
|
||||
if (range != null) expect(h['content-range'], 'bytes $start-$end/$n');
|
||||
expect(h['content-type'], 'video/x-matroska');
|
||||
}
|
||||
final (status, h, _) = await get(url, range: 'bytes=${n + 5}-');
|
||||
expect(status, 416);
|
||||
expect(h['content-range'], 'bytes */$n');
|
||||
});
|
||||
|
||||
test('a late joiner gets its own /r/ URL in welcome', () async {
|
||||
final alice = await join('alice');
|
||||
await swapRelay(alice);
|
||||
final carol = await join('carol');
|
||||
expect(carol.welcome.mediaUrl, viewerUrl(carol, mediaIdOf(alice)));
|
||||
});
|
||||
|
||||
test('nobody watching: the source uploads nothing', () async {
|
||||
final alice = await join('alice');
|
||||
await swapRelay(alice);
|
||||
final src = await startSource(alice);
|
||||
await _until(() => server.hub.relay!.live, 'live');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 600));
|
||||
expect(src.bytesSent, 0);
|
||||
});
|
||||
|
||||
test('several viewers: each byte crosses the source link about once', () async {
|
||||
final alice = await join('alice');
|
||||
final viewers = [for (final n in ['b', 'c', 'd']) await join(n)];
|
||||
await swapRelay(alice);
|
||||
final src = await startSource(alice);
|
||||
final id = mediaIdOf(alice);
|
||||
final got = await Future.wait([for (final v in viewers) get(viewerUrl(v, id))]);
|
||||
for (final (status, _, body) in got) {
|
||||
expect(status, 200);
|
||||
expect(body.length, bytes.length);
|
||||
}
|
||||
expect(src.bytesSent, lessThan(bytes.length * 1.3));
|
||||
});
|
||||
|
||||
test('only the source may open the channel', () async {
|
||||
final alice = await join('alice');
|
||||
final bob = await join('bob');
|
||||
await swapRelay(alice);
|
||||
final impostor = await startSource(bob);
|
||||
await impostor.done.timeout(const Duration(seconds: 5));
|
||||
expect(server.hub.relay!.live, isFalse);
|
||||
});
|
||||
|
||||
test('a new swap drops the buffer, old URLs 404, the source stops', () async {
|
||||
final alice = await join('alice');
|
||||
final bob = await join('bob');
|
||||
await swapRelay(alice);
|
||||
final src = await startSource(alice);
|
||||
final oldId = mediaIdOf(alice);
|
||||
expect((await get(viewerUrl(bob, oldId), range: 'bytes=0-9')).$1, 206);
|
||||
final buf = server.hub.relay!.buffer;
|
||||
alice.send({
|
||||
't': 'swap',
|
||||
'media': {'name': 'x', 'size': 0, 'duration': 0, 'url': 'http://example.invalid/x.mkv'},
|
||||
});
|
||||
await _until(() => mediaIdOf(alice) != oldId, 'swap');
|
||||
expect(server.hub.relay, isNull);
|
||||
expect(buf.cachedBytes, 0);
|
||||
await src.done.timeout(const Duration(seconds: 5));
|
||||
expect((await get(viewerUrl(bob, oldId), range: 'bytes=0-9')).$1, 404);
|
||||
});
|
||||
|
||||
test('server stop drops the relay', () async {
|
||||
final alice = await join('alice');
|
||||
await swapRelay(alice);
|
||||
final buf = server.hub.relay!.buffer;
|
||||
await server.stop();
|
||||
expect(server.hub.relay, isNull);
|
||||
expect(buf.cachedBytes, 0);
|
||||
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 't'));
|
||||
await server.start();
|
||||
});
|
||||
|
||||
test('source drops and comes back: buffered bytes keep serving, the rest resumes', () async {
|
||||
final alice = await join('alice');
|
||||
final bob = await join('bob');
|
||||
await swapRelay(alice);
|
||||
final src = await startSource(alice);
|
||||
final url = viewerUrl(bob, mediaIdOf(alice));
|
||||
expect((await get(url, range: 'bytes=0-${RelayBuffer.slice * 2 - 1}')).$3,
|
||||
bytes.sublist(0, RelayBuffer.slice * 2));
|
||||
await src.close();
|
||||
await src.done;
|
||||
await _until(() => !server.hub.relay!.live, 'down');
|
||||
expect((await get(url, range: 'bytes=0-99')).$3, bytes.sublist(0, 100));
|
||||
final pending = get(url, range: 'bytes=${RelayBuffer.slice * 4}-');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
await startSource(alice);
|
||||
final (status, _, body) = await pending.timeout(const Duration(seconds: 10));
|
||||
expect(status, 206);
|
||||
expect(body, bytes.sublist(RelayBuffer.slice * 4));
|
||||
});
|
||||
|
||||
test('Jellyfin swap can carry a relay', () async {
|
||||
final alice = await join('alice');
|
||||
final before = alice.raw.length;
|
||||
alice.send({
|
||||
't': 'swap',
|
||||
'media': {
|
||||
'name': 'Pilot',
|
||||
'size': 0,
|
||||
'duration': 0,
|
||||
'jellyfin': {'serverId': 'srv1', 'itemId': '0123456789abcdef0123456789abcdef', 'title': 'Pilot'},
|
||||
'relay': {},
|
||||
},
|
||||
});
|
||||
await _until(() => alice.raw.skip(before).any((f) => f.contains('"jellyfin"')), 'state');
|
||||
expect(alice.media!['relay'], {'source': alice.welcome.memberId});
|
||||
expect(server.hub.relay!.sourceToken, alice.welcome.token);
|
||||
});
|
||||
});
|
||||
|
||||
group('server fetches the link', () {
|
||||
late HttpServer origin;
|
||||
setUp(() async {
|
||||
origin = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
origin.listen((req) async {
|
||||
final r = resolveMediaRequest(
|
||||
rangeHeader: req.headers.value('range'), size: bytes.length, filename: 'x.mkv');
|
||||
req.response.statusCode = r.status;
|
||||
r.headers.forEach(req.response.headers.set);
|
||||
if (r.status == 200 || r.status == 206) {
|
||||
final end = r.end ?? bytes.length - 1;
|
||||
req.response.add(bytes.sublist(r.start, end + 1));
|
||||
}
|
||||
await req.response.close();
|
||||
});
|
||||
});
|
||||
tearDown(() => origin.close(force: true));
|
||||
|
||||
test('byte-exact, the link is never broadcast', () async {
|
||||
final alice = await join('alice');
|
||||
final link = 'http://127.0.0.1:${origin.port}/secret/file.mkv?api_key=KEY';
|
||||
await swapRelay(alice, relay: {'fetch': true}, url: link);
|
||||
expect(jsonEncode(alice.lastState), isNot(contains('KEY')));
|
||||
await _until(() => server.hub.relay!.live, 'live');
|
||||
final bob = await join('bob');
|
||||
expect(jsonEncode(bob.welcome.toJson()), isNot(contains('KEY')));
|
||||
final (status, _, body) = await get(bob.welcome.mediaUrl!, range: 'bytes=1000-');
|
||||
expect(status, 206);
|
||||
expect(body, bytes.sublist(1000));
|
||||
});
|
||||
|
||||
test('an open server (no password) refuses to fetch', () async {
|
||||
await server.stop();
|
||||
server = SyncServer(const AppConfig(port: 0, secret: '', name: 'open'));
|
||||
await server.start();
|
||||
final m = await _Member.join(server.boundPort, 'mallory', secret: '');
|
||||
cleanup.add(() => m.ws.close());
|
||||
m.send({
|
||||
't': 'swap',
|
||||
'media': {
|
||||
'name': 'x', 'size': 0, 'duration': 0,
|
||||
'url': 'http://127.0.0.1:${origin.port}/x.mkv', 'relay': {'fetch': true},
|
||||
},
|
||||
});
|
||||
await _until(() => m.raw.any((f) => f.contains('server fetch needs a server password')), 'err');
|
||||
expect(server.hub.relay, isNull);
|
||||
});
|
||||
|
||||
test('a non-http link is refused', () async {
|
||||
final alice = await join('alice');
|
||||
alice.send({
|
||||
't': 'swap',
|
||||
'media': {'name': 'x', 'size': 0, 'duration': 0, 'url': 'file:///etc/passwd', 'relay': {'fetch': true}},
|
||||
});
|
||||
await _until(() => alice.raw.any((f) => f.contains('relay fetch needs')), 'err');
|
||||
expect(server.hub.relay, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('buffer', () {
|
||||
// A feed that serves [bytes] in-process and counts what it sends.
|
||||
late RelayBuffer buf;
|
||||
late LocalRelayFeed feed;
|
||||
late _CountingBytes counted;
|
||||
setUp(() {
|
||||
buf = RelayBuffer(ahead: 2 * RelayBuffer.slice, back: RelayBuffer.slice, pin: RelayBuffer.slice);
|
||||
counted = _CountingBytes(FileRelayBytes(file));
|
||||
feed = LocalRelayFeed(counted, buf);
|
||||
buf.attach(feed, bytes.length);
|
||||
});
|
||||
tearDown(() async {
|
||||
buf.dispose();
|
||||
await feed.close();
|
||||
});
|
||||
|
||||
Future<List<int>> read(String v, int start, int end) async =>
|
||||
[for (final c in await buf.read(v, start, end).toList()) ...c];
|
||||
|
||||
test('keeps pinned head/tail plus a window around the viewer', () async {
|
||||
expect(await read('a', 0, bytes.length - 1), bytes);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
// ahead 2 + back 1 + 2 pinned slices: never all 6.
|
||||
expect(buf.cachedBytes, lessThanOrEqualTo(5 * RelayBuffer.slice));
|
||||
});
|
||||
|
||||
test('a superseded request does not hold the feed', () async {
|
||||
// Like mpv after a seek: the old request stays open, unread.
|
||||
final old = buf.read('a', 0, bytes.length - 1).listen((_) {})..pause();
|
||||
final s = 3 * RelayBuffer.slice;
|
||||
expect(await read('a', s, s + 999).timeout(const Duration(seconds: 5)), bytes.sublist(s, s + 1000));
|
||||
await old.cancel();
|
||||
});
|
||||
|
||||
test('dispose frees everything and ends waiting readers', () async {
|
||||
await read('a', 0, 10);
|
||||
buf.dispose();
|
||||
expect(buf.cachedBytes, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _CountingBytes implements RelayBytes {
|
||||
final RelayBytes inner;
|
||||
int sent = 0;
|
||||
_CountingBytes(this.inner);
|
||||
@override
|
||||
Future<int> size() => inner.size();
|
||||
@override
|
||||
Stream<List<int>> read(int start, int end) => inner.read(start, end).map((c) {
|
||||
sent += c.length;
|
||||
return c;
|
||||
});
|
||||
@override
|
||||
Future<void> close() => inner.close();
|
||||
}
|
||||
@@ -80,8 +80,14 @@ void main() {
|
||||
});
|
||||
|
||||
group('core welcome + proto', () {
|
||||
test('proto 7: Jellyfin refs and relay', () {
|
||||
expect(RelayConstants.protoVersion, 7);
|
||||
test('proto 8: relayed media', () {
|
||||
expect(RelayConstants.protoVersion, 8);
|
||||
const m = HelloMedia(name: 'a', size: 1, duration: 0, relay: MediaRelay(source: 'm1', live: true));
|
||||
final back = HelloMedia.fromJson(Map<String, Object?>.from(m.toJson()));
|
||||
expect(back.relay!.source, 'm1');
|
||||
expect(back.relay!.live, isTrue);
|
||||
expect(back.relay!.fetch, isFalse);
|
||||
expect(const MediaRelay().toJson(), isEmpty);
|
||||
});
|
||||
|
||||
test('welcome round-trips with token + member id', () {
|
||||
|
||||
Reference in new issue
Block a user