Relay: a member's media through the server's buffer (proto 8)
ci / core (push) Successful in 28s
ci / updater-windows (push) Successful in 49s
ci / server (push) Successful in 49s

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:
foxyzandClaude Opus 5.5 committed 2026-10-08 20:41:01 +03:00
1 parent 9c5733719b
commit e7f33b5dc3
27 files changed
+2438 -116

No files matched your search

+2
View File
@@ -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';
+408
View File
@@ -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();
}
}
}
+230
View File
@@ -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'));
+92 -2
View File
@@ -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;
}
+6 -1
View File
@@ -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;