forked from Foxyz/sync_replay_project
Protocol 11: split-track relay (YouTube video+audio), chunked upstream reads
Relayed media can carry 2-4 byte tracks (video, audio): yt-dlp picks a split pair when no single-file format exists (h264 first), the source channel serves each track, the server keeps a buffer per track (budget split by size, 8 MiB floor) and viewers open an mpv edl:// of the track URLs. Expired URLs re-resolve once for all tracks. Upstream GETs are cut into <= http_chunk_size (10 MiB) ranges: YouTube throttles larger ones. Live streams and DRM are refused; HLS/DASH pieces are a follow-up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b2b77a6b3f
commit
b038706dff
19 files changed
+945
-286
No files matched your search
@@ -31,7 +31,7 @@ class LocalRelayFeed extends RelayFeed {
|
||||
_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();
|
||||
unawaited(_subs.remove(id)?.cancel().catchError((_) {}));
|
||||
buffer.feedFail(id, '$e');
|
||||
},
|
||||
onDone: () {
|
||||
@@ -42,26 +42,29 @@ class LocalRelayFeed extends RelayFeed {
|
||||
);
|
||||
}
|
||||
|
||||
/// A read cut off mid-request errors on cancel: moot.
|
||||
@override
|
||||
void cancel(int id) => _subs.remove(id)?.cancel();
|
||||
void cancel(int id) => unawaited(_subs.remove(id)?.cancel().catchError((_) {}));
|
||||
|
||||
Future<void> close() async {
|
||||
for (final s in _subs.values) {
|
||||
await s.cancel();
|
||||
await s.cancel().catchError((_) {});
|
||||
}
|
||||
_subs.clear();
|
||||
await bytes.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// A relay channel (`/r/.../source`) as a feed.
|
||||
/// A relay channel (`/r/.../source`) as a feed: [track] of the source's
|
||||
/// media (null: a single file, the pull names no track).
|
||||
class ChannelRelayFeed extends RelayFeed {
|
||||
final void Function(String text) send;
|
||||
ChannelRelayFeed(this.send);
|
||||
final int? track;
|
||||
ChannelRelayFeed(this.send, {this.track});
|
||||
|
||||
@override
|
||||
void pull(int id, int start, int end) =>
|
||||
send(jsonEncode({'t': 'pull', 'id': id, 'start': start, 'end': end}));
|
||||
send(jsonEncode({'t': 'pull', 'id': id, if (track != null) 'track': track, 'start': start, 'end': end}));
|
||||
|
||||
@override
|
||||
void cancel(int id) => send(jsonEncode({'t': 'cancel', 'id': id}));
|
||||
@@ -100,14 +103,24 @@ class _Viewer {
|
||||
/// 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;
|
||||
/// Set by the relay when a media's tracks share the budget.
|
||||
int ahead, back;
|
||||
final int pin;
|
||||
final Duration waitLimit;
|
||||
/// Pull ids; the tracks of one media share one sequence (one channel).
|
||||
final int Function() _newId;
|
||||
RelayBuffer({
|
||||
this.ahead = RelayConstants.relayAheadBytes,
|
||||
this.back = RelayConstants.relayBackBytes,
|
||||
this.pin = RelayConstants.relayPinBytes,
|
||||
this.waitLimit = const Duration(seconds: RelayConstants.relayWaitSecs),
|
||||
});
|
||||
int Function()? nextId,
|
||||
}) : _newId = nextId ?? _counter();
|
||||
|
||||
static int Function() _counter() {
|
||||
var n = 0;
|
||||
return () => ++n;
|
||||
}
|
||||
|
||||
static const slice = RelayConstants.relaySliceBytes;
|
||||
|
||||
@@ -118,7 +131,7 @@ class RelayBuffer {
|
||||
final _blocked = <int, int>{}; // slice -> wait start (us)
|
||||
final _viewers = <String, _Viewer>{};
|
||||
_Pull? _pull;
|
||||
var _nextId = 1, _reqSeq = 0;
|
||||
var _reqSeq = 0;
|
||||
var _disposed = false;
|
||||
Completer<void> _kick = Completer();
|
||||
final Completer<void> _ready = Completer();
|
||||
@@ -217,6 +230,7 @@ class RelayBuffer {
|
||||
}
|
||||
|
||||
void feedFail(int id, String reason) {
|
||||
if (_pull?.id != id) return;
|
||||
_log.warning('relay pull $id failed: $reason');
|
||||
feedEnd(id);
|
||||
}
|
||||
@@ -323,7 +337,7 @@ class RelayBuffer {
|
||||
while (e + 1 < _nSlices && !_slices.containsKey(e + 1) && _wanted(e + 1)) {
|
||||
e++;
|
||||
}
|
||||
final pl = _Pull(_nextId++, s * slice, _sliceEnd(e));
|
||||
final pl = _Pull(_newId(), s * slice, _sliceEnd(e));
|
||||
_pull = pl;
|
||||
feed.pull(pl.id, pl.start, pl.end);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:relic/relic.dart';
|
||||
@@ -12,43 +13,83 @@ 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).
|
||||
/// Each track's (ahead, back) bytes when a media's [sizes] share one
|
||||
/// buffer budget: by size (the tracks have one duration, so by bitrate),
|
||||
/// never under [RelayConstants.relayTrackFloorBytes]. One track keeps the
|
||||
/// whole budget.
|
||||
List<(int, int)> relayBudgets(List<int> sizes,
|
||||
{int ahead = RelayConstants.relayAheadBytes, int back = RelayConstants.relayBackBytes}) {
|
||||
if (sizes.length <= 1) return [for (final _ in sizes) (ahead, back)];
|
||||
final total = sizes.fold(0, (a, n) => a + n);
|
||||
int share(int all, int n) => max(RelayConstants.relayTrackFloorBytes, (all * (n / total)).round());
|
||||
return [for (final n in sizes) (share(ahead, n), share(back, n))];
|
||||
}
|
||||
|
||||
/// The relay of the current media: one buffer per track and where the
|
||||
/// bytes come from. One per relayed swap; [dispose] on the next swap or
|
||||
/// server stop (the buffers are 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]).
|
||||
/// server fetches the link itself.
|
||||
final String? sourceToken;
|
||||
final RelayBuffer buffer;
|
||||
final LocalRelayFeed? local;
|
||||
final void Function() onLiveChanged;
|
||||
/// One per track, in track order; empty until the server has read a
|
||||
/// fetched link.
|
||||
List<RelayBuffer> buffers = const [];
|
||||
/// `media.relay.tracks`: the kinds when there are several tracks.
|
||||
List<String> kinds = const [];
|
||||
final _known = Completer<void>();
|
||||
var _ids = 0;
|
||||
RelayMedia? _media;
|
||||
var _local = const <LocalRelayFeed>[];
|
||||
RelicWebSocket? _channel;
|
||||
ChannelRelayFeed? _channelFeed;
|
||||
var _channelFeeds = const <ChannelRelayFeed>[];
|
||||
|
||||
ActiveRelay._(this.mediaId, this.name, this.sourceToken, this.buffer, this.local, this.onLiveChanged);
|
||||
ActiveRelay._(this.mediaId, this.name, this.sourceToken, this.onLiveChanged);
|
||||
|
||||
/// Bytes from member [sourceToken] over its relay channel.
|
||||
int _nextId() => ++_ids;
|
||||
|
||||
void _setTracks(List<String> kinds) {
|
||||
this.kinds = kinds.length > 1 ? List.unmodifiable(kinds) : const [];
|
||||
buffers = [for (var i = 0; i < max(1, kinds.length); i++) RelayBuffer(nextId: _nextId)];
|
||||
if (!_known.isCompleted) _known.complete();
|
||||
}
|
||||
|
||||
void _budget(List<int> sizes) {
|
||||
final b = relayBudgets(sizes);
|
||||
for (var i = 0; i < buffers.length; i++) {
|
||||
buffers[i]
|
||||
..ahead = b[i].$1
|
||||
..back = b[i].$2;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes from member [sourceToken] over its relay channel, as the
|
||||
/// [tracks] its swap named (empty: one file).
|
||||
factory ActiveRelay.fromMember(int mediaId, String name, String sourceToken,
|
||||
{RelayBuffer? buffer, required void Function() onLiveChanged}) =>
|
||||
ActiveRelay._(mediaId, name, sourceToken, buffer ?? RelayBuffer(), null, onLiveChanged);
|
||||
{List<String> tracks = const [], required void Function() onLiveChanged}) =>
|
||||
ActiveRelay._(mediaId, name, sourceToken, onLiveChanged).._setTracks(tracks);
|
||||
|
||||
/// The server reads [url] itself: a media file as is, a web page
|
||||
/// through [ytDlp]. [onFail] gets the reason when it cannot.
|
||||
/// through [ytDlp] (one file, or video + audio tracks). [onFail] gets
|
||||
/// the reason when it cannot.
|
||||
static ActiveRelay fetch(int mediaId, String name, Uri url,
|
||||
{RelayBuffer? buffer,
|
||||
YtDlp? ytDlp,
|
||||
required void Function() onLiveChanged,
|
||||
void Function(String reason)? onFail}) {
|
||||
final b = buffer ?? RelayBuffer();
|
||||
final local = LocalRelayFeed(LinkRelayBytes(url, ytDlp: ytDlp), b);
|
||||
final r = ActiveRelay._(mediaId, name, null, b, local, onLiveChanged);
|
||||
{YtDlp? ytDlp, required void Function() onLiveChanged, void Function(String reason)? onFail}) {
|
||||
final r = ActiveRelay._(mediaId, name, null, onLiveChanged);
|
||||
final media = r._media = LinkRelayMedia(url, ytDlp: ytDlp);
|
||||
unawaited(() async {
|
||||
try {
|
||||
final size = await local.bytes.size();
|
||||
final tracks = await media.open();
|
||||
final sizes = [for (final t in tracks) await t.bytes.size()];
|
||||
if (r._disposed) return;
|
||||
b.attach(local, size);
|
||||
r._setTracks([for (final t in tracks) t.kind]);
|
||||
r._budget(sizes);
|
||||
r._local = [for (var i = 0; i < tracks.length; i++) LocalRelayFeed(tracks[i].bytes, r.buffers[i])];
|
||||
for (var i = 0; i < tracks.length; i++) {
|
||||
r.buffers[i].attach(r._local[i], sizes[i]);
|
||||
}
|
||||
onLiveChanged();
|
||||
} catch (e) {
|
||||
_log.warning('relay fetch: cannot read the link: $e');
|
||||
@@ -59,13 +100,40 @@ class ActiveRelay {
|
||||
}
|
||||
|
||||
var _disposed = false;
|
||||
bool get live => buffer.live;
|
||||
bool get live => buffers.isNotEmpty && buffers.every((b) => b.live);
|
||||
|
||||
/// The tracks are known (a fetched link has been read).
|
||||
bool get known => _known.isCompleted && !_disposed;
|
||||
|
||||
/// Waits until the tracks are known, up to [RelayConstants.relayWaitSecs].
|
||||
Future<bool> whenKnown() async {
|
||||
if (!_known.isCompleted) {
|
||||
await _known.future.timeout(const Duration(seconds: RelayConstants.relayWaitSecs), onTimeout: () {});
|
||||
}
|
||||
return known;
|
||||
}
|
||||
|
||||
/// The buffer of [track]; null (the single-file URL) only for a media of
|
||||
/// one track. Null when there is no such track.
|
||||
RelayBuffer? bufferAt(int? track) {
|
||||
if (track == null) return buffers.length == 1 ? buffers.single : null;
|
||||
return track >= 0 && track < buffers.length ? buffers[track] : null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
buffer.dispose();
|
||||
unawaited(local?.close());
|
||||
for (final b in buffers) {
|
||||
b.dispose();
|
||||
}
|
||||
if (!_known.isCompleted) _known.complete();
|
||||
final local = _local, media = _media;
|
||||
unawaited(() async {
|
||||
for (final f in local) {
|
||||
await f.close();
|
||||
}
|
||||
await media?.close();
|
||||
}());
|
||||
final ch = _channel;
|
||||
_channel = null;
|
||||
if (ch != null) unawaited(_closeNotSource(ch));
|
||||
@@ -78,25 +146,48 @@ class ActiveRelay {
|
||||
if (ch != null) unawaited(_closeNotSource(ch));
|
||||
}
|
||||
|
||||
void _attachChannel(RelicWebSocket ws, int size) {
|
||||
void _attachChannel(RelicWebSocket ws, List<int> sizes) {
|
||||
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);
|
||||
final multi = buffers.length > 1;
|
||||
final feeds = _channelFeeds = [
|
||||
for (var i = 0; i < buffers.length; i++) ChannelRelayFeed((t) => ws.trySendText(t), track: multi ? i : null),
|
||||
];
|
||||
_budget(sizes);
|
||||
for (var i = 0; i < buffers.length; i++) {
|
||||
buffers[i].attach(feeds[i], sizes[i]);
|
||||
}
|
||||
onLiveChanged();
|
||||
}
|
||||
|
||||
void _detachChannel(RelicWebSocket ws) {
|
||||
if (!identical(_channel, ws)) return;
|
||||
_channel = null;
|
||||
final feed = _channelFeed;
|
||||
_channelFeed = null;
|
||||
if (feed != null) buffer.detach(feed);
|
||||
final feeds = _channelFeeds;
|
||||
_channelFeeds = const [];
|
||||
for (var i = 0; i < feeds.length && i < buffers.length; i++) {
|
||||
buffers[i].detach(feeds[i]);
|
||||
}
|
||||
onLiveChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// The track sizes of a channel's `info` frame (`size`, or `tracks[].size`),
|
||||
/// or null when malformed.
|
||||
List<int>? _infoSizes(Map j) {
|
||||
final size = j['size'], tracks = j['tracks'];
|
||||
if (size is int) return size > 0 ? [size] : null;
|
||||
if (tracks is! List || tracks.isEmpty) return null;
|
||||
final out = <int>[];
|
||||
for (final t in tracks) {
|
||||
final n = t is Map ? t['size'] : null;
|
||||
if (n is! int || n <= 0) return null;
|
||||
out.add(n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<void> _closeNotSource(RelicWebSocket ws) async {
|
||||
try {
|
||||
await ws.close(relayCloseNotSource, 'not the source');
|
||||
@@ -107,7 +198,8 @@ Future<void> _closeNotSource(RelicWebSocket ws) async {
|
||||
|
||||
/// `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.
|
||||
/// relayed file, Range as for `/m`; `.../<mediaId>/<track>/<name>` one
|
||||
/// track of a media relayed as several. [relay] gives the current relay.
|
||||
void mountRelayRoutes(
|
||||
RelicApp app,
|
||||
SessionMap sessions,
|
||||
@@ -144,21 +236,37 @@ void mountRelayRoutes(
|
||||
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);
|
||||
if (attached) continue;
|
||||
final sizes = _infoSizes(j);
|
||||
if (sizes == null || sizes.length != r.buffers.length) {
|
||||
// Not the media this swap named: the source stops.
|
||||
_log.warning('relay channel: info does not match the media (${r.name})');
|
||||
await _closeNotSource(ws);
|
||||
return;
|
||||
}
|
||||
attached = true;
|
||||
_log.info('relay channel up (${r.name}, ${sizes.join(' + ')} bytes)');
|
||||
r._attachChannel(ws, sizes);
|
||||
case 'end':
|
||||
if (id is int) r.buffer.feedEnd(id);
|
||||
if (id is int) {
|
||||
for (final b in r.buffers) {
|
||||
b.feedEnd(id);
|
||||
}
|
||||
}
|
||||
case 'fail':
|
||||
if (id is int) r.buffer.feedFail(id, '${j['reason']}');
|
||||
if (id is int) {
|
||||
for (final b in r.buffers) {
|
||||
b.feedFail(id, '${j['reason']}');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (e is BinaryDataReceived && attached) {
|
||||
try {
|
||||
// Pull ids are unique across tracks: only the owner takes it.
|
||||
final (id, payload) = decodeRelayFrame(e.data);
|
||||
r.buffer.feedBytes(id, payload);
|
||||
for (final b in r.buffers) {
|
||||
b.feedBytes(id, payload);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -171,11 +279,12 @@ void mountRelayRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/r/:sid/:token/:mediaId/:name', (final req) async {
|
||||
Future<Response> viewer(Request req, {required bool tracked}) 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 track = tracked ? int.tryParse(req.pathParameters.raw[#seg] ?? '') ?? -1 : null;
|
||||
final session = sessions.get(sid);
|
||||
if (session == null) return _plain(410);
|
||||
switch (session.gate(token)) {
|
||||
@@ -189,12 +298,14 @@ void mountRelayRoutes(
|
||||
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 notConnected = Response(503,
|
||||
body: Body.fromString('relay source not connected'),
|
||||
headers: Headers.build((h) => h['retry-after'] = ['2']));
|
||||
if (!await r.whenKnown()) return notConnected;
|
||||
final buffer = r.bufferAt(track);
|
||||
if (buffer == null) return _plain(404);
|
||||
if (!await buffer.ready()) return notConnected;
|
||||
final size = buffer.size;
|
||||
final typed = req.headers.range;
|
||||
String? rangeHeader;
|
||||
if (typed != null && typed.ranges.isNotEmpty) {
|
||||
@@ -212,14 +323,14 @@ void mountRelayRoutes(
|
||||
token,
|
||||
MediaLog(
|
||||
method: req.method.name,
|
||||
path: '/r/$sid/$shortToken/$mediaId/${r.name}',
|
||||
path: '/r/$sid/$shortToken/$mediaId/${track == null ? '' : '$track/'}${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));
|
||||
buffer.read(token, res.start, end, onSent: (n) => session.addBytes(token, n));
|
||||
MimeType mime;
|
||||
try {
|
||||
mime = MimeType.parse(res.headers['content-type'] ?? '');
|
||||
@@ -229,7 +340,12 @@ void mountRelayRoutes(
|
||||
return Response(res.status,
|
||||
body: Body.fromDataStream(body, mimeType: mime, contentLength: end - res.start + 1),
|
||||
headers: headers);
|
||||
});
|
||||
}
|
||||
|
||||
// `:seg` is the name of a single file, the track of several (the router
|
||||
// wants one parameter name per level).
|
||||
app.get('/r/:sid/:token/:mediaId/:seg', (final req) => viewer(req, tracked: false));
|
||||
app.get('/r/:sid/:token/:mediaId/:seg/:name', (final req) => viewer(req, tracked: true));
|
||||
}
|
||||
|
||||
Response _plain(int status) => Response(status, body: Body.fromString('status $status'));
|
||||
@@ -46,16 +46,20 @@ class ServerHub {
|
||||
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);
|
||||
/// `media.relay` for a relayed swap by [token]: who serves the bytes
|
||||
/// and, from a member, its tracks (the server finds a fetched link's).
|
||||
MediaRelay relayFor(String token, MediaRelay requested) => requested.fetch
|
||||
? const MediaRelay(fetch: true)
|
||||
: MediaRelay(source: engine.members[token]?.id ?? '', tracks: requested.tracks);
|
||||
|
||||
/// The relay's channel came up or went down: `media.relay.live` follows.
|
||||
/// The relay's channel came up or went down, or a fetched link was read:
|
||||
/// `media.relay.live` and `tracks` follow.
|
||||
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));
|
||||
final was = m!.relay!;
|
||||
if (was.live == r.live && was.tracks.join(',') == r.kinds.join(',')) return;
|
||||
engine.media = m.copyWith(relay: was.copyWith(live: r.live, tracks: r.kinds));
|
||||
changed();
|
||||
}
|
||||
|
||||
@@ -243,7 +247,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,
|
||||
String Function(String sid, String token, int mediaId, String name, int tracks, String host)? relayMediaUrlBuilder,
|
||||
}) {
|
||||
app.get('/sync', (final req) {
|
||||
// Host the client dialled (Host header), so file URLs work for remote joiners.
|
||||
@@ -310,7 +314,7 @@ void mountSyncRoute(
|
||||
size: parsed.size,
|
||||
duration: parsed.duration,
|
||||
jellyfin: ref,
|
||||
relay: relayReq == null ? null : hub.relayFor(token, relayReq));
|
||||
relay: relayReq == null ? null : hub.relayFor(token, const MediaRelay()));
|
||||
if (media == null || !hub.engine.applySwap(token, media)) {
|
||||
wsErr(ws, 'bad swap media');
|
||||
return;
|
||||
@@ -321,7 +325,7 @@ void mountSyncRoute(
|
||||
duration: media.duration,
|
||||
jellyfin: ref,
|
||||
mediaId: hub.engine.mediaId);
|
||||
hub.setRelay(relayReq == null ? null : _memberRelay(hub, token, media.name));
|
||||
hub.setRelay(relayReq == null ? null : _memberRelay(hub, token, media.name, const []));
|
||||
hub.changed();
|
||||
return;
|
||||
}
|
||||
@@ -361,7 +365,7 @@ void mountSyncRoute(
|
||||
ytDlp: hub.ytDlp,
|
||||
onLiveChanged: () => hub.relayLiveChanged(active),
|
||||
onFail: (why) => wsErr(ws, "The server can't fetch this link: $why"))
|
||||
: _memberRelay(hub, token, name);
|
||||
: _memberRelay(hub, token, name, media.relay!.tracks);
|
||||
hub.setRelay(active);
|
||||
hub.changed();
|
||||
return;
|
||||
@@ -556,13 +560,17 @@ void mountSyncRoute(
|
||||
// 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).
|
||||
// A fetched link not read yet has no URL: the state's `live` opens it.
|
||||
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)
|
||||
? (relayed.known
|
||||
? relayMediaUrlBuilder(
|
||||
'default', activeToken, relayed.mediaId, relayed.name, relayed.buffers.length, dialledHost)
|
||||
: null)
|
||||
: media?.file != null
|
||||
? urlBuilder('default', activeToken, media, dialledHost)
|
||||
: media?.url,
|
||||
@@ -587,8 +595,9 @@ void mountSyncRoute(
|
||||
});
|
||||
}
|
||||
|
||||
ActiveRelay _memberRelay(ServerHub hub, String token, String name) {
|
||||
ActiveRelay _memberRelay(ServerHub hub, String token, String name, List<String> tracks) {
|
||||
late final ActiveRelay r;
|
||||
r = ActiveRelay.fromMember(hub.engine.mediaId, name, token, onLiveChanged: () => hub.relayLiveChanged(r));
|
||||
r = ActiveRelay.fromMember(hub.engine.mediaId, name, token,
|
||||
tracks: tracks, onLiveChanged: () => hub.relayLiveChanged(r));
|
||||
return r;
|
||||
}
|
||||
@@ -48,11 +48,12 @@ class SyncServer {
|
||||
/// Upstream connections of the Jellyfin relay; created on first use.
|
||||
HttpClient? _relayClient;
|
||||
|
||||
SyncServer(this.config) {
|
||||
/// [ytDlp] replaces the server's own (tests).
|
||||
SyncServer(this.config, {YtDlp? ytDlp}) {
|
||||
hub = ServerHub(sessions, config.secret,
|
||||
hostKey: config.hostKey,
|
||||
jellyfin: config.jellyfin,
|
||||
ytDlp: YtDlp(serverYtDlpDir(config.ytDlpDir), enabled: config.ytDlp));
|
||||
ytDlp: ytDlp ?? YtDlp(serverYtDlpDir(config.ytDlpDir), enabled: config.ytDlp));
|
||||
}
|
||||
|
||||
bool get isRunning => _server != null;
|
||||
@@ -103,8 +104,8 @@ class SyncServer {
|
||||
return 'http://${authority(dialledHost)}/m/$sid/$token/$name';
|
||||
}, relayUrlBuilder: (sid, token, itemId, dialledHost) =>
|
||||
'http://${authority(dialledHost)}/j/$sid/$token/${Uri.encodeComponent(itemId)}',
|
||||
relayMediaUrlBuilder: (sid, token, mediaId, name, dialledHost) =>
|
||||
'http://${authority(dialledHost)}/r/$sid/$token/$mediaId/${relayUrlName(name)}');
|
||||
relayMediaUrlBuilder: (sid, token, mediaId, name, tracks, dialledHost) =>
|
||||
relayViewerUrl('http://${authority(dialledHost)}/r/$sid/$token/$mediaId', name, tracks: tracks));
|
||||
_app.fallback = (final req) => Response.notFound(
|
||||
body: Body.fromString('not found'),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user