Protocol 14: a warm relay preloads relayed Up next items for everyone
The server keeps one small relay (32 MiB ahead, 8 behind) for Up next's first relayed item, keyed by its queue id at a URL that stays the same after the swap: fetched by the server for server-fetch links, fed by the adder's app for through-me links and files, by a member with an account for member-passed Jellyfin items. Every app preloads from it; the swap promotes it with its buffer. Dropped when the item stops being first or its adder leaves. Measured switch: ~1.6 s cold, 20-40 ms preloaded. ROADMAP gains the research item on server-side fetching of member-resolved URLs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e52a7633a0
commit
d8585fb78e
17 files changed
+820
-150
No files matched your search
@@ -167,9 +167,12 @@ or an episode on the Schedule for **Add to queue** / **Play next**; the
|
|||||||
Jellyfin details panel has a queue button (right-click it to play next),
|
Jellyfin details panel has a queue button (right-click it to play next),
|
||||||
and the card for a dropped or pasted video or link has both. A link is
|
and the card for a dropped or pasted video or link has both. A link is
|
||||||
checked first, like Put it up. Every app keeps the next item ready,
|
checked first, like Put it up. Every app keeps the next item ready,
|
||||||
its first seconds already loaded, so skipping to it starts at once
|
its first seconds already loaded, so skipping to it starts at once.
|
||||||
(not yet for a link passed on, someone else's file or one the server
|
For a link passed on, a member's file or a link the server fetches,
|
||||||
fetches: those load when they go up). Everyone shares one Up next and anyone can change it; what
|
the server starts getting the next item while the current one plays
|
||||||
|
(from the app of whoever added it, or by fetching the link itself), so
|
||||||
|
everyone has its start before it goes up; whoever added it keeps the
|
||||||
|
app open for that. Everyone shares one Up next and anyone can change it; what
|
||||||
you added stays when you leave.
|
you added stays when you leave.
|
||||||
|
|
||||||
In the player, Up next sits in the top bar next to the chat button: the
|
In the player, Up next sits in the top bar next to the chat button: the
|
||||||
|
|||||||
@@ -525,28 +525,35 @@ class QueueController {
|
|||||||
if (head != null) {
|
if (head != null) {
|
||||||
final performer = queuePerformer(head, r.members).id == _me;
|
final performer = queuePerformer(head, r.members).id == _me;
|
||||||
final local = _local[head.key]?.pick;
|
final local = _local[head.key]?.pick;
|
||||||
|
// The server's warm relay of the item, once bytes can flow: what
|
||||||
|
// everyone without their own copy preloads.
|
||||||
|
final relay = head.media.relay;
|
||||||
|
final warmUrl = relay != null && relay.live ? coord.warmRelayUrl(head, relay) : null;
|
||||||
switch (head.how) {
|
switch (head.how) {
|
||||||
case QueueHow.jellyfin:
|
case QueueHow.jellyfin:
|
||||||
final ref = head.media.jellyfin;
|
final ref = head.media.jellyfin;
|
||||||
if (ref == null) return null;
|
if (ref == null) return null;
|
||||||
if (_ownAccount(ref.serverId)) return _WarmTarget(head.id, ref: ref);
|
if (_ownAccount(ref.serverId)) return _WarmTarget(head.id, ref: ref);
|
||||||
// No account here: the server's login passes it on (`/j`).
|
// No account here: the server's login passes it on (`/j`), else
|
||||||
final relay = r.relayNext ? coord.jellyfinRelayUrl(ref) : null;
|
// the warm relay a member with an account feeds.
|
||||||
return relay == null ? null : _WarmTarget(head.id, open: relay);
|
final open = r.relayNext ? coord.jellyfinRelayUrl(ref) : warmUrl;
|
||||||
|
return open == null ? null : _WarmTarget(head.id, open: open);
|
||||||
case QueueHow.direct:
|
case QueueHow.direct:
|
||||||
final url = head.media.url;
|
final url = head.media.url;
|
||||||
if (url == null) return null;
|
if (url == null) return null;
|
||||||
return _WarmTarget(head.id, probe: performer ? url : null, open: url);
|
return _WarmTarget(head.id, probe: performer ? url : null, open: url);
|
||||||
case QueueHow.through:
|
case QueueHow.through:
|
||||||
// Only its adder has the link; the others' relay starts with the swap.
|
// Only its adder has the link (and feeds the warm relay).
|
||||||
if (!performer) return null;
|
if (!performer) return warmUrl == null ? null : _WarmTarget(head.id, open: warmUrl);
|
||||||
final url = local is LinkMedia ? local.url : null;
|
final url = local is LinkMedia ? local.url : null;
|
||||||
return _WarmTarget(head.id, probe: url, open: url);
|
return _WarmTarget(head.id, probe: url, open: url);
|
||||||
case QueueHow.file:
|
case QueueHow.file:
|
||||||
if (!performer) return null;
|
if (!performer) return warmUrl == null ? null : _WarmTarget(head.id, open: warmUrl);
|
||||||
// The host's own file plays from its `/m` URL once it is up.
|
// The host's own file plays from its `/m` URL once it is up.
|
||||||
return _WarmTarget(head.id, open: local is FileMedia && coord.hostFile == null ? local.path : null);
|
return _WarmTarget(head.id, open: local is FileMedia && coord.hostFile == null ? local.path : null);
|
||||||
case QueueHow.server:
|
case QueueHow.server:
|
||||||
|
// The server fetches it into its warm relay; everyone preloads that.
|
||||||
|
if (warmUrl != null) return _WarmTarget(head.id, open: warmUrl);
|
||||||
return performer ? _WarmTarget(head.id) : null;
|
return performer ? _WarmTarget(head.id) : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -564,6 +571,7 @@ class QueueController {
|
|||||||
/// playing has started. Done as soon as it is first, again before that
|
/// playing has started. Done as soon as it is first, again before that
|
||||||
/// runs out, dropped when it is no longer first.
|
/// runs out, dropped when it is no longer first.
|
||||||
void _warm(StateBroadcast? r) {
|
void _warm(StateBroadcast? r) {
|
||||||
|
_feedWarm(r);
|
||||||
final t = _warmTarget(r);
|
final t = _warmTarget(r);
|
||||||
final key = t?.key ?? '';
|
final key = t?.key ?? '';
|
||||||
if (key != _warmKey) {
|
if (key != _warmKey) {
|
||||||
@@ -581,6 +589,59 @@ class QueueController {
|
|||||||
_syncPreload(r);
|
_syncPreload(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The warm relay this app feeds (`<queue id>|<token>`), started once.
|
||||||
|
String _fedWarm = '';
|
||||||
|
|
||||||
|
/// Feeds the server's warm relay of Up next's first item from here when
|
||||||
|
/// it names this member as its source (a link passed on, a file), or a
|
||||||
|
/// Jellyfin item this app puts up with its own account when the server
|
||||||
|
/// asks for a source; see [SessionCoordinator.feedWarm]. The server
|
||||||
|
/// drops the relay (and with it the source) once the item is no longer
|
||||||
|
/// first.
|
||||||
|
void _feedWarm(StateBroadcast? r) {
|
||||||
|
final head = r?.queue.firstOrNull;
|
||||||
|
final relay = head?.media.relay;
|
||||||
|
if (r == null || head == null || relay == null || relay.fetch) return;
|
||||||
|
final key = '${head.id}|${coord.sync.token}';
|
||||||
|
if (key == _fedWarm) return;
|
||||||
|
switch (head.how) {
|
||||||
|
case QueueHow.through:
|
||||||
|
case QueueHow.file:
|
||||||
|
if (relay.source != _me) return;
|
||||||
|
final pick = _local[head.key]?.pick;
|
||||||
|
if (pick is FileMedia) {
|
||||||
|
_fedWarm = key;
|
||||||
|
coord.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
|
||||||
|
} else if (pick is LinkMedia) {
|
||||||
|
_fedWarm = key;
|
||||||
|
unawaited(() async {
|
||||||
|
try {
|
||||||
|
// The probe [_resolveWarm] keeps: no second yt-dlp run.
|
||||||
|
final probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60));
|
||||||
|
final y = await coord.ytDlp.ytDlp;
|
||||||
|
if (_fedWarm != key) return;
|
||||||
|
coord.feedWarm(head.id, LinkRelayMedia(Uri.parse(pick.url), ytDlp: y, probe: probe));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('queue: feeding ${head.id}: $e');
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
case QueueHow.jellyfin:
|
||||||
|
final ref = head.media.jellyfin;
|
||||||
|
if (ref == null || (relay.source.isNotEmpty && relay.source != _me)) return;
|
||||||
|
if (queuePerformer(head, r.members).id != _me) return;
|
||||||
|
// Resolved with this app's account by [_resolveWarm].
|
||||||
|
final w = coord.warmJellyfin;
|
||||||
|
if (w == null || !sameJellyfinItem(w.ref, ref)) return;
|
||||||
|
_fedWarm = key;
|
||||||
|
coord.feedWarm(
|
||||||
|
head.id, RelayMedia.single(HttpRelayBytes(Uri.parse(w.plan.url), headers: w.plan.headers)));
|
||||||
|
case QueueHow.direct:
|
||||||
|
case QueueHow.server:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _dropWarm(StateBroadcast? r) {
|
void _dropWarm(StateBroadcast? r) {
|
||||||
_warmTimer?.cancel();
|
_warmTimer?.cancel();
|
||||||
_warmTimer = null;
|
_warmTimer = null;
|
||||||
@@ -613,6 +674,7 @@ class QueueController {
|
|||||||
if (plan is OwnPlay) {
|
if (plan is OwnPlay) {
|
||||||
coord.warmJellyfin = (ref: ref, plan: plan, until: DateTime.now().add(_warmKeep));
|
coord.warmJellyfin = (ref: ref, plan: plan, until: DateTime.now().add(_warmKeep));
|
||||||
open = (url: plan.url, headers: plan.headers);
|
open = (url: plan.url, headers: plan.headers);
|
||||||
|
_feedWarm(coord.serverState.peek());
|
||||||
}
|
}
|
||||||
} else if (url != null && ok) {
|
} else if (url != null && ok) {
|
||||||
open = (url: url, headers: null);
|
open = (url: url, headers: null);
|
||||||
|
|||||||
@@ -680,13 +680,62 @@ class SessionCoordinator {
|
|||||||
|
|
||||||
/// This viewer's `/r/` URL; with tracks, a pieces track's playlist, or
|
/// This viewer's `/r/` URL; with tracks, a pieces track's playlist, or
|
||||||
/// an mpv EDL of every track when there are several (a page's separate
|
/// an mpv EDL of every track when there are several (a page's separate
|
||||||
/// video and audio).
|
/// video and audio). A relay started in Up next ([queueId]) keeps its
|
||||||
String _relayMediaUrlFor(String name, int mediaId, [List<RelayTrackSpec> tracks = const []]) =>
|
/// queue id in the URL, so the preload of its warm relay matches.
|
||||||
relayViewerUrl('http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId', name, tracks: tracks);
|
String _relayMediaUrlFor(String name, int mediaId,
|
||||||
|
[List<RelayTrackSpec> tracks = const [], String queueId = '']) =>
|
||||||
|
relayViewerUrl('http://$_wsHost:$_wsPort/r/default/${sync.token}/${queueId.isEmpty ? mediaId : queueId}', name,
|
||||||
|
tracks: tracks);
|
||||||
|
|
||||||
|
/// This viewer's URL of the warm relay of Up next's first [item]
|
||||||
|
/// (`queue[0].media.relay`, see ARCHITECTURE "Getting ready"): the same
|
||||||
|
/// URL the media has once the item goes up, so the preload is promoted.
|
||||||
|
String warmRelayUrl(QueueItem item, MediaRelay relay) =>
|
||||||
|
_relayMediaUrlFor(item.media.name.isEmpty ? item.title : item.media.name, -1, relay.tracks, item.id);
|
||||||
|
|
||||||
|
// This member feeds the warm relay of Up next item `queueId` (its
|
||||||
|
// link, file or Jellyfin stream) before the item goes up; the swap
|
||||||
|
// that puts it up takes the source over.
|
||||||
|
({String queueId, RelaySource src})? _warmSource;
|
||||||
|
|
||||||
|
/// Feeds the warm relay of Up next item [queueId] from here with
|
||||||
|
/// [media]; one already feeding it is kept. The server pulls only while
|
||||||
|
/// someone preloads it, and closes the channel once the item is no
|
||||||
|
/// longer first (the source then stops).
|
||||||
|
void feedWarm(String queueId, RelayMedia media) {
|
||||||
|
if (_warmSource?.queueId == queueId) {
|
||||||
|
unawaited(media.close());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopWarmSource();
|
||||||
|
final src = RelaySource(
|
||||||
|
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${sync.token}/source?q=${Uri.encodeQueryComponent(queueId)}'),
|
||||||
|
media);
|
||||||
|
_warmSource = (queueId: queueId, src: src);
|
||||||
|
unawaited(() async {
|
||||||
|
try {
|
||||||
|
await src.start();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('warm relay source of $queueId failed: $e');
|
||||||
|
if (identical(_warmSource?.src, src)) _warmSource = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await src.done;
|
||||||
|
if (identical(_warmSource?.src, src)) _warmSource = null;
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopWarmSource() {
|
||||||
|
final w = _warmSource;
|
||||||
|
_warmSource = null;
|
||||||
|
if (w != null) unawaited(w.src.close());
|
||||||
|
}
|
||||||
|
|
||||||
/// Serves [media] for media [mediaId] on this member's relay channel.
|
/// Serves [media] for media [mediaId] on this member's relay channel.
|
||||||
/// The server only pulls while someone watches through it.
|
/// The server only pulls while someone watches through it. When the
|
||||||
void _startRelaySource(int mediaId, RelayMedia media, {bool announce = false}) {
|
/// media came from Up next item [queueId] whose warm relay this member
|
||||||
|
/// feeds, that source carries on instead.
|
||||||
|
void _startRelaySource(int mediaId, RelayMedia media, {bool announce = false, String queueId = ''}) {
|
||||||
// Back after a reconnect on the same media: it is still serving (and
|
// Back after a reconnect on the same media: it is still serving (and
|
||||||
// redials by itself).
|
// redials by itself).
|
||||||
if (_relaySource != null && _relaySourceMediaId == mediaId) {
|
if (_relaySource != null && _relaySourceMediaId == mediaId) {
|
||||||
@@ -694,6 +743,24 @@ class SessionCoordinator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_stopRelaySource();
|
_stopRelaySource();
|
||||||
|
final warm = _warmSource;
|
||||||
|
if (queueId.isNotEmpty && warm != null && warm.queueId == queueId) {
|
||||||
|
_warmSource = null;
|
||||||
|
unawaited(media.close());
|
||||||
|
_relaySource = warm.src;
|
||||||
|
_relaySourceMediaId = mediaId;
|
||||||
|
relaying.value = true;
|
||||||
|
if (announce) {
|
||||||
|
_pushToast('Passing the video on through the server. Keep the app open while it plays.');
|
||||||
|
}
|
||||||
|
unawaited(warm.src.done.then((_) {
|
||||||
|
if (identical(_relaySource, warm.src)) {
|
||||||
|
_relaySource = null;
|
||||||
|
relaying.value = false;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
final src = RelaySource(
|
final src = RelaySource(
|
||||||
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${sync.token}/source'), media);
|
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${sync.token}/source'), media);
|
||||||
_relaySource = src;
|
_relaySource = src;
|
||||||
@@ -811,15 +878,17 @@ class SessionCoordinator {
|
|||||||
if (ref != null) {
|
if (ref != null) {
|
||||||
await _playJellyfin(ref, r,
|
await _playJellyfin(ref, r,
|
||||||
origin: origin is _JellyfinOrigin ? origin : null,
|
origin: origin is _JellyfinOrigin ? origin : null,
|
||||||
memberRelayUrl: relay != null && !mine ? _relayMediaUrlFor(name, r.mediaId) : null);
|
memberRelayUrl:
|
||||||
|
relay != null && !mine ? _relayMediaUrlFor(name, r.mediaId, const [], relay.queueId) : null);
|
||||||
} else if (origin is _FileOrigin) {
|
} else if (origin is _FileOrigin) {
|
||||||
await _openMedia(origin.path);
|
await _openMedia(origin.path);
|
||||||
_startRelaySource(r.mediaId, RelayMedia.single(FileRelayBytes(File(origin.path))), announce: true);
|
_startRelaySource(r.mediaId, RelayMedia.single(FileRelayBytes(File(origin.path))),
|
||||||
|
announce: true, queueId: relay?.queueId ?? '');
|
||||||
} else if (origin is _LinkOrigin) {
|
} else if (origin is _LinkOrigin) {
|
||||||
await _openMedia(origin.url);
|
await _openMedia(origin.url);
|
||||||
_startRelaySource(
|
_startRelaySource(
|
||||||
r.mediaId, LinkRelayMedia(Uri.parse(origin.url), ytDlp: origin.ytDlp, probe: origin.probe),
|
r.mediaId, LinkRelayMedia(Uri.parse(origin.url), ytDlp: origin.ytDlp, probe: origin.probe),
|
||||||
announce: true);
|
announce: true, queueId: relay?.queueId ?? '');
|
||||||
} else if (relay != null && !mine) {
|
} else if (relay != null && !mine) {
|
||||||
if (relay.fetch && !relay.live) {
|
if (relay.fetch && !relay.live) {
|
||||||
// The server is still getting it (yt-dlp takes seconds; mpv
|
// The server is still getting it (yt-dlp takes seconds; mpv
|
||||||
@@ -828,7 +897,7 @@ class SessionCoordinator {
|
|||||||
_awaitLiveFor = r.mediaId;
|
_awaitLiveFor = r.mediaId;
|
||||||
health.value = const HealthStatus(HealthSeverity.info, 'the server is getting the video…');
|
health.value = const HealthStatus(HealthSeverity.info, 'the server is getting the video…');
|
||||||
} else {
|
} else {
|
||||||
await _openMedia(_relayMediaUrlFor(name, r.mediaId, relay.tracks));
|
await _openMedia(_relayMediaUrlFor(name, r.mediaId, relay.tracks, relay.queueId));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var url = stateMediaUrl(r);
|
var url = stateMediaUrl(r);
|
||||||
@@ -848,8 +917,9 @@ class SessionCoordinator {
|
|||||||
if (_mediaId == r.mediaId) settledMediaId.value = r.mediaId;
|
if (_mediaId == r.mediaId) settledMediaId.value = r.mediaId;
|
||||||
} else if (_awaitLiveFor == r.mediaId && (stateMediaRelay(r)?.live ?? false)) {
|
} else if (_awaitLiveFor == r.mediaId && (stateMediaRelay(r)?.live ?? false)) {
|
||||||
_awaitLiveFor = -1;
|
_awaitLiveFor = -1;
|
||||||
|
final relay = stateMediaRelay(r)!;
|
||||||
await _openMedia(
|
await _openMedia(
|
||||||
_relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId, stateMediaRelay(r)!.tracks));
|
_relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId, relay.tracks, relay.queueId));
|
||||||
}
|
}
|
||||||
_jellyfinTransport(r);
|
_jellyfinTransport(r);
|
||||||
_relayStatus(r);
|
_relayStatus(r);
|
||||||
@@ -1469,11 +1539,14 @@ class SessionCoordinator {
|
|||||||
final relayProblem = probe.relayProblem;
|
final relayProblem = probe.relayProblem;
|
||||||
if (relayProblem != null) return relayProblem;
|
if (relayProblem != null) return relayProblem;
|
||||||
final y = await ytDlp.ytDlp;
|
final y = await ytDlp.ytDlp;
|
||||||
final check = LinkRelayMedia(uri, ytDlp: y, probe: probe);
|
// Up next's item this app already feeds (its warm relay): read.
|
||||||
|
final warm = _warmSource;
|
||||||
|
final fed = queueId != null && warm != null && warm.queueId == queueId && warm.src.tracks.isNotEmpty;
|
||||||
|
final check = fed ? null : LinkRelayMedia(uri, ytDlp: y, probe: probe);
|
||||||
var size = 0;
|
var size = 0;
|
||||||
final List<RelayTrackSpec> specs;
|
final List<RelayTrackSpec> specs;
|
||||||
try {
|
try {
|
||||||
final tracks = await check.open().timeout(const Duration(seconds: 15));
|
final tracks = fed ? warm.src.tracks : await check!.open().timeout(const Duration(seconds: 15));
|
||||||
for (final t in tracks) {
|
for (final t in tracks) {
|
||||||
// A playlist is read (DRM and live refused) but has no size.
|
// A playlist is read (DRM and live refused) but has no size.
|
||||||
final pieces = t.pieces;
|
final pieces = t.pieces;
|
||||||
@@ -1487,7 +1560,7 @@ class SessionCoordinator {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return 'cannot pass this link on: $e';
|
return 'cannot pass this link on: $e';
|
||||||
} finally {
|
} finally {
|
||||||
await check.close();
|
await check?.close();
|
||||||
}
|
}
|
||||||
_pendingOrigin = _LinkOrigin(url, probe, y);
|
_pendingOrigin = _LinkOrigin(url, probe, y);
|
||||||
_swap(HelloMedia(
|
_swap(HelloMedia(
|
||||||
@@ -1717,7 +1790,8 @@ class SessionCoordinator {
|
|||||||
// its own sign-in, else the pasted link (played directly here too).
|
// its own sign-in, else the pasted link (played directly here too).
|
||||||
if (origin != null) {
|
if (origin != null) {
|
||||||
if (plan is OwnPlay) {
|
if (plan is OwnPlay) {
|
||||||
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers)));
|
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers)),
|
||||||
|
queueId: stateMediaRelay(r)?.queueId ?? '');
|
||||||
} else if (origin.link != null) {
|
} else if (origin.link != null) {
|
||||||
plan = RelayPlay(origin.link!);
|
plan = RelayPlay(origin.link!);
|
||||||
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!))));
|
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!))));
|
||||||
@@ -2100,6 +2174,7 @@ class SessionCoordinator {
|
|||||||
reconnecting.value = false;
|
reconnecting.value = false;
|
||||||
await _stopJellyfin();
|
await _stopJellyfin();
|
||||||
_stopRelaySource();
|
_stopRelaySource();
|
||||||
|
stopWarmSource();
|
||||||
_pendingOrigin = null;
|
_pendingOrigin = null;
|
||||||
_pendingArt = null;
|
_pendingArt = null;
|
||||||
_activeOrigin = null;
|
_activeOrigin = null;
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import 'package:watch_together/src/session/switch_trace.dart';
|
|||||||
// How long an Up next switch takes, end to end, over real mpv and a real
|
// How long an Up next switch takes, end to end, over real mpv and a real
|
||||||
// sync server: two Jellyfin account holders and a guest (through the
|
// sync server: two Jellyfin account holders and a guest (through the
|
||||||
// server's /j login) watch an episode, then ⏭ puts up the next episode,
|
// server's /j login) watch an episode, then ⏭ puts up the next episode,
|
||||||
// then a direct link. The fake Jellyfin and the link's host answer with a
|
// then a direct link, then a link passed on "through" alice, then one the
|
||||||
|
// server fetches (both relayed: the guests preload them from the server's
|
||||||
|
// warm relay of the item). The fake Jellyfin and the link's host answer with a
|
||||||
// delay and a capped rate, like a server elsewhere. Prints each client's
|
// delay and a capped rate, like a server elsewhere. Prints each client's
|
||||||
// phases (SwitchTrace) and the wall time from ⏭ to the server playing.
|
// phases (SwitchTrace) and the wall time from ⏭ to the server playing.
|
||||||
//
|
//
|
||||||
@@ -206,7 +208,7 @@ void main() {
|
|||||||
final link = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
final link = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
link.listen((req) => _serveFile(req, file));
|
link.listen((req) => _serveFile(req, file));
|
||||||
final linkUrl = 'http://127.0.0.1:${link.port}/clip.mkv';
|
final linkUrl = 'http://127.0.0.1:${link.port}/clip.mkv';
|
||||||
final server = SyncServer(AppConfig(
|
final server = SyncServer(ytDlp: YtDlp('$_tmp/queue_switch_server_ytdlp', enabled: false), AppConfig(
|
||||||
port: 0,
|
port: 0,
|
||||||
secret: 'q',
|
secret: 'q',
|
||||||
name: 'q',
|
name: 'q',
|
||||||
@@ -219,7 +221,14 @@ void main() {
|
|||||||
deviceId: 'host-1',
|
deviceId: 'host-1',
|
||||||
version: '9.9.9'),
|
version: '9.9.9'),
|
||||||
));
|
));
|
||||||
await server.start();
|
// Relic logs errors writing a response (a relay response cut short
|
||||||
|
// when its relay is dropped at a swap or stop) as it does outside
|
||||||
|
// tests; anything else fails the test.
|
||||||
|
final serverErrors = <Object>[];
|
||||||
|
await runZonedGuarded(server.start, (e, _) {
|
||||||
|
if ('$e'.contains('Content size below specified contentLength') || '$e'.contains('did not arrive')) return;
|
||||||
|
serverErrors.add(e);
|
||||||
|
});
|
||||||
final ytDlp = AppYtDlp(YtDlp('$_tmp/queue_switch_ytdlp', enabled: false));
|
final ytDlp = AppYtDlp(YtDlp('$_tmp/queue_switch_ytdlp', enabled: false));
|
||||||
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
|
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
|
||||||
final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC');
|
final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC');
|
||||||
@@ -297,8 +306,30 @@ void main() {
|
|||||||
}
|
}
|
||||||
await switchOnce('direct link');
|
await switchOnce('direct link');
|
||||||
expect(stateMediaUrl(a.serverState.peek()!), linkUrl);
|
expect(stateMediaUrl(a.serverState.peek()!), linkUrl);
|
||||||
|
|
||||||
|
// Relayed items: alice passes the link on, then the server fetches
|
||||||
|
// it. Each is fed into the server's warm relay while it waits, so
|
||||||
|
// bob and carol (and for the fetched one alice) preload from it,
|
||||||
|
// and the swap promotes that relay with its buffer.
|
||||||
|
for (final (what, mode) in [('link through alice', LinkRelay.me), ('server-fetched link', LinkRelay.server)]) {
|
||||||
|
expect(await a.queue.add(LinkMedia(linkUrl, relay: mode)), isNull);
|
||||||
|
await _poll(() => a.serverState.peek()?.queue.isNotEmpty ?? false, '$what queued');
|
||||||
|
final id = a.serverState.peek()!.queue.first.id;
|
||||||
|
await _poll(() => a.serverState.peek()?.queue.firstOrNull?.media.relay?.live ?? false, '$what warm relay live');
|
||||||
|
await switchOnce(what);
|
||||||
|
final relay = stateMediaRelay(a.serverState.peek()!);
|
||||||
|
expect(relay?.queueId, id, reason: 'the warm relay became the media\'s');
|
||||||
|
expect(relay?.fetch, mode == LinkRelay.server);
|
||||||
|
if (preload) {
|
||||||
|
for (final x in all.values) {
|
||||||
|
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded),
|
||||||
|
reason: '$what preloaded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print(report.join('\n'));
|
print(report.join('\n'));
|
||||||
|
expect(serverErrors, isEmpty);
|
||||||
|
|
||||||
// Nothing queued: no preload. Queued and taken off again: dropped.
|
// Nothing queued: no preload. Queued and taken off again: dropped.
|
||||||
if (preload) {
|
if (preload) {
|
||||||
|
|||||||
+39
-3
@@ -595,6 +595,32 @@ How bytes move:
|
|||||||
- Cleanup: the buffer is memory only. Any swap disposes it (its URLs then
|
- Cleanup: the buffer is memory only. Any swap disposes it (its URLs then
|
||||||
404, the source is closed with 4001 and stops), and so does server
|
404, the source is closed with 4001 and stops), and so does server
|
||||||
stop. A crash frees it with the process.
|
stop. A crash frees it with the process.
|
||||||
|
- Warm relay (`ServerHub.syncWarm`, run on every change; wire in
|
||||||
|
[PROTOCOL.md](PROTOCOL.md) "Relay"): a second, small `ActiveRelay` for
|
||||||
|
Up next's first item while it waits, keyed by its queue id, so viewers
|
||||||
|
preload it (see "Getting ready" under "Up next"). A server-fetched
|
||||||
|
link starts its fetch then; a link passed on or a file is fed by its
|
||||||
|
adder's app (a second channel, `/r/.../source?q=<queueId>`), only
|
||||||
|
while someone else is in the server and not for the host's own file;
|
||||||
|
a Jellyfin item someone present cannot play (and the server's login
|
||||||
|
does not cover) by the first member with an account that dials in.
|
||||||
|
Its budget is 32 MiB ahead and 8 MiB behind its viewers
|
||||||
|
(`relayWarmAheadBytes`, split by track size with the usual 8 MiB
|
||||||
|
floor) plus the pins: about what a preload player reads, so the
|
||||||
|
server holds at most that much on top of the current relay (there is
|
||||||
|
no global relay budget; each relay is bounded by its own). Like any
|
||||||
|
buffer it pulls nothing while nobody preloads. The swap that puts the
|
||||||
|
item up (`swap.queueId`, from its source, or anyone for a fetch, with
|
||||||
|
the same tracks) promotes it (`ServerHub.takeWarm`): the same buffer,
|
||||||
|
feed and source channel become the media's relay with the full
|
||||||
|
budget, `media.relay.queueId` names the item and the media keeps the
|
||||||
|
item's name, so its `/r/.../<queueId>/...` URLs (and the preloads of
|
||||||
|
them) stay valid. Any other swap of it, the item no longer first (a
|
||||||
|
removal, a reorder, Play now of another), its adder leaving, or server
|
||||||
|
stop drops it (its URLs 404, its source gets 4001); a restart starts
|
||||||
|
with none. A manual swap to something else keeps it (the item is
|
||||||
|
still first). A failed warm fetch is not promoted: the swap fetches
|
||||||
|
again and reports the reason.
|
||||||
- Source away: viewers keep playing from the buffer; a request waits up
|
- Source away: viewers keep playing from the buffer; a request waits up
|
||||||
to 60 s for missing bytes. The source reconnects with backoff (same
|
to 60 s for missing bytes. The source reconnects with backoff (same
|
||||||
token after a resume); a different size (or piece count and roles)
|
token after a resume); a different size (or piece count and roles)
|
||||||
@@ -917,10 +943,17 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
|
|||||||
"Jellyfin relay"). A link everyone opens is that link; the app that
|
"Jellyfin relay"). A link everyone opens is that link; the app that
|
||||||
puts it up also probes it (`AppYtDlp.probe` with `keep`: kept 35
|
puts it up also probes it (`AppYtDlp.probe` with `keep`: kept 35
|
||||||
minutes instead of 10, a refresh replaces it only once it is in; swaps
|
minutes instead of 10, a refresh replaces it only once it is in; swaps
|
||||||
read it from there). A link passed on "through me" and a file only on
|
read it from there). A link passed on "through me" and a file on
|
||||||
their adder's app (the link, the path; not the host's own file, which
|
their adder's app (the link, the path; not the host's own file, which
|
||||||
plays from `/m`). A server-fetched link and others' relayed items have
|
plays from `/m`), which also feeds the server's warm relay of it
|
||||||
nothing to get ready (the relay starts with the swap; see ROADMAP). It
|
(`QueueController._feedWarm`, `SessionCoordinator.feedWarm`; a
|
||||||
|
Jellyfin item's from the member who puts it up, with its resolved
|
||||||
|
stream, when the server asks for one); everyone else, and everyone
|
||||||
|
for a server-fetched link, preloads that warm relay's URL
|
||||||
|
(`SessionCoordinator.warmRelayUrl`) once `queue[0].media.relay.live`
|
||||||
|
says bytes can flow (see "Relay"). At the swap the adder's app skips
|
||||||
|
re-reading the link's sizes (its warm source has them) and that
|
||||||
|
source carries on as the media's. It
|
||||||
is resolved as soon as it is first there, again every 30 minutes, and
|
is resolved as soon as it is first there, again every 30 minutes, and
|
||||||
dropped when it stops being first.
|
dropped when it stops being first.
|
||||||
**Preload**: once resolved, the stream is opened in a second, hidden
|
**Preload**: once resolved, the stream is opened in a second, hidden
|
||||||
@@ -952,6 +985,9 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
|
|||||||
account holders and a `/j` guest): ⏭ to the server playing 59 to
|
account holders and a `/j` guest): ⏭ to the server playing 59 to
|
||||||
86 ms for the next episode and 35 ms for a direct link, against about
|
86 ms for the next episode and 35 ms for a direct link, against about
|
||||||
1.5 s cold; at 300 ms and 12 Mbit/s 52 and 103 ms against about 2.9 s.
|
1.5 s cold; at 300 ms and 12 Mbit/s 52 and 103 ms against about 2.9 s.
|
||||||
|
Relayed, from the warm relay (same setup, loopback server): a link
|
||||||
|
passed on 21 to 22 ms, a server-fetched link 35 to 37 ms, against
|
||||||
|
about 1.6 s cold.
|
||||||
`SwitchTrace` (env `SYNC_RELAY_TRACE_SWITCH=1` prints it) times each
|
`SwitchTrace` (env `SYNC_RELAY_TRACE_SWITCH=1` prints it) times each
|
||||||
phase of a media change: state, resolved, open, player (a fresh
|
phase of a media change: state, resolved, open, player (a fresh
|
||||||
player is up; see "Player lifecycle" under "Media"), opened or
|
player is up; see "Player lifecycle" under "Media"), opened or
|
||||||
|
|||||||
+7
-1
@@ -56,7 +56,8 @@ and `e2e_check` (simulated viewers against a live server).
|
|||||||
`seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `jellyfin_e2e` (a
|
`seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `jellyfin_e2e` (a
|
||||||
local fake Jellyfin over HTTP; tests never talk to a real Jellyfin),
|
local fake Jellyfin over HTTP; tests never talk to a real Jellyfin),
|
||||||
`queue_switch_e2e` (times Up next switches against a delayed, rate-capped
|
`queue_switch_e2e` (times Up next switches against a delayed, rate-capped
|
||||||
fake Jellyfin and link host; `WT_SWITCH_PRELOAD=0` for the cold path,
|
fake Jellyfin and link host: an episode, a direct link, a link passed
|
||||||
|
on and a server-fetched one; `WT_SWITCH_PRELOAD=0` for the cold path,
|
||||||
`WT_SWITCH_DELAY_MS` / `WT_SWITCH_RATE_KBS` for the link). The sync and
|
`WT_SWITCH_DELAY_MS` / `WT_SWITCH_RATE_KBS` for the link). The sync and
|
||||||
end-of-file ones expect `~/Videos/chiyo.mp4`; others generate media with
|
end-of-file ones expect `~/Videos/chiyo.mp4`; others generate media with
|
||||||
ffmpeg into `WT_TEST_TMP`. Headless mpv (no video output attached) won't
|
ffmpeg into `WT_TEST_TMP`. Headless mpv (no video output attached) won't
|
||||||
@@ -77,6 +78,11 @@ Traps:
|
|||||||
the `flutter_tester` by PID). `AppShell(videoBuilder:)` takes a stand-in,
|
the `flutter_tester` by PID). `AppShell(videoBuilder:)` takes a stand-in,
|
||||||
so shell tests run the real player layout without it; otherwise test pure
|
so shell tests run the real player layout without it; otherwise test pure
|
||||||
logic (`StateDiff`, coordinator without widgets).
|
logic (`StateDiff`, coordinator without widgets).
|
||||||
|
- A `SyncServer` started inside a test: relic reports an error writing a
|
||||||
|
response (a relay response cut short when its relay is dropped at a
|
||||||
|
swap or stop) to the zone the server started in, which fails the
|
||||||
|
test; outside tests it is only logged. Start it in `runZonedGuarded`
|
||||||
|
and fail on anything else (`queue_switch_e2e`).
|
||||||
- Never depend on a server-side WebSocket `onDone`; assert presence with
|
- Never depend on a server-side WebSocket `onDone`; assert presence with
|
||||||
deadline polling.
|
deadline polling.
|
||||||
- `sub.asFuture()` can resolve before `onDone`; poll with a deadline.
|
- `sub.asFuture()` can resolve before `onDone`; poll with a deadline.
|
||||||
|
|||||||
+38
-14
@@ -1,4 +1,4 @@
|
|||||||
# Protocol (proto = 13)
|
# Protocol (proto = 14)
|
||||||
|
|
||||||
Wire format only. Behavior (gate, clock, timeouts) is in
|
Wire format only. Behavior (gate, clock, timeouts) is in
|
||||||
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
|
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
|
||||||
@@ -31,7 +31,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
|||||||
## Client to server
|
## Client to server
|
||||||
|
|
||||||
```
|
```
|
||||||
{"t":"hello","proto":13,"secret":"","user":{"name":"","avatar":""},
|
{"t":"hello","proto":14,"secret":"","user":{"name":"","avatar":""},
|
||||||
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
|
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
|
||||||
"resume":"<token>"}
|
"resume":"<token>"}
|
||||||
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
|
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
|
||||||
@@ -275,6 +275,9 @@ swap media.relay = {} the swapper's machine serves the bytes
|
|||||||
state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true}
|
state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true}
|
||||||
media.relay = {"fetch":true,"live":true,"tracks":[{"kind":"video"},{"kind":"audio"}]}
|
media.relay = {"fetch":true,"live":true,"tracks":[{"kind":"video"},{"kind":"audio"}]}
|
||||||
media.relay = {"source":"m2","live":true,"rate":4100000,"need":2300000}
|
media.relay = {"source":"m2","live":true,"rate":4100000,"need":2300000}
|
||||||
|
media.relay = {"source":"m2","live":true,"queueId":"q3"} started in Up next (proto 14)
|
||||||
|
queue[0].media.relay = {"source":"m2","live":true,"tracks":[...]} its warm relay
|
||||||
|
queue[0].media.relay = {"fetch":true,"live":true}
|
||||||
```
|
```
|
||||||
|
|
||||||
Tracks: a relayed media is one file, or tracks that viewers play as one
|
Tracks: a relayed media is one file, or tracks that viewers play as one
|
||||||
@@ -313,37 +316,57 @@ anything else means one file.
|
|||||||
its segments in so far, after a minute of them). Both two significant
|
its segments in so far, after a minute of them). Both two significant
|
||||||
digits, updated once a second. `rate` under `need` means the source
|
digits, updated once a second. `rate` under `need` means the source
|
||||||
cannot keep up; clients tell the source once per media.
|
cannot keep up; clients tell the source once per media.
|
||||||
|
- Warm relay (proto 14): while Up next's first item is relayed (`how`
|
||||||
|
`server`, `through` or `file`, or `jellyfin` when someone present has
|
||||||
|
no access to that server and `state.relayNext` is false), the server
|
||||||
|
holds a small relay for it and shows it as that item's
|
||||||
|
`media.relay`: `source` (the adder; for `jellyfin` empty until the
|
||||||
|
first member with `jf` true for that server dials in), or `fetch`, and
|
||||||
|
`live` with `tracks` once bytes can flow. Its URLs use the item's
|
||||||
|
queue id where a media id would be. Not for the host's own file, nor
|
||||||
|
for an adder alone in the server. A swap with `queueId` of that item,
|
||||||
|
from its source (or, `fetch`, from anyone) with the same `tracks`,
|
||||||
|
makes it the media's relay: `media.relay.queueId` is the item's id,
|
||||||
|
`media.name` the item's `media.name`, the buffer and source carry on
|
||||||
|
and the queue-id URLs keep working. Any other swap of the item, or the
|
||||||
|
item no longer first, drops it (its URLs 404, its source is closed
|
||||||
|
with 4001).
|
||||||
- `welcome.mediaUrl` for relayed media without `jellyfin`: this member's
|
- `welcome.mediaUrl` for relayed media without `jellyfin`: this member's
|
||||||
`/r/` URL; absent for the source itself (it plays its own copy) and for
|
`/r/` URL; absent for the source itself (it plays its own copy) and for
|
||||||
a fetched link the server has not read yet (clients open it on `live`).
|
a fetched link the server has not read yet (clients open it on `live`).
|
||||||
Clients build it after a swap like the `/m` one:
|
Clients build it after a swap like the `/m` one:
|
||||||
`http://<host>/r/default/<token>/<mediaId>/<name>` (name with `/` and
|
`http://<host>/r/default/<token>/<key>/<name>` (`<key>` the
|
||||||
`\` made `_`, then URL-encoded; the server ignores it). With `tracks`,
|
`mediaId`, or `media.relay.queueId` when set; name with `/` and `\`
|
||||||
each track's URL is `http://<host>/r/default/<token>/<mediaId>/<i>/<name>`
|
made `_`, then URL-encoded; the server ignores it). With `tracks`,
|
||||||
|
each track's URL is `http://<host>/r/default/<token>/<key>/<i>/<name>`
|
||||||
(a pieces track: `<name>.m3u8`); one track is opened as is, several as
|
(a pieces track: `<name>.m3u8`); one track is opened as is, several as
|
||||||
an mpv EDL of them all, each framed by its byte length:
|
an mpv EDL of them all, each framed by its byte length:
|
||||||
`edl://!no_clip;%<len>%<track 0 url>;!new_stream;!no_clip;%<len>%<track 1 url>`.
|
`edl://!no_clip;%<len>%<track 0 url>;!new_stream;!no_clip;%<len>%<track 1 url>`.
|
||||||
- `GET /r/<sid>/<token>/<mediaId>/<name>`: the media (one file), Range
|
- `GET /r/<sid>/<token>/<key>/<name>`: the media (one file), Range
|
||||||
table as for `/m`. `GET /r/<sid>/<token>/<mediaId>/<i>/<name>`: track
|
table as for `/m`; `<key>` a `mediaId`, or a queue id (the warm relay,
|
||||||
|
or the media's relay started from it). `GET /r/<sid>/<token>/<key>/<i>/<name>`: track
|
||||||
`i` (from 0) of a media with `tracks` (track 0 of a one-file media
|
`i` (from 0) of a media with `tracks` (track 0 of a one-file media
|
||||||
too); for a pieces track (any name), its media playlist
|
too); for a pieces track (any name), its media playlist
|
||||||
(`application/vnd.apple.mpegurl`): `#EXT-X-PLAYLIST-TYPE:VOD`,
|
(`application/vnd.apple.mpegurl`): `#EXT-X-PLAYLIST-TYPE:VOD`,
|
||||||
`EXT-X-MEDIA-SEQUENCE`, `EXTINF` durations, `EXT-X-DISCONTINUITY`,
|
`EXT-X-MEDIA-SEQUENCE`, `EXTINF` durations, `EXT-X-DISCONTINUITY`,
|
||||||
`EXT-X-MAP` and `EXT-X-KEY:METHOD=AES-128` (with `IV` when given) as
|
`EXT-X-MAP` and `EXT-X-KEY:METHOD=AES-128` (with `IV` when given) as
|
||||||
upstream, `EXT-X-ENDLIST`; every URI is `p/<j>`. `GET
|
upstream, `EXT-X-ENDLIST`; every URI is `p/<j>`. `GET
|
||||||
/r/<sid>/<token>/<mediaId>/<i>/p/<j>`: piece `j` of pieces track `i`,
|
/r/<sid>/<token>/<key>/<i>/p/<j>`: piece `j` of pieces track `i`,
|
||||||
whole, once it is in (a Range inside the piece answers 206). Errors: 401
|
whole, once it is in (a Range inside the piece answers 206). Errors: 401
|
||||||
bad token, 404 not the current relayed media (a player still on an
|
bad token, 404 not the current relayed media nor the warm one (a player
|
||||||
older `mediaId` gets 404, never the new bytes), no such track (the
|
still on an older `mediaId` gets 404, never the new bytes), no such track (the
|
||||||
one-file URL of a media with `tracks`) or piece (or `p/` of a byte
|
one-file URL of a media with `tracks`) or piece (or `p/` of a byte
|
||||||
track), 410 unknown session, 503 + `retry-after` the source never
|
track), 410 unknown session, 503 + `retry-after` the source never
|
||||||
connected or the server could not read the link (60 s), or a piece did
|
connected or the server could not read the link (60 s), or a piece did
|
||||||
not arrive within 60 s. A byte request whose bytes do not arrive within
|
not arrive within 60 s. A byte request whose bytes do not arrive within
|
||||||
60 s ends early. Bytes count toward `bytesServed`.
|
60 s ends early. Bytes count toward `bytesServed`.
|
||||||
- `GET /r/<sid>/<token>/source` (WebSocket): the source's relay channel,
|
- `GET /r/<sid>/<token>/source` (WebSocket): the source's relay channel,
|
||||||
dialled by the source (works behind NAT). Only the current source's
|
dialled by the source (works behind NAT); `?q=<queueId>` for the warm
|
||||||
token is accepted; anyone else, or the source after the next swap, is
|
relay of that item (or the media's relay started from it). Only that
|
||||||
closed with code 4001 (the source stops instead of reconnecting).
|
relay's source token is accepted; anyone else, or the source once its
|
||||||
|
relay is dropped, is closed with code 4001 (the source stops instead
|
||||||
|
of reconnecting). A warm relay's tracks are those of its source's
|
||||||
|
first `info`.
|
||||||
Frames:
|
Frames:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -442,4 +465,5 @@ state "queue":[<item>,...], "queuePlay":"q3"
|
|||||||
leave out `url`: the server uses the one it kept. A swap without
|
leave out `url`: the server uses the one it kept. A swap without
|
||||||
`queueId` leaves the queue as it is.
|
`queueId` leaves the queue as it is.
|
||||||
- `state.queue` (omitted when empty) and `state.queuePlay` (omitted when
|
- `state.queue` (omitted when empty) and `state.queuePlay` (omitted when
|
||||||
none) ride in every state, `welcome.state` included.
|
none) ride in every state, `welcome.state` included. The first item's
|
||||||
|
`media.relay` shows its warm relay (see "Relay").
|
||||||
+14
-22
@@ -56,25 +56,19 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
|||||||
over instead of the buffer running dry.
|
over instead of the buffer running dry.
|
||||||
- "Bob can't open this link — relay it?": offer the relay to whoever put
|
- "Bob can't open this link — relay it?": offer the relay to whoever put
|
||||||
a direct link up when a viewer's player fails to open it.
|
a direct link up when a viewer's player fails to open it.
|
||||||
- Preload relayed Up next items (see ARCHITECTURE "Getting ready"):
|
- The host's own file as Up next's first item: it plays from `/m`
|
||||||
today a link passed on "through me", another member's file, a
|
once up, so others open it cold (the warm relay covers every other
|
||||||
server-fetched link and the host's own file are opened cold by
|
relayed item, see ARCHITECTURE "Relay"). The hosting app could hand
|
||||||
everyone but their source when they go up (their relay or `/m`
|
the server the item's path early and `/m` serve it under a `next/`
|
||||||
starts with the swap), so the gate waits for the slowest open. Plan
|
name until the swap.
|
||||||
(wire change): the server holds a second, small relay for Up next's
|
- Research (before building anything): can stream URLs a member's
|
||||||
first item, keyed by its queue id. Its adder's app dials a second
|
app resolved with yt-dlp (its cookies, its address) be fetched by
|
||||||
source for it as soon as it is first (`/r/.../source` naming the
|
the server from another address? Per site: YouTube (its
|
||||||
queue id); a server-fetched item starts its fetch then;
|
`googlevideo` URLs carry the resolving IP), Bilibili, X, generic
|
||||||
`state.queue[0].relay` (`live`, `tracks`) tells viewers when and
|
HLS/DASH, others; and YouTube resolved by the server itself from a
|
||||||
how to open `/r/<sid>/<token>/q/<queueId>/<name>` in their preload
|
VPS, with and without cookies. Decides whether a server-driven
|
||||||
player. The buffer keeps only the head and tail pins plus about
|
fetch of member-resolved links (the member need not upload or stay)
|
||||||
32 MiB ahead of the viewers until the swap, pulls nothing while
|
is worth building.
|
||||||
nobody preloads, and is dropped when the item stops being first. A
|
|
||||||
swap with `queueId` turns it into the media's relay (same buffer;
|
|
||||||
the `q/` URL keeps working for that media, so the preloads match
|
|
||||||
and are promoted). The host's file: the hosting app hands the
|
|
||||||
server the first item's path early and `/m` serves it under a
|
|
||||||
`next/` name until the swap.
|
|
||||||
|
|
||||||
8. **Jellyfin** (epic, below).
|
8. **Jellyfin** (epic, below).
|
||||||
|
|
||||||
@@ -98,9 +92,7 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
|||||||
- HTTP: move the app's clients (Jellyfin, AniList, AnimeFlow, updater)
|
- HTTP: move the app's clients (Jellyfin, AniList, AnimeFlow, updater)
|
||||||
to dio from `fox_utils` when touching them, for retries/interceptors.
|
to dio from `fox_utils` when touching them, for retries/interceptors.
|
||||||
- TLS: document a reverse proxy in front of the server.
|
- TLS: document a reverse proxy in front of the server.
|
||||||
- Up next follow-ups (see ARCHITECTURE "Up next"): getting the first
|
- Up next follow-ups (see ARCHITECTURE "Up next"): a member
|
||||||
item ready only resolves its link; fetching its first seconds early
|
|
||||||
through the relay would make relayed starts instant too. A member
|
|
||||||
whose reconnect lands as a new member loses its "through me" links
|
whose reconnect lands as a new member loses its "through me" links
|
||||||
and files in the queue (skipped as gone); the key could follow the
|
and files in the queue (skipped as gone); the key could follow the
|
||||||
member. A whole season or a pick list at once ("Add the rest of the
|
member. A whole season or a pick list at once ("Add the rest of the
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
abstract class RelayConstants {
|
abstract class RelayConstants {
|
||||||
static const int protoVersion = 13;
|
static const int protoVersion = 14;
|
||||||
|
|
||||||
// Relay (`/r/...`): a member's bytes through the server's buffer.
|
// Relay (`/r/...`): a member's bytes through the server's buffer.
|
||||||
static const int relaySliceBytes = 1 << 20;
|
static const int relaySliceBytes = 1 << 20;
|
||||||
@@ -24,6 +24,12 @@ abstract class RelayConstants {
|
|||||||
/// A viewer request waits this long for its bytes (source away) before
|
/// A viewer request waits this long for its bytes (source away) before
|
||||||
/// the server gives up on it.
|
/// the server gives up on it.
|
||||||
static const int relayWaitSecs = 60;
|
static const int relayWaitSecs = 60;
|
||||||
|
/// The warm relay (Up next's first relayed item, before it goes up)
|
||||||
|
/// keeps this much ahead of and behind its viewers' preloads, besides
|
||||||
|
/// the pins: about what a preload player reads ahead (48 MiB), never the
|
||||||
|
/// whole media.
|
||||||
|
static const int relayWarmAheadBytes = 32 << 20;
|
||||||
|
static const int relayWarmBackBytes = 8 << 20;
|
||||||
|
|
||||||
static const int seekDebounceMs = 300;
|
static const int seekDebounceMs = 300;
|
||||||
static const double driftSeekThresholdSecs = 5.0;
|
static const double driftSeekThresholdSecs = 5.0;
|
||||||
|
|||||||
@@ -182,7 +182,10 @@ class RelayTrackSpec {
|
|||||||
/// them, the server fills them in for [fetch] once it has read the link.
|
/// them, the server fills them in for [fetch] once it has read the link.
|
||||||
/// [rate] and [need] are the server's measures, 0 while unknown: bytes per
|
/// [rate] and [need] are the server's measures, 0 while unknown: bytes per
|
||||||
/// second the source (or the server's own fetch) delivers while the
|
/// second the source (or the server's own fetch) delivers while the
|
||||||
/// buffer asks for bytes, and the media's average bitrate.
|
/// buffer asks for bytes, and the media's average bitrate. [queueId]:
|
||||||
|
/// the Up next item this relay was started for (its warm relay) before
|
||||||
|
/// it went up; viewers open its `/r/.../<queueId>/...` URLs, so their
|
||||||
|
/// preloads of it carry on.
|
||||||
class MediaRelay {
|
class MediaRelay {
|
||||||
final String source;
|
final String source;
|
||||||
final bool fetch;
|
final bool fetch;
|
||||||
@@ -190,8 +193,15 @@ class MediaRelay {
|
|||||||
final List<RelayTrackSpec> tracks;
|
final List<RelayTrackSpec> tracks;
|
||||||
final int rate;
|
final int rate;
|
||||||
final int need;
|
final int need;
|
||||||
|
final String queueId;
|
||||||
const MediaRelay(
|
const MediaRelay(
|
||||||
{this.source = '', this.fetch = false, this.live = false, this.tracks = const [], this.rate = 0, this.need = 0});
|
{this.source = '',
|
||||||
|
this.fetch = false,
|
||||||
|
this.live = false,
|
||||||
|
this.tracks = const [],
|
||||||
|
this.rate = 0,
|
||||||
|
this.need = 0,
|
||||||
|
this.queueId = ''});
|
||||||
|
|
||||||
/// The source is measured slower than the media plays (both known):
|
/// The source is measured slower than the media plays (both known):
|
||||||
/// below 85 % of [need], so a rate close to it does not flicker.
|
/// below 85 % of [need], so a rate close to it does not flicker.
|
||||||
@@ -210,7 +220,8 @@ class MediaRelay {
|
|||||||
live: live ?? this.live,
|
live: live ?? this.live,
|
||||||
tracks: tracks ?? this.tracks,
|
tracks: tracks ?? this.tracks,
|
||||||
rate: rate ?? this.rate,
|
rate: rate ?? this.rate,
|
||||||
need: need ?? this.need);
|
need: need ?? this.need,
|
||||||
|
queueId: queueId);
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {
|
Map<String, Object?> toJson() => {
|
||||||
if (source.isNotEmpty) 'source': source,
|
if (source.isNotEmpty) 'source': source,
|
||||||
@@ -219,6 +230,7 @@ class MediaRelay {
|
|||||||
if (validTracks(tracks)) 'tracks': [for (final t in tracks) t.toJson()],
|
if (validTracks(tracks)) 'tracks': [for (final t in tracks) t.toJson()],
|
||||||
if (rate > 0) 'rate': rate,
|
if (rate > 0) 'rate': rate,
|
||||||
if (need > 0) 'need': need,
|
if (need > 0) 'need': need,
|
||||||
|
if (queueId.isNotEmpty) 'queueId': queueId,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Unknown kinds, a single byte track or too many make it one file.
|
/// Unknown kinds, a single byte track or too many make it one file.
|
||||||
@@ -232,7 +244,8 @@ class MediaRelay {
|
|||||||
live: json['live'] == true,
|
live: json['live'] == true,
|
||||||
tracks: ok ? List.unmodifiable(specs.cast<RelayTrackSpec>()) : const [],
|
tracks: ok ? List.unmodifiable(specs.cast<RelayTrackSpec>()) : const [],
|
||||||
rate: _byteRate(json['rate']),
|
rate: _byteRate(json['rate']),
|
||||||
need: _byteRate(json['need']));
|
need: _byteRate(json['need']),
|
||||||
|
queueId: json['queueId'] is String ? json['queueId'] as String : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A bytes-per-second field: a finite non-negative number, else 0.
|
/// A bytes-per-second field: a finite non-negative number, else 0.
|
||||||
|
|||||||
@@ -348,6 +348,9 @@ class RelaySource {
|
|||||||
|
|
||||||
var _tracks = const <RelayTrack>[];
|
var _tracks = const <RelayTrack>[];
|
||||||
|
|
||||||
|
/// The media's tracks once [start] has opened it (empty before).
|
||||||
|
List<RelayTrack> get tracks => _tracks;
|
||||||
|
|
||||||
/// Opens the media (a page is resolved) and starts serving; throws
|
/// Opens the media (a page is resolved) and starts serving; throws
|
||||||
/// when it cannot be read.
|
/// when it cannot be read.
|
||||||
Future<void> start() async {
|
Future<void> start() async {
|
||||||
|
|||||||
@@ -535,7 +535,9 @@ class SyncEngine {
|
|||||||
|
|
||||||
/// [relay]: whether the server can relay the current Jellyfin media; the
|
/// [relay]: whether the server can relay the current Jellyfin media; the
|
||||||
/// engine does not know the server's credential.
|
/// engine does not know the server's credential.
|
||||||
StateBroadcast broadcast({bool relay = false, bool relayNext = false}) {
|
/// [warm]: the server's warm relay for Up next's first item, shown as
|
||||||
|
/// that item's `media.relay`.
|
||||||
|
StateBroadcast broadcast({bool relay = false, bool relayNext = false, ({String id, MediaRelay relay})? warm}) {
|
||||||
final bySeek = _seekEventId == eventId;
|
final bySeek = _seekEventId == eventId;
|
||||||
return StateBroadcast(
|
return StateBroadcast(
|
||||||
eventId: eventId,
|
eventId: eventId,
|
||||||
@@ -555,7 +557,10 @@ class SyncEngine {
|
|||||||
seekSeq: bySeek ? _seekSeq : null,
|
seekSeq: bySeek ? _seekSeq : null,
|
||||||
relay: relay,
|
relay: relay,
|
||||||
relayNext: relayNext,
|
relayNext: relayNext,
|
||||||
queue: queue.items,
|
queue: [
|
||||||
|
for (final q in queue.items)
|
||||||
|
warm != null && q.id == warm.id ? q.copyWith(media: q.media.copyWith(relay: warm.relay)) : q,
|
||||||
|
],
|
||||||
queuePlay: queue.play,
|
queuePlay: queue.play,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,22 +32,39 @@ List<(int, int)> relayBudgets(List<int> sizes,
|
|||||||
/// video, 160 kbit/s for audio.
|
/// video, 160 kbit/s for audio.
|
||||||
double _piecesRate(String kind) => kind == 'audio' ? 20000 : 500000;
|
double _piecesRate(String kind) => kind == 'audio' ? 20000 : 500000;
|
||||||
|
|
||||||
/// One track as the source (or the fetcher) described it: a byte size,
|
/// One track as the source (or the fetcher) described it: its kind, a
|
||||||
/// or a piece list.
|
/// byte size or a piece list.
|
||||||
typedef _TrackInfo = ({int? size, RelayPieceList? pieces});
|
typedef _TrackInfo = ({String kind, int? size, RelayPieceList? pieces});
|
||||||
|
|
||||||
/// The relay of the current media: one buffer per track and where the
|
/// A relayed media on the server: one buffer per track and where the
|
||||||
/// bytes come from. One per relayed swap; [dispose] on the next swap or
|
/// bytes come from. The relay of the current media (one per relayed swap;
|
||||||
/// server stop (the buffers are memory only, so that is the whole cleanup).
|
/// [dispose] on the next swap or server stop: the buffers are memory
|
||||||
|
/// only, so that is the whole cleanup), or the warm relay of Up next's
|
||||||
|
/// first item ([warm], keyed by [queueId]), which a swap of that item
|
||||||
|
/// [promote]s to the current one, buffer and all.
|
||||||
class ActiveRelay {
|
class ActiveRelay {
|
||||||
final int mediaId;
|
/// The media it relays; -1 while it is warm.
|
||||||
|
int mediaId;
|
||||||
final String name;
|
final String name;
|
||||||
/// Token of the member whose machine serves the bytes; null when the
|
/// Token of the member whose machine serves the bytes; null when the
|
||||||
/// server fetches the link itself.
|
/// server fetches the link itself, or (a warm Jellyfin item) until a
|
||||||
final String? sourceToken;
|
/// member with an account dials in.
|
||||||
|
String? sourceToken;
|
||||||
|
/// That member's id (kept after it leaves); empty with [fetch].
|
||||||
|
String sourceId;
|
||||||
|
/// The server reads the link itself.
|
||||||
|
final bool fetch;
|
||||||
|
/// The Up next item it was started for (the warm relay); kept once
|
||||||
|
/// promoted, so the `/r/.../<queueId>/...` URLs keep working.
|
||||||
|
final String? queueId;
|
||||||
|
bool _warm;
|
||||||
final void Function() onLiveChanged;
|
final void Function() onLiveChanged;
|
||||||
|
/// The server's own fetch could not read the link (with the reason).
|
||||||
|
void Function(String reason)? onFail;
|
||||||
|
bool failed = false;
|
||||||
/// One per track, in track order ([RelayBuffer] for bytes, [PieceBuffer]
|
/// One per track, in track order ([RelayBuffer] for bytes, [PieceBuffer]
|
||||||
/// for pieces); empty until the server has read a fetched link.
|
/// for pieces); empty until the tracks are known (a fetched link read,
|
||||||
|
/// a warm source's first `info`).
|
||||||
List<TrackBuffer> buffers = const [];
|
List<TrackBuffer> buffers = const [];
|
||||||
/// `media.relay.tracks`: empty for one file.
|
/// `media.relay.tracks`: empty for one file.
|
||||||
List<RelayTrackSpec> tracks = const [];
|
List<RelayTrackSpec> tracks = const [];
|
||||||
@@ -57,11 +74,17 @@ class ActiveRelay {
|
|||||||
var _local = const <LocalRelayFeed>[];
|
var _local = const <LocalRelayFeed>[];
|
||||||
RelicWebSocket? _channel;
|
RelicWebSocket? _channel;
|
||||||
var _channelFeeds = const <ChannelRelayFeed>[];
|
var _channelFeeds = const <ChannelRelayFeed>[];
|
||||||
|
List<_TrackInfo>? _infos;
|
||||||
/// Shared by the tracks: time any of them waits on the source.
|
/// Shared by the tracks: time any of them waits on the source.
|
||||||
final clock = FeedClock();
|
final clock = FeedClock();
|
||||||
final _rate = SourceRate();
|
final _rate = SourceRate();
|
||||||
|
|
||||||
ActiveRelay._(this.mediaId, this.name, this.sourceToken, this.onLiveChanged);
|
ActiveRelay._(this.mediaId, this.name, this.sourceToken, this.onLiveChanged,
|
||||||
|
{this.sourceId = '', this.fetch = false, this.queueId, bool warm = false})
|
||||||
|
: _warm = warm;
|
||||||
|
|
||||||
|
/// Up next's first item, before it goes up.
|
||||||
|
bool get warm => _warm;
|
||||||
|
|
||||||
/// Takes the bytes the source sent so far, for [rate]; the server calls
|
/// Takes the bytes the source sent so far, for [rate]; the server calls
|
||||||
/// it once a second.
|
/// it once a second.
|
||||||
@@ -71,6 +94,9 @@ class ActiveRelay {
|
|||||||
/// (`media.relay.rate`); 0 until measured.
|
/// (`media.relay.rate`); 0 until measured.
|
||||||
int get rate => _rate.rate;
|
int get rate => _rate.rate;
|
||||||
|
|
||||||
|
/// Bytes the buffers hold now.
|
||||||
|
int get cachedBytes => buffers.fold(0, (a, b) => a + b.cachedBytes);
|
||||||
|
|
||||||
/// The media's average bytes per second for a [duration] in seconds
|
/// The media's average bytes per second for a [duration] in seconds
|
||||||
/// (`media.relay.need`): byte tracks by size, pieces tracks from the
|
/// (`media.relay.need`): byte tracks by size, pieces tracks from the
|
||||||
/// pieces in so far; 0 while any track is unknown.
|
/// pieces in so far; 0 while any track is unknown.
|
||||||
@@ -106,6 +132,12 @@ class ActiveRelay {
|
|||||||
if (!_known.isCompleted) _known.complete();
|
if (!_known.isCompleted) _known.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The tracks a warm source described in its first `info`.
|
||||||
|
static List<RelayTrackSpec> _specsOf(List<_TrackInfo> infos) =>
|
||||||
|
infos.length == 1 && infos.single.pieces == null
|
||||||
|
? const []
|
||||||
|
: [for (final t in infos) RelayTrackSpec(t.kind.isEmpty ? 'video' : t.kind, pieces: t.pieces != null)];
|
||||||
|
|
||||||
/// The info of every track matches [tracks] (bytes for a byte track,
|
/// The info of every track matches [tracks] (bytes for a byte track,
|
||||||
/// pieces for a pieces one).
|
/// pieces for a pieces one).
|
||||||
bool _matches(List<_TrackInfo> infos) {
|
bool _matches(List<_TrackInfo> infos) {
|
||||||
@@ -118,10 +150,8 @@ class ActiveRelay {
|
|||||||
|
|
||||||
/// Attaches every buffer to its feed.
|
/// Attaches every buffer to its feed.
|
||||||
void _attachAll(List<RelayFeed> feeds, List<_TrackInfo> infos) {
|
void _attachAll(List<RelayFeed> feeds, List<_TrackInfo> infos) {
|
||||||
_budget([
|
_infos = infos;
|
||||||
for (var i = 0; i < infos.length; i++)
|
_budget();
|
||||||
infos[i].size ?? (_piecesRate(tracks[i].kind) * infos[i].pieces!.duration).round(),
|
|
||||||
]);
|
|
||||||
for (var i = 0; i < buffers.length; i++) {
|
for (var i = 0; i < buffers.length; i++) {
|
||||||
final b = buffers[i];
|
final b = buffers[i];
|
||||||
if (b is PieceBuffer) {
|
if (b is PieceBuffer) {
|
||||||
@@ -132,8 +162,18 @@ class ActiveRelay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _budget(List<int> sizes) {
|
/// The tracks share the budget by size: a warm relay's small one
|
||||||
final b = relayBudgets(sizes);
|
/// ([RelayConstants.relayWarmAheadBytes]), else the full one.
|
||||||
|
void _budget() {
|
||||||
|
final infos = _infos;
|
||||||
|
if (infos == null) return;
|
||||||
|
final sizes = [
|
||||||
|
for (var i = 0; i < infos.length; i++)
|
||||||
|
infos[i].size ?? (_piecesRate(infos[i].kind) * infos[i].pieces!.duration).round(),
|
||||||
|
];
|
||||||
|
final b = _warm
|
||||||
|
? relayBudgets(sizes, ahead: RelayConstants.relayWarmAheadBytes, back: RelayConstants.relayWarmBackBytes)
|
||||||
|
: relayBudgets(sizes);
|
||||||
for (var i = 0; i < buffers.length; i++) {
|
for (var i = 0; i < buffers.length; i++) {
|
||||||
buffers[i]
|
buffers[i]
|
||||||
..ahead = b[i].$1
|
..ahead = b[i].$1
|
||||||
@@ -144,22 +184,33 @@ class ActiveRelay {
|
|||||||
/// Bytes from member [sourceToken] over its relay channel, as the
|
/// Bytes from member [sourceToken] over its relay channel, as the
|
||||||
/// [tracks] its swap named (empty: one file).
|
/// [tracks] its swap named (empty: one file).
|
||||||
factory ActiveRelay.fromMember(int mediaId, String name, String sourceToken,
|
factory ActiveRelay.fromMember(int mediaId, String name, String sourceToken,
|
||||||
{List<RelayTrackSpec> tracks = const [], required void Function() onLiveChanged}) =>
|
{List<RelayTrackSpec> tracks = const [], String sourceId = '', required void Function() onLiveChanged}) =>
|
||||||
ActiveRelay._(mediaId, name, sourceToken, onLiveChanged).._setTracks(tracks);
|
ActiveRelay._(mediaId, name, sourceToken, onLiveChanged, sourceId: sourceId).._setTracks(tracks);
|
||||||
|
|
||||||
|
/// The warm relay of Up next's item [queueId] fed by member
|
||||||
|
/// [sourceToken] (null: the first qualified member to dial in); its
|
||||||
|
/// tracks are what the source's first `info` says.
|
||||||
|
factory ActiveRelay.warmFromMember(String queueId, String name, String? sourceToken,
|
||||||
|
{String sourceId = '', required void Function() onLiveChanged}) =>
|
||||||
|
ActiveRelay._(-1, name, sourceToken, onLiveChanged, sourceId: sourceId, queueId: queueId, warm: true);
|
||||||
|
|
||||||
/// The server reads [url] itself: a media file as is, a web page
|
/// The server reads [url] itself: a media file as is, a web page
|
||||||
/// through [ytDlp] (one file, video + audio tracks, or segmented
|
/// through [ytDlp] (one file, video + audio tracks, or segmented
|
||||||
/// playlists). [onFail] gets the reason when it cannot.
|
/// playlists). [onFail] gets the reason when it cannot. With [queueId]
|
||||||
static ActiveRelay fetch(int mediaId, String name, Uri url,
|
/// it is that Up next item's warm relay.
|
||||||
{YtDlp? ytDlp, required void Function() onLiveChanged, void Function(String reason)? onFail}) {
|
static ActiveRelay fetchLink(int mediaId, String name, Uri url,
|
||||||
final r = ActiveRelay._(mediaId, name, null, onLiveChanged);
|
{YtDlp? ytDlp, String? queueId, required void Function() onLiveChanged, void Function(String reason)? onFail}) {
|
||||||
|
final r = ActiveRelay._(mediaId, name, null, onLiveChanged, fetch: true, queueId: queueId, warm: queueId != null)
|
||||||
|
..onFail = onFail;
|
||||||
final media = r._media = LinkRelayMedia(url, ytDlp: ytDlp);
|
final media = r._media = LinkRelayMedia(url, ytDlp: ytDlp);
|
||||||
unawaited(() async {
|
unawaited(() async {
|
||||||
try {
|
try {
|
||||||
final tracks = await media.open();
|
final tracks = await media.open();
|
||||||
final infos = <_TrackInfo>[
|
final infos = <_TrackInfo>[
|
||||||
for (final t in tracks)
|
for (final t in tracks)
|
||||||
t.pieces != null ? (size: null, pieces: await t.pieces!.list()) : (size: await t.bytes!.size(), pieces: null),
|
t.pieces != null
|
||||||
|
? (kind: t.kind, size: null, pieces: await t.pieces!.list())
|
||||||
|
: (kind: t.kind, size: await t.bytes!.size(), pieces: null),
|
||||||
];
|
];
|
||||||
if (r._disposed) return;
|
if (r._disposed) return;
|
||||||
r._setTracks(relayTrackSpecs(tracks));
|
r._setTracks(relayTrackSpecs(tracks));
|
||||||
@@ -168,17 +219,34 @@ class ActiveRelay {
|
|||||||
onLiveChanged();
|
onLiveChanged();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.warning('relay fetch: cannot read the link: $e');
|
_log.warning('relay fetch: cannot read the link: $e');
|
||||||
if (!r._disposed) onFail?.call('$e');
|
if (!r._disposed) {
|
||||||
|
r.failed = true;
|
||||||
|
r.onFail?.call('$e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}());
|
}());
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A member relay whose source has not described its tracks yet takes
|
||||||
|
/// [tracks] from the swap that puts it up.
|
||||||
|
void ensureTracks(List<RelayTrackSpec> tracks) {
|
||||||
|
if (!fetch && !_known.isCompleted) _setTracks(tracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The warm relay becomes media [mediaId]'s: its buffer and feed carry
|
||||||
|
/// on, with the full budget.
|
||||||
|
void promote(int mediaId) {
|
||||||
|
this.mediaId = mediaId;
|
||||||
|
_warm = false;
|
||||||
|
_budget();
|
||||||
|
}
|
||||||
|
|
||||||
var _disposed = false;
|
var _disposed = false;
|
||||||
bool get live => buffers.isNotEmpty && buffers.every((b) => b.live);
|
bool get live => buffers.isNotEmpty && buffers.every((b) => b.live);
|
||||||
|
|
||||||
/// The tracks are known (a fetched link has been read).
|
/// The tracks are known (a fetched link has been read).
|
||||||
bool get known => _known.isCompleted && !_disposed;
|
bool get known => _known.isCompleted && !_disposed && buffers.isNotEmpty;
|
||||||
|
|
||||||
/// Waits until the tracks are known, up to [RelayConstants.relayWaitSecs].
|
/// Waits until the tracks are known, up to [RelayConstants.relayWaitSecs].
|
||||||
Future<bool> whenKnown() async {
|
Future<bool> whenKnown() async {
|
||||||
@@ -214,6 +282,13 @@ class ActiveRelay {
|
|||||||
if (ch != null) unawaited(_closeNotSource(ch));
|
if (ch != null) unawaited(_closeNotSource(ch));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The source dialled in with its tracks: a warm relay learns them
|
||||||
|
/// here; false when they are not this relay's.
|
||||||
|
bool _channelInfo(List<_TrackInfo> infos) {
|
||||||
|
if (!_known.isCompleted) _setTracks(_specsOf(infos));
|
||||||
|
return _matches(infos);
|
||||||
|
}
|
||||||
|
|
||||||
void _attachChannel(RelicWebSocket ws, List<_TrackInfo> infos) {
|
void _attachChannel(RelicWebSocket ws, List<_TrackInfo> infos) {
|
||||||
final old = _channel;
|
final old = _channel;
|
||||||
if (old != null && !identical(old, ws)) unawaited(old.tryClose());
|
if (old != null && !identical(old, ws)) unawaited(old.tryClose());
|
||||||
@@ -243,19 +318,21 @@ class ActiveRelay {
|
|||||||
/// malformed.
|
/// malformed.
|
||||||
List<_TrackInfo>? _infoTracks(Map j) {
|
List<_TrackInfo>? _infoTracks(Map j) {
|
||||||
final size = j['size'], tracks = j['tracks'];
|
final size = j['size'], tracks = j['tracks'];
|
||||||
if (size is int) return size > 0 ? [(size: size, pieces: null)] : null;
|
if (size is int) return size > 0 ? [(kind: '', size: size, pieces: null)] : null;
|
||||||
if (tracks is! List || tracks.isEmpty || tracks.length > MediaRelay.maxTracks) return null;
|
if (tracks is! List || tracks.isEmpty || tracks.length > MediaRelay.maxTracks) return null;
|
||||||
final out = <_TrackInfo>[];
|
final out = <_TrackInfo>[];
|
||||||
for (final t in tracks) {
|
for (final t in tracks) {
|
||||||
if (t is! Map) return null;
|
if (t is! Map) return null;
|
||||||
|
final kind = t['kind'];
|
||||||
|
if (!MediaRelay.trackKinds.contains(kind)) return null;
|
||||||
if (t['as'] == 'pieces') {
|
if (t['as'] == 'pieces') {
|
||||||
final list = RelayPieceList.fromJson(t);
|
final list = RelayPieceList.fromJson(t);
|
||||||
if (list == null) return null;
|
if (list == null) return null;
|
||||||
out.add((size: null, pieces: list));
|
out.add((kind: kind as String, size: null, pieces: list));
|
||||||
} else {
|
} else {
|
||||||
final n = t['size'];
|
final n = t['size'];
|
||||||
if (n is! int || n <= 0) return null;
|
if (n is! int || n <= 0) return null;
|
||||||
out.add((size: n, pieces: null));
|
out.add((kind: kind as String, size: n, pieces: null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -270,32 +347,32 @@ Future<void> _closeNotSource(RelicWebSocket ws) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /r/<sid>/<token>/source` (WebSocket): the source member's relay
|
/// `GET /r/<sid>/<token>/source` (WebSocket): the source member's relay
|
||||||
/// channel. `GET /r/<sid>/<token>/<mediaId>/<name>`: a viewer reads the
|
/// channel; `?q=<queueId>` for the warm relay of that Up next item.
|
||||||
/// relayed file, Range as for `/m`; `.../<mediaId>/<track>/<name>` one
|
/// `GET /r/<sid>/<token>/<key>/<name>`: a viewer reads the relayed file,
|
||||||
/// track of a media with tracks (a pieces track's media playlist), and
|
/// Range as for `/m`; `.../<key>/<track>/<name>` one track of a media
|
||||||
/// `.../<mediaId>/<track>/p/<i>` piece `i` of a pieces track. [relay]
|
/// with tracks (a pieces track's media playlist), and
|
||||||
/// gives the current relay.
|
/// `.../<key>/<track>/p/<i>` piece `i` of a pieces track. `<key>` is the
|
||||||
|
/// media id, or the queue id of a warm (or promoted) relay. [relayAt]
|
||||||
|
/// finds the relay of a key, [sourceOf] the relay a token may feed.
|
||||||
void mountRelayRoutes(
|
void mountRelayRoutes(
|
||||||
RelicApp app,
|
RelicApp app,
|
||||||
SessionMap sessions,
|
SessionMap sessions, {
|
||||||
ActiveRelay? Function() relay, {
|
required ActiveRelay? Function(String key) relayAt,
|
||||||
|
required ActiveRelay? Function(String token, String? queueId) sourceOf,
|
||||||
void Function(String token, MediaLog log)? onLog,
|
void Function(String token, MediaLog log)? onLog,
|
||||||
}) {
|
}) {
|
||||||
app.get('/r/:sid/:token/source', (final req) {
|
app.get('/r/:sid/:token/source', (final req) {
|
||||||
final sid = req.pathParameters.raw[#sid] ?? '';
|
final sid = req.pathParameters.raw[#sid] ?? '';
|
||||||
final token = req.pathParameters.raw[#token] ?? '';
|
final token = req.pathParameters.raw[#token] ?? '';
|
||||||
|
final queueId = req.url.queryParameters['q'];
|
||||||
final session = sessions.get(sid);
|
final session = sessions.get(sid);
|
||||||
final r = relay();
|
final found = session != null && session.gate(token) == MediaGate.ok ? sourceOf(token, queueId) : null;
|
||||||
final ok = session != null &&
|
|
||||||
session.gate(token) == MediaGate.ok &&
|
|
||||||
r != null &&
|
|
||||||
r.sourceToken == token;
|
|
||||||
return WebSocketUpgrade((final ws) async {
|
return WebSocketUpgrade((final ws) async {
|
||||||
if (!ok) {
|
final r = found;
|
||||||
|
if (r == null) {
|
||||||
await _closeNotSource(ws);
|
await _closeNotSource(ws);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final r = relay()!;
|
|
||||||
ws.pingInterval = Duration(seconds: RelayConstants.pingIntervalSecs);
|
ws.pingInterval = Duration(seconds: RelayConstants.pingIntervalSecs);
|
||||||
var attached = false;
|
var attached = false;
|
||||||
try {
|
try {
|
||||||
@@ -313,7 +390,7 @@ void mountRelayRoutes(
|
|||||||
case 'info':
|
case 'info':
|
||||||
if (attached) continue;
|
if (attached) continue;
|
||||||
final infos = _infoTracks(j);
|
final infos = _infoTracks(j);
|
||||||
if (infos == null || !r._matches(infos)) {
|
if (infos == null || !r._channelInfo(infos)) {
|
||||||
// Not the media this swap named: the source stops.
|
// Not the media this swap named: the source stops.
|
||||||
_log.warning('relay channel: info does not match the media (${r.name})');
|
_log.warning('relay channel: info does not match the media (${r.name})');
|
||||||
await _closeNotSource(ws);
|
await _closeNotSource(ws);
|
||||||
@@ -359,7 +436,7 @@ void mountRelayRoutes(
|
|||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
final sid = req.pathParameters.raw[#sid] ?? '';
|
final sid = req.pathParameters.raw[#sid] ?? '';
|
||||||
final token = req.pathParameters.raw[#token] ?? '';
|
final token = req.pathParameters.raw[#token] ?? '';
|
||||||
final mediaId = int.tryParse(req.pathParameters.raw[#mediaId] ?? '');
|
final key = req.pathParameters.raw[#mediaId] ?? '';
|
||||||
final track = tracked ? int.tryParse(req.pathParameters.raw[#seg] ?? '') ?? -1 : null;
|
final track = tracked ? int.tryParse(req.pathParameters.raw[#seg] ?? '') ?? -1 : null;
|
||||||
final session = sessions.get(sid);
|
final session = sessions.get(sid);
|
||||||
if (session == null) return _plain(410);
|
if (session == null) return _plain(410);
|
||||||
@@ -369,9 +446,9 @@ void mountRelayRoutes(
|
|||||||
default:
|
default:
|
||||||
return _plain(401);
|
return _plain(401);
|
||||||
}
|
}
|
||||||
final r = relay();
|
|
||||||
// A player still on an older media must not get the new one's bytes.
|
// A player still on an older media must not get the new one's bytes.
|
||||||
if (r == null || r.mediaId != mediaId) return _plain(404);
|
final r = relayAt(key);
|
||||||
|
if (r == null) return _plain(404);
|
||||||
final notConnected = Response(503,
|
final notConnected = Response(503,
|
||||||
body: Body.fromString('relay source not connected'),
|
body: Body.fromString('relay source not connected'),
|
||||||
headers: Headers.build((h) => h['retry-after'] = ['2']));
|
headers: Headers.build((h) => h['retry-after'] = ['2']));
|
||||||
@@ -421,7 +498,7 @@ void mountRelayRoutes(
|
|||||||
token,
|
token,
|
||||||
MediaLog(
|
MediaLog(
|
||||||
method: req.method.name,
|
method: req.method.name,
|
||||||
path: '/r/$sid/$shortToken/$mediaId/${track == null ? '' : '$track/'}${r.name}',
|
path: '/r/$sid/$shortToken/$key/${track == null ? '' : '$track/'}${r.name}',
|
||||||
rangeHeader: rangeHeader,
|
rangeHeader: rangeHeader,
|
||||||
status: res.status,
|
status: res.status,
|
||||||
bytesWritten: end - res.start + 1,
|
bytesWritten: end - res.start + 1,
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ class ServerHub {
|
|||||||
: '$head: $why';
|
: '$head: $why';
|
||||||
}
|
}
|
||||||
|
|
||||||
StateBroadcast stateBroadcast() => engine.broadcast(relay: relayAvailable, relayNext: relayNextAvailable);
|
StateBroadcast stateBroadcast() =>
|
||||||
|
engine.broadcast(relay: relayAvailable, relayNext: relayNextAvailable, warm: _warmView());
|
||||||
|
|
||||||
/// The relay of the current media (`/r/...`), if it is relayed.
|
/// The relay of the current media (`/r/...`), if it is relayed.
|
||||||
ActiveRelay? relay;
|
ActiveRelay? relay;
|
||||||
@@ -69,19 +70,178 @@ class ServerHub {
|
|||||||
/// Replaces the relay (null: the new media is not relayed). The old
|
/// Replaces the relay (null: the new media is not relayed). The old
|
||||||
/// buffer is dropped with it.
|
/// buffer is dropped with it.
|
||||||
void setRelay(ActiveRelay? next) {
|
void setRelay(ActiveRelay? next) {
|
||||||
relay?.dispose();
|
if (!identical(relay, next)) relay?.dispose();
|
||||||
relay = next;
|
relay = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The warm relay: Up next's first item relayed before it goes up, so
|
||||||
|
/// every viewer preloads it (see ARCHITECTURE "Getting ready"); null
|
||||||
|
/// when that item is not relayed (or nobody would read it).
|
||||||
|
ActiveRelay? warm;
|
||||||
|
|
||||||
|
void _setWarm(ActiveRelay? next) {
|
||||||
|
if (!identical(warm, next)) warm?.dispose();
|
||||||
|
warm = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops both relays (server stop).
|
||||||
|
void dropRelays() {
|
||||||
|
setRelay(null);
|
||||||
|
_setWarm(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `queue[0].media.relay` while the warm relay exists.
|
||||||
|
({String id, MediaRelay relay})? _warmView() {
|
||||||
|
final w = warm, id = w?.queueId;
|
||||||
|
if (w == null || id == null) return null;
|
||||||
|
return (
|
||||||
|
id: id,
|
||||||
|
relay: MediaRelay(
|
||||||
|
source: w.fetch ? '' : w.sourceId, fetch: w.fetch, live: w.live, tracks: w.live ? w.tracks : const []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps [warm] on Up next's first item when it is relayed: a link the
|
||||||
|
/// server fetches (its fetch starts now), a link passed on or a file
|
||||||
|
/// (its adder's app feeds it, while someone else is here to read it;
|
||||||
|
/// not the host's own file), a
|
||||||
|
/// Jellyfin item that someone here cannot play and the server's login
|
||||||
|
/// does not cover (the first member with an account feeds it). Called
|
||||||
|
/// on every change; anything else drops it.
|
||||||
|
void syncWarm() {
|
||||||
|
final first = engine.queue.items.firstOrNull;
|
||||||
|
final w = warm;
|
||||||
|
if (w != null && (first == null || w.queueId != first.id || !_keepWarm(w, first))) _setWarm(null);
|
||||||
|
if (warm == null && first != null) _setWarm(_newWarm(first));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _othersHere(String? memberId) => engine.members.values.any((m) => m.id != memberId);
|
||||||
|
|
||||||
|
/// A Jellyfin [ref] someone here cannot play while someone can, outside
|
||||||
|
/// the server's login.
|
||||||
|
bool _jellyfinNeedsWarm(JellyfinRef ref) =>
|
||||||
|
!_onLogin(ref) &&
|
||||||
|
engine.members.values.any((m) => !m.out && m.jf[ref.serverId] != true) &&
|
||||||
|
engine.members.values.any((m) => m.jf[ref.serverId] == true);
|
||||||
|
|
||||||
|
bool _keepWarm(ActiveRelay w, QueueItem first) {
|
||||||
|
switch (first.how) {
|
||||||
|
case QueueHow.server:
|
||||||
|
return w.fetch;
|
||||||
|
case QueueHow.through:
|
||||||
|
case QueueHow.file:
|
||||||
|
final adder = engine.byId(first.by);
|
||||||
|
return adder != null && adder.token == w.sourceToken && _othersHere(adder.id);
|
||||||
|
case QueueHow.jellyfin:
|
||||||
|
final ref = first.media.jellyfin;
|
||||||
|
final src = w.sourceToken;
|
||||||
|
return ref != null && _jellyfinNeedsWarm(ref) && (src == null || engine.members.containsKey(src));
|
||||||
|
case QueueHow.direct:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActiveRelay? _newWarm(QueueItem first) {
|
||||||
|
final id = first.id;
|
||||||
|
final name = sanitizeMediaName(first.media.name.isEmpty ? first.title : first.media.name);
|
||||||
|
late final ActiveRelay r;
|
||||||
|
switch (first.how) {
|
||||||
|
case QueueHow.server:
|
||||||
|
final url = Uri.tryParse(engine.queue.privateUrlOf(id) ?? '');
|
||||||
|
if (url == null || session.secret.isEmpty) return null;
|
||||||
|
r = ActiveRelay.fetchLink(-1, name, url, ytDlp: ytDlp, queueId: id, onLiveChanged: () => relayLiveChanged(r));
|
||||||
|
case QueueHow.through:
|
||||||
|
case QueueHow.file:
|
||||||
|
final adder = engine.byId(first.by);
|
||||||
|
// The host's own file plays from its `/m` URL, never relayed.
|
||||||
|
if (adder == null || !_othersHere(adder.id) || (adder.host && first.how == QueueHow.file)) return null;
|
||||||
|
r = ActiveRelay.warmFromMember(id, name, adder.token,
|
||||||
|
sourceId: adder.id, onLiveChanged: () => relayLiveChanged(r));
|
||||||
|
case QueueHow.jellyfin:
|
||||||
|
final ref = first.media.jellyfin;
|
||||||
|
if (ref == null || !_jellyfinNeedsWarm(ref)) return null;
|
||||||
|
r = ActiveRelay.warmFromMember(id, name, null, onLiveChanged: () => relayLiveChanged(r));
|
||||||
|
case QueueHow.direct:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The relay `/r/.../<key>/...` reads: the current one by its media id,
|
||||||
|
/// or by the queue id it was started for, or the warm one.
|
||||||
|
ActiveRelay? relayAt(String key) {
|
||||||
|
final r = relay, w = warm;
|
||||||
|
final mediaId = int.tryParse(key);
|
||||||
|
if (mediaId != null) return r != null && r.mediaId == mediaId ? r : null;
|
||||||
|
if (key.isEmpty) return null;
|
||||||
|
if (w != null && w.queueId == key) return w;
|
||||||
|
if (r != null && r.queueId == key) return r;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The relay member [token] may feed on its channel: the current one
|
||||||
|
/// ([queueId] null), or the one of Up next item [queueId] (warm or
|
||||||
|
/// already promoted). A warm Jellyfin item takes the first member with
|
||||||
|
/// an account on that server that dials in.
|
||||||
|
ActiveRelay? sourceOf(String token, String? queueId) {
|
||||||
|
if (queueId == null || queueId.isEmpty) {
|
||||||
|
final r = relay;
|
||||||
|
return r != null && r.sourceToken == token ? r : null;
|
||||||
|
}
|
||||||
|
final r = relayAt(queueId);
|
||||||
|
if (r == null || r.fetch) return null;
|
||||||
|
if (r.sourceToken == null && identical(r, warm)) {
|
||||||
|
final ref = engine.queue.item(queueId)?.media.jellyfin;
|
||||||
|
final m = engine.members[token];
|
||||||
|
if (ref == null || m == null || m.jf[ref.serverId] != true) return null;
|
||||||
|
r
|
||||||
|
..sourceToken = token
|
||||||
|
..sourceId = m.id;
|
||||||
|
broadcastState();
|
||||||
|
}
|
||||||
|
return r.sourceToken == token ? r : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A swap of Up next item [queueId] by [token]: its warm relay, when it
|
||||||
|
/// can become the media's (the same kind of relay; a member's from its
|
||||||
|
/// own source with the same tracks), else null. Taken either way: the
|
||||||
|
/// item goes up now.
|
||||||
|
ActiveRelay? takeWarm(String? queueId, String token, {required bool fetch, List<RelayTrackSpec> tracks = const []}) {
|
||||||
|
final w = warm;
|
||||||
|
if (queueId == null || w == null || w.queueId != queueId) return null;
|
||||||
|
warm = null;
|
||||||
|
final ok = fetch
|
||||||
|
? w.fetch && !w.failed
|
||||||
|
: !w.fetch && w.sourceToken == token && (!w.known || _sameTracks(w.tracks, tracks));
|
||||||
|
if (ok) {
|
||||||
|
w.ensureTracks(tracks);
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
w.dispose();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _sameTracks(List<RelayTrackSpec> a, List<RelayTrackSpec> b) =>
|
||||||
|
a.length == b.length && [for (var i = 0; i < a.length; i++) a[i] == b[i]].every((x) => x);
|
||||||
|
|
||||||
/// `media.relay` for a relayed swap by [token]: who serves the bytes
|
/// `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).
|
/// and, from a member, its tracks (the server finds a fetched link's).
|
||||||
MediaRelay relayFor(String token, MediaRelay requested) => requested.fetch
|
MediaRelay relayFor(String token, MediaRelay requested) => requested.fetch
|
||||||
? const MediaRelay(fetch: true)
|
? const MediaRelay(fetch: true)
|
||||||
: MediaRelay(source: engine.members[token]?.id ?? '', tracks: requested.tracks);
|
: MediaRelay(source: engine.members[token]?.id ?? '', tracks: requested.tracks);
|
||||||
|
|
||||||
|
/// `media.relay` of a promoted warm relay [w] (Up next item [queueId]).
|
||||||
|
MediaRelay promotedRelay(ActiveRelay w, String queueId) => w.fetch
|
||||||
|
? MediaRelay(fetch: true, live: w.live, tracks: w.live ? w.tracks : const [], queueId: queueId)
|
||||||
|
: MediaRelay(source: w.sourceId, live: w.live, tracks: w.tracks, queueId: queueId);
|
||||||
|
|
||||||
/// The relay's channel came up or went down, or a fetched link was read:
|
/// The relay's channel came up or went down, or a fetched link was read:
|
||||||
/// `media.relay.live` and `tracks` follow.
|
/// `media.relay.live` and `tracks` follow (the warm one's in `queue`).
|
||||||
void relayLiveChanged(ActiveRelay r) {
|
void relayLiveChanged(ActiveRelay r) {
|
||||||
|
if (identical(warm, r)) {
|
||||||
|
broadcastState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
final m = engine.media;
|
final m = engine.media;
|
||||||
if (!identical(relay, r) || m?.relay == null) return;
|
if (!identical(relay, r) || m?.relay == null) return;
|
||||||
final was = m!.relay!;
|
final was = m!.relay!;
|
||||||
@@ -192,6 +352,7 @@ class ServerHub {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void changed() {
|
void changed() {
|
||||||
|
syncWarm();
|
||||||
broadcastState();
|
broadcastState();
|
||||||
broadcastFeed();
|
broadcastFeed();
|
||||||
}
|
}
|
||||||
@@ -301,7 +462,8 @@ void mountSyncRoute(
|
|||||||
String Function(String sid, String token, SessionMedia? media, String host)
|
String Function(String sid, String token, SessionMedia? media, String host)
|
||||||
urlBuilder, {
|
urlBuilder, {
|
||||||
String Function(String sid, String token, String itemId, String host)? relayUrlBuilder,
|
String Function(String sid, String token, String itemId, String host)? relayUrlBuilder,
|
||||||
String Function(String sid, String token, int mediaId, String name, List<RelayTrackSpec> tracks, String host)?
|
/// `key`: the media id, or the queue id of a relay started in Up next.
|
||||||
|
String Function(String sid, String token, String key, String name, List<RelayTrackSpec> tracks, String host)?
|
||||||
relayMediaUrlBuilder,
|
relayMediaUrlBuilder,
|
||||||
}) {
|
}) {
|
||||||
app.get('/sync', (final req) {
|
app.get('/sync', (final req) {
|
||||||
@@ -374,15 +536,22 @@ void mountSyncRoute(
|
|||||||
// Relayed for viewers without access: from the swapper's
|
// Relayed for viewers without access: from the swapper's
|
||||||
// machine (its own sign-in never leaves it).
|
// machine (its own sign-in never leaves it).
|
||||||
final relayReq = parsed.relay == null || parsed.relay!.fetch ? null : parsed.relay;
|
final relayReq = parsed.relay == null || parsed.relay!.fetch ? null : parsed.relay;
|
||||||
|
// Up next's warm relay of this item carries on as its relay.
|
||||||
|
final promoted = relayReq == null || ref == null ? null : hub.takeWarm(queueId, token, fetch: false);
|
||||||
final media = ref == null
|
final media = ref == null
|
||||||
? null
|
? null
|
||||||
: HelloMedia(
|
: HelloMedia(
|
||||||
name: sanitizeMediaName(parsed.name.isEmpty ? ref.title : parsed.name),
|
name: promoted?.name ?? sanitizeMediaName(parsed.name.isEmpty ? ref.title : parsed.name),
|
||||||
size: parsed.size,
|
size: parsed.size,
|
||||||
duration: parsed.duration,
|
duration: parsed.duration,
|
||||||
jellyfin: ref,
|
jellyfin: ref,
|
||||||
relay: relayReq == null ? null : hub.relayFor(token, const MediaRelay()));
|
relay: relayReq == null
|
||||||
|
? null
|
||||||
|
: promoted != null
|
||||||
|
? hub.promotedRelay(promoted, queueId!)
|
||||||
|
: hub.relayFor(token, const MediaRelay()));
|
||||||
if (media == null || !hub.engine.applySwap(token, media, queueId: queueId)) {
|
if (media == null || !hub.engine.applySwap(token, media, queueId: queueId)) {
|
||||||
|
promoted?.dispose();
|
||||||
wsErr(ws, 'bad swap media');
|
wsErr(ws, 'bad swap media');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -392,7 +561,11 @@ void mountSyncRoute(
|
|||||||
duration: media.duration,
|
duration: media.duration,
|
||||||
jellyfin: ref,
|
jellyfin: ref,
|
||||||
mediaId: hub.engine.mediaId);
|
mediaId: hub.engine.mediaId);
|
||||||
hub.setRelay(relayReq == null ? null : _memberRelay(hub, token, media.name, const []));
|
hub.setRelay(relayReq == null
|
||||||
|
? null
|
||||||
|
: promoted != null
|
||||||
|
? (promoted..promote(hub.engine.mediaId))
|
||||||
|
: _memberRelay(hub, token, media.name, const []));
|
||||||
hub.changed();
|
hub.changed();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -415,27 +588,38 @@ void mountSyncRoute(
|
|||||||
wsErr(ws, 'server fetch needs a server password');
|
wsErr(ws, 'server fetch needs a server password');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Up next's warm relay of this item carries on as its relay
|
||||||
|
// (its buffer, its feed, its `/r/.../<queueId>/` URLs).
|
||||||
|
final promoted = hub.takeWarm(queueId, token, fetch: relayReq.fetch, tracks: relayReq.tracks);
|
||||||
|
final shown = promoted?.name ?? name;
|
||||||
final media = HelloMedia(
|
final media = HelloMedia(
|
||||||
name: name,
|
name: shown,
|
||||||
size: parsed.size,
|
size: parsed.size,
|
||||||
duration: parsed.duration,
|
duration: parsed.duration,
|
||||||
relay: hub.relayFor(token, relayReq));
|
relay: promoted != null ? hub.promotedRelay(promoted, queueId!) : hub.relayFor(token, relayReq));
|
||||||
if (!hub.engine.applySwap(token, media, queueId: queueId)) {
|
if (!hub.engine.applySwap(token, media, queueId: queueId)) {
|
||||||
|
promoted?.dispose();
|
||||||
wsErr(ws, 'bad swap media');
|
wsErr(ws, 'bad swap media');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
session.media = SessionMedia(
|
session.media = SessionMedia(
|
||||||
name: name, size: parsed.size, duration: parsed.duration, mediaId: hub.engine.mediaId);
|
name: shown, size: parsed.size, duration: parsed.duration, mediaId: hub.engine.mediaId);
|
||||||
late final ActiveRelay active;
|
void failed(String why) {
|
||||||
active = relayReq.fetch
|
|
||||||
? ActiveRelay.fetch(hub.engine.mediaId, name, link!,
|
|
||||||
ytDlp: hub.ytDlp,
|
|
||||||
onLiveChanged: () => hub.relayLiveChanged(active),
|
|
||||||
onFail: (why) {
|
|
||||||
wsErr(ws, hub.fetchFailText(why));
|
wsErr(ws, hub.fetchFailText(why));
|
||||||
hub.fetchFailed(name);
|
hub.fetchFailed(shown);
|
||||||
})
|
}
|
||||||
|
late final ActiveRelay active;
|
||||||
|
if (promoted != null) {
|
||||||
|
active = promoted..promote(hub.engine.mediaId);
|
||||||
|
if (active.fetch) {
|
||||||
|
active.onFail = failed;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
active = relayReq.fetch
|
||||||
|
? ActiveRelay.fetchLink(hub.engine.mediaId, name, link!,
|
||||||
|
ytDlp: hub.ytDlp, onLiveChanged: () => hub.relayLiveChanged(active), onFail: failed)
|
||||||
: _memberRelay(hub, token, name, media.relay!.tracks);
|
: _memberRelay(hub, token, name, media.relay!.tracks);
|
||||||
|
}
|
||||||
hub.setRelay(active);
|
hub.setRelay(active);
|
||||||
hub.changed();
|
hub.changed();
|
||||||
return;
|
return;
|
||||||
@@ -626,8 +810,8 @@ void mountSyncRoute(
|
|||||||
relayed.mediaId == media.mediaId &&
|
relayed.mediaId == media.mediaId &&
|
||||||
relayMediaUrlBuilder != null
|
relayMediaUrlBuilder != null
|
||||||
? (relayed.known
|
? (relayed.known
|
||||||
? relayMediaUrlBuilder(
|
? relayMediaUrlBuilder('default', activeToken, relayed.queueId ?? '${relayed.mediaId}', media.name,
|
||||||
'default', activeToken, relayed.mediaId, relayed.name, relayed.tracks, dialledHost)
|
relayed.tracks, dialledHost)
|
||||||
: null)
|
: null)
|
||||||
: media?.file != null
|
: media?.file != null
|
||||||
? urlBuilder('default', activeToken, media, dialledHost)
|
? urlBuilder('default', activeToken, media, dialledHost)
|
||||||
@@ -656,6 +840,6 @@ void mountSyncRoute(
|
|||||||
ActiveRelay _memberRelay(ServerHub hub, String token, String name, List<RelayTrackSpec> tracks) {
|
ActiveRelay _memberRelay(ServerHub hub, String token, String name, List<RelayTrackSpec> tracks) {
|
||||||
late final ActiveRelay r;
|
late final ActiveRelay r;
|
||||||
r = ActiveRelay.fromMember(hub.engine.mediaId, name, token,
|
r = ActiveRelay.fromMember(hub.engine.mediaId, name, token,
|
||||||
tracks: tracks, onLiveChanged: () => hub.relayLiveChanged(r));
|
tracks: tracks, sourceId: hub.engine.members[token]?.id ?? '', onLiveChanged: () => hub.relayLiveChanged(r));
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ class SyncServer {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
mountSessionMediaRoute(_app, sessions);
|
mountSessionMediaRoute(_app, sessions);
|
||||||
mountRelayRoutes(_app, sessions, () => hub.relay);
|
mountRelayRoutes(_app, sessions, relayAt: hub.relayAt, sourceOf: hub.sourceOf);
|
||||||
mountAvatarRoute(_app, hub);
|
mountAvatarRoute(_app, hub);
|
||||||
mountArtRoute(_app, hub);
|
mountArtRoute(_app, hub);
|
||||||
mountJellyfinRelayRoute(
|
mountJellyfinRelayRoute(
|
||||||
@@ -108,8 +108,8 @@ class SyncServer {
|
|||||||
return 'http://${authority(dialledHost)}/m/$sid/$token/$name';
|
return 'http://${authority(dialledHost)}/m/$sid/$token/$name';
|
||||||
}, relayUrlBuilder: (sid, token, itemId, dialledHost) =>
|
}, 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, tracks, dialledHost) =>
|
relayMediaUrlBuilder: (sid, token, key, name, tracks, dialledHost) =>
|
||||||
relayViewerUrl('http://${authority(dialledHost)}/r/$sid/$token/$mediaId', name, tracks: tracks));
|
relayViewerUrl('http://${authority(dialledHost)}/r/$sid/$token/$key', name, tracks: tracks));
|
||||||
_app.fallback = (final req) => Response.notFound(
|
_app.fallback = (final req) => Response.notFound(
|
||||||
body: Body.fromString('not found'),
|
body: Body.fromString('not found'),
|
||||||
);
|
);
|
||||||
@@ -181,7 +181,7 @@ class SyncServer {
|
|||||||
_heartbeat = null;
|
_heartbeat = null;
|
||||||
_relayClient?.close(force: true);
|
_relayClient?.close(force: true);
|
||||||
_relayClient = null;
|
_relayClient = null;
|
||||||
hub.setRelay(null);
|
hub.dropRelays();
|
||||||
await hub.closeAllSockets();
|
await hub.closeAllSockets();
|
||||||
await _app.close();
|
await _app.close();
|
||||||
_server = null;
|
_server = null;
|
||||||
|
|||||||
@@ -92,9 +92,10 @@ void main() {
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<RelaySource> startSource(_Member m, {RelayMedia? from}) async {
|
Future<RelaySource> startSource(_Member m, {RelayMedia? from, String? queueId}) async {
|
||||||
final src = RelaySource(
|
final src = RelaySource(
|
||||||
Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/${m.welcome.token}/source'),
|
Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/${m.welcome.token}/source'
|
||||||
|
'${queueId == null ? '' : '?q=$queueId'}'),
|
||||||
from ?? RelayMedia.single(FileRelayBytes(file)),
|
from ?? RelayMedia.single(FileRelayBytes(file)),
|
||||||
backoff: (_) => const Duration(milliseconds: 100));
|
backoff: (_) => const Duration(milliseconds: 100));
|
||||||
await src.start();
|
await src.start();
|
||||||
@@ -444,6 +445,158 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('warm relay (Up next)', () {
|
||||||
|
late HttpServer origin;
|
||||||
|
var originBytes = 0;
|
||||||
|
setUp(() async {
|
||||||
|
originBytes = 0;
|
||||||
|
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;
|
||||||
|
originBytes += end - r.start + 1;
|
||||||
|
req.response.add(bytes.sublist(r.start, end + 1));
|
||||||
|
}
|
||||||
|
await req.response.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
tearDown(() => origin.close(force: true));
|
||||||
|
|
||||||
|
List<Map> queueOf(_Member m) => [for (final q in (m.lastState?['queue'] as List?) ?? const []) q as Map];
|
||||||
|
Map? headRelay(_Member m) => (queueOf(m).firstOrNull?['media'] as Map?)?['relay'] as Map?;
|
||||||
|
|
||||||
|
Future<String> add(_Member m, Map<String, Object?> item, {bool next = false}) async {
|
||||||
|
final had = {for (final q in queueOf(m)) q['id']};
|
||||||
|
m.send({'t': 'queue', 'op': 'add', 'at': next ? 'next' : 'end', 'item': item});
|
||||||
|
await _until(() => queueOf(m).any((q) => !had.contains(q['id'])), 'queued');
|
||||||
|
return queueOf(m).firstWhere((q) => !had.contains(q['id']))['id'] as String;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> through(String key) => {
|
||||||
|
'media': {'name': 'ep 01.mkv', 'size': bytes.length, 'duration': 0},
|
||||||
|
'how': 'through',
|
||||||
|
'title': 'Ep 1',
|
||||||
|
'key': key,
|
||||||
|
};
|
||||||
|
|
||||||
|
String qUrl(_Member v, String queueId) =>
|
||||||
|
'http://127.0.0.1:${server.boundPort}/r/default/${v.welcome.token}/$queueId/${Uri.encodeComponent('ep 01.mkv')}';
|
||||||
|
|
||||||
|
test('a link the server fetches: preloaded from its queue id, promoted at the swap with its buffer', () async {
|
||||||
|
final alice = await join('alice');
|
||||||
|
final bob = await join('bob');
|
||||||
|
final id = await add(alice, {
|
||||||
|
'media': {'name': 'ep 01.mkv', 'size': 0, 'duration': 0, 'url': 'http://127.0.0.1:${origin.port}/x.mkv'},
|
||||||
|
'how': 'server',
|
||||||
|
'title': 'Ep 1',
|
||||||
|
});
|
||||||
|
await _until(() => headRelay(bob)?['live'] == true, 'warm relay live');
|
||||||
|
expect(headRelay(bob), {'fetch': true, 'live': true});
|
||||||
|
expect(jsonEncode(bob.lastState), isNot(contains('x.mkv')));
|
||||||
|
expect((await get(qUrl(bob, id))).$3, bytes);
|
||||||
|
// Small: the warm budget is far over this file, so all of it is in.
|
||||||
|
final fetched = originBytes;
|
||||||
|
alice.send({
|
||||||
|
't': 'swap',
|
||||||
|
'queueId': id,
|
||||||
|
'media': {'name': 'Ep 1', 'size': 0, 'duration': 0, 'relay': {'fetch': true}},
|
||||||
|
});
|
||||||
|
await _until(() => (bob.media?['relay'] as Map?)?['queueId'] == id, 'promoted');
|
||||||
|
expect(bob.media!['relay'], {'fetch': true, 'live': true, 'queueId': id});
|
||||||
|
expect(bob.media!['name'], 'ep 01.mkv', reason: 'the URL name the preload used');
|
||||||
|
expect(queueOf(bob), isEmpty);
|
||||||
|
expect(server.hub.warm, isNull);
|
||||||
|
// The same buffer: both URLs serve it, nothing is fetched again.
|
||||||
|
expect((await get(qUrl(bob, id))).$3, bytes);
|
||||||
|
expect((await get(viewerUrl(bob, mediaIdOf(bob)))).$3, bytes);
|
||||||
|
expect(originBytes, fetched);
|
||||||
|
final carol = await join('carol');
|
||||||
|
expect(carol.welcome.mediaUrl, qUrl(carol, id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a link passed on: its adder feeds it before the swap, and the swap keeps that source', () async {
|
||||||
|
final alice = await join('alice');
|
||||||
|
final bob = await join('bob');
|
||||||
|
final id = await add(alice, through('k-abcdefgh'));
|
||||||
|
expect(headRelay(bob), {'source': alice.welcome.memberId});
|
||||||
|
final src = await startSource(alice, queueId: id);
|
||||||
|
await _until(() => headRelay(bob)?['live'] == true, 'warm relay live');
|
||||||
|
expect((await get(qUrl(bob, id), range: 'bytes=0-99')).$3, bytes.sublist(0, 100));
|
||||||
|
// Only its source may feed it.
|
||||||
|
final impostor = await startSource(bob, queueId: id);
|
||||||
|
await impostor.done.timeout(const Duration(seconds: 5));
|
||||||
|
alice.send({
|
||||||
|
't': 'swap',
|
||||||
|
'queueId': id,
|
||||||
|
'media': {'name': 'ep 01.mkv', 'size': bytes.length, 'duration': 0, 'relay': {}},
|
||||||
|
});
|
||||||
|
await _until(() => (bob.media?['relay'] as Map?)?['queueId'] == id, 'promoted');
|
||||||
|
expect(bob.media!['relay'], {'source': alice.welcome.memberId, 'live': true, 'queueId': id});
|
||||||
|
expect((await get(qUrl(bob, id))).$3, bytes);
|
||||||
|
expect(src.bytesSent, lessThan(bytes.length * 1.3));
|
||||||
|
// Still the source of the media it now relays.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||||
|
expect(server.hub.relay!.live, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dropped when the item stops being first: its URLs 404, its source stops', () async {
|
||||||
|
final alice = await join('alice');
|
||||||
|
final bob = await join('bob');
|
||||||
|
final id = await add(alice, through('k-abcdefgh'));
|
||||||
|
final src = await startSource(alice, queueId: id);
|
||||||
|
await _until(() => headRelay(bob)?['live'] == true, 'warm relay live');
|
||||||
|
await add(alice, through('k-ijklmnop'), next: true);
|
||||||
|
await src.done.timeout(const Duration(seconds: 5));
|
||||||
|
expect((await get(qUrl(bob, id))).$1, 404);
|
||||||
|
expect(server.hub.warm!.queueId, isNot(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('not for an adder alone; dropped when its adder leaves', () async {
|
||||||
|
final alice = await join('alice');
|
||||||
|
await add(alice, through('k-abcdefgh'));
|
||||||
|
expect(headRelay(alice), isNull);
|
||||||
|
final bob = await join('bob');
|
||||||
|
await _until(() => headRelay(bob) != null, 'warm relay once someone could read it');
|
||||||
|
await alice.ws.close();
|
||||||
|
await _until(() => bob.lastState!['members'] is List && (bob.lastState!['members'] as List).length == 1, 'left');
|
||||||
|
expect(queueOf(bob), hasLength(1));
|
||||||
|
expect(headRelay(bob), isNull);
|
||||||
|
expect(server.hub.warm, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Jellyfin item: fed by the first member with an account, for those without', () async {
|
||||||
|
final alice = await join('alice');
|
||||||
|
final bob = await join('bob');
|
||||||
|
void report(_Member m, bool access) => m.send({
|
||||||
|
't': 'report', 'position': 0, 'buffered': 0, 'eventId': 0, 'jf': {'srv1': access}, //
|
||||||
|
});
|
||||||
|
report(alice, true);
|
||||||
|
report(bob, false);
|
||||||
|
final id = await add(alice, {
|
||||||
|
'media': {
|
||||||
|
'name': 'Pilot',
|
||||||
|
'size': 0,
|
||||||
|
'duration': 0,
|
||||||
|
'jellyfin': {'serverId': 'srv1', 'itemId': '0123456789abcdef0123456789abcdef', 'title': 'Pilot'},
|
||||||
|
},
|
||||||
|
'how': 'jellyfin',
|
||||||
|
'title': 'Pilot',
|
||||||
|
});
|
||||||
|
await _until(() => headRelay(bob) != null, 'warm relay');
|
||||||
|
expect(headRelay(bob), isEmpty, reason: 'nobody feeds it yet');
|
||||||
|
final refused = await startSource(bob, queueId: id);
|
||||||
|
await refused.done.timeout(const Duration(seconds: 5));
|
||||||
|
await startSource(alice, queueId: id);
|
||||||
|
await _until(() => headRelay(bob)?['live'] == true, 'live');
|
||||||
|
expect(headRelay(bob)!['source'], alice.welcome.memberId);
|
||||||
|
report(bob, true);
|
||||||
|
await _until(() => headRelay(alice) == null, 'dropped once everyone has an account');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('buffer', () {
|
group('buffer', () {
|
||||||
// A feed that serves [bytes] in-process and counts what it sends.
|
// A feed that serves [bytes] in-process and counts what it sends.
|
||||||
late RelayBuffer buf;
|
late RelayBuffer buf;
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('core welcome + proto', () {
|
group('core welcome + proto', () {
|
||||||
test('proto 12: avatars by hash, profile intent', () {
|
test('proto 14: avatars by hash, profile intent', () {
|
||||||
expect(RelayConstants.protoVersion, 13);
|
expect(RelayConstants.protoVersion, 14);
|
||||||
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false);
|
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false);
|
||||||
expect(v.toJson().containsKey('avatarHash'), isFalse);
|
expect(v.toJson().containsKey('avatarHash'), isFalse);
|
||||||
expect(v.toJson().containsKey('avatar'), isFalse);
|
expect(v.toJson().containsKey('avatar'), isFalse);
|
||||||
|
|||||||
Reference in new issue
Block a user