Relay: a member's media through the server's buffer (proto 8)
Viewers who cannot reach the media get it through the server: a file on
any member's computer, a link passed on by a member ("Through me") or
fetched by the server ("Server fetches it", password-protected servers
only), and Jellyfin items for guests without an account, sourced by
whoever put them up with their own sign-in (or a pasted link's key),
which never leaves their machine. Fixes guests on a dedicated server
without a Jellyfin login being blocked.
The source dials /r/.../source (works behind NAT) and answers pulls with
an adaptive in-flight window (2 x rate x min RTT, 256 KiB..8 MiB). The
server keeps a memory-only buffer of 1 MiB slices sized like a viewer's
mpv buffer (pinned head/tail, 64 MiB back, 256 MiB ahead), fills it with
one sequential pull at a time (blocked viewers first, newest request
only), fetches nothing while nobody watches through it, and drops it on
the next swap or stop. Measured with mpv through shaped links
(tool/relay_e2e.dart): 400 Mbit/s seeks ~0.03 s; 40 Mbit/s no stalls,
3 viewers cost the source ~1x the bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9c5733719b
commit
e7f33b5dc3
27 files changed
+2438
-116
No files matched your search
@@ -169,13 +169,16 @@ Watching:
|
|||||||
your watched state, Continue watching and resume point follow what you
|
your watched state, Continue watching and resume point follow what you
|
||||||
saw (reported on start, every 10 s, on pause, resume, seek and track
|
saw (reported on start, every 10 s, on pause, resume, seek and track
|
||||||
change, and when it stops).
|
change, and when it stops).
|
||||||
- Friends without an account watch through the host: the server fetches
|
- Friends without an account watch through the server: whoever put the
|
||||||
the file with the host's sign-in and passes it on (only the current item,
|
item up passes it on automatically, with their own sign-in, which never
|
||||||
only to people in the session). They show "via host" in the member list.
|
leaves their computer (only the current item, only to people in the
|
||||||
A dedicated server relays when started with `--jellyfin <file>` (see
|
session). They show "via host" in the member list. When the app hosts,
|
||||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay).
|
or a dedicated server has its own login (`--jellyfin <file>`, see
|
||||||
- Without either, the app says "This needs a Jellyfin account on ..." with
|
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server
|
||||||
a button to link one; it plays as soon as you are signed in.
|
fetches it itself instead.
|
||||||
|
- If nobody can pass it on (the person who put it up left), the app says
|
||||||
|
"No Jellyfin account here for ..." with a button to link one; it plays as
|
||||||
|
soon as you are signed in.
|
||||||
|
|
||||||
Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`,
|
Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`,
|
||||||
Shift+P / Shift+N) put up the previous or next episode for everyone. From
|
Shift+P / Shift+N) put up the previous or next episode for everyone. From
|
||||||
@@ -194,7 +197,24 @@ next episode. This is per person; nobody else is affected.
|
|||||||
|
|
||||||
"Change video" in the session panel (Tab) opens the same picker as the Host
|
"Change video" in the session panel (Tab) opens the same picker as the Host
|
||||||
tab: the library starts on the series you are watching with the current
|
tab: the library starts on the series you are watching with the current
|
||||||
season, else where you left it. File is offered to the host only.
|
season, else where you left it.
|
||||||
|
|
||||||
|
Anyone can put up a file from their own computer, also on a dedicated
|
||||||
|
server: it streams from your computer through the server to everyone (the
|
||||||
|
server keeps a few minutes in memory, so seeks and skipping the opening
|
||||||
|
are quick). Keep the app open while it plays. For a link you choose how
|
||||||
|
the others get it:
|
||||||
|
- Everyone fetches it (default): each app plays the link itself.
|
||||||
|
- Server fetches it: the server downloads it once for everyone, for links
|
||||||
|
that are slow or blocked for some. The server must be able to reach it,
|
||||||
|
and it must have a password (an open server refuses: anyone could make
|
||||||
|
it fetch addresses only it can reach).
|
||||||
|
- Through me: your computer downloads it and passes it on, for links only
|
||||||
|
you can open.
|
||||||
|
|
||||||
|
The last two need a link to a video file (not a page like YouTube) and
|
||||||
|
never show the link to anyone. Your upload has to beat the video's
|
||||||
|
bitrate: a 25-minute 2 GB episode is about 11 Mbit/s.
|
||||||
|
|
||||||
## Video quality and shaders
|
## Video quality and shaders
|
||||||
|
|
||||||
|
|||||||
@@ -201,7 +201,8 @@ final class OwnPlay extends JellyfinPlan {
|
|||||||
bool get jfAccess => true;
|
bool get jfAccess => true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Through the sync server's relay (`/j/...`).
|
/// Through the server: its own Jellyfin login (`/j/...`) or the member
|
||||||
|
/// who put it up (`/r/...`).
|
||||||
final class RelayPlay extends JellyfinPlan {
|
final class RelayPlay extends JellyfinPlan {
|
||||||
final String url;
|
final String url;
|
||||||
const RelayPlay(this.url);
|
const RelayPlay(this.url);
|
||||||
@@ -210,7 +211,7 @@ final class RelayPlay extends JellyfinPlan {
|
|||||||
bool get jfAccess => false;
|
bool get jfAccess => false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Neither: no (usable) account here and the server cannot relay.
|
/// Neither: no (usable) account here and nothing relays it.
|
||||||
final class BlockedPlay extends JellyfinPlan {
|
final class BlockedPlay extends JellyfinPlan {
|
||||||
final String serverId;
|
final String serverId;
|
||||||
/// Known only when this install has (had) an account there.
|
/// Known only when this install has (had) an account there.
|
||||||
@@ -224,20 +225,22 @@ final class BlockedPlay extends JellyfinPlan {
|
|||||||
|
|
||||||
String get message {
|
String get message {
|
||||||
final where = serverName == null || serverName!.isEmpty ? 'that Jellyfin server' : serverName!;
|
final where = serverName == null || serverName!.isEmpty ? 'that Jellyfin server' : serverName!;
|
||||||
final base = 'This needs a Jellyfin account on $where (or the host can relay)';
|
final base = 'No Jellyfin account here for $where, and nobody is passing it on';
|
||||||
return problem == null ? base : '$base — $problem';
|
return problem == null ? base : '$base ($problem)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Picks how to play [ref]: this install's account first (PlaybackInfo
|
/// Picks how to play [ref]: this install's account first (PlaybackInfo
|
||||||
/// with its own user; a 401 marks the account expired), else the relay
|
/// with its own user; a 401 marks the account expired), else the server's
|
||||||
/// when the server offers it ([relay] = `state.relay`, [relayUrl] this
|
/// own Jellyfin login ([relay] = `state.relay`, [relayUrl] this member's
|
||||||
/// member's `/j/...` URL), else [BlockedPlay].
|
/// `/j/...` URL), else the member who put it up ([memberRelayUrl], the
|
||||||
|
/// `/r/...` URL when `media.relay` is set), else [BlockedPlay].
|
||||||
Future<JellyfinPlan> resolveJellyfinRef(
|
Future<JellyfinPlan> resolveJellyfinRef(
|
||||||
JellyfinAccounts accounts,
|
JellyfinAccounts accounts,
|
||||||
JellyfinRef ref, {
|
JellyfinRef ref, {
|
||||||
required bool relay,
|
required bool relay,
|
||||||
required String? relayUrl,
|
required String? relayUrl,
|
||||||
|
String? memberRelayUrl,
|
||||||
Duration timeout = const Duration(seconds: 15),
|
Duration timeout = const Duration(seconds: 15),
|
||||||
}) async {
|
}) async {
|
||||||
await accounts.ensureLoaded();
|
await accounts.ensureLoaded();
|
||||||
@@ -282,6 +285,7 @@ Future<JellyfinPlan> resolveJellyfinRef(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (relay && relayUrl != null && relayUrl.isNotEmpty) return RelayPlay(relayUrl);
|
if (relay && relayUrl != null && relayUrl.isNotEmpty) return RelayPlay(relayUrl);
|
||||||
|
if (memberRelayUrl != null && memberRelayUrl.isNotEmpty) return RelayPlay(memberRelayUrl);
|
||||||
return BlockedPlay(serverId: ref.serverId, serverName: account?.displayName, problem: problem);
|
return BlockedPlay(serverId: ref.serverId, serverName: account?.displayName, problem: problem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ class MediaPickerController extends ChangeNotifier {
|
|||||||
final TextEditingController file;
|
final TextEditingController file;
|
||||||
MediaKind _source;
|
MediaKind _source;
|
||||||
String? _notice;
|
String? _notice;
|
||||||
|
LinkRelay _linkRelay = LinkRelay.direct;
|
||||||
|
|
||||||
|
LinkRelay get linkRelay => _linkRelay;
|
||||||
|
set linkRelay(LinkRelay v) {
|
||||||
|
if (v == _linkRelay) return;
|
||||||
|
_linkRelay = v;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
JellyfinMedia? _jellyfin;
|
JellyfinMedia? _jellyfin;
|
||||||
|
|
||||||
/// The Jellyfin item picked in the browser (kept while another source
|
/// The Jellyfin item picked in the browser (kept while another source
|
||||||
@@ -71,7 +79,7 @@ class MediaPickerController extends ChangeNotifier {
|
|||||||
switch (_source) {
|
switch (_source) {
|
||||||
case MediaKind.link:
|
case MediaKind.link:
|
||||||
final url = link.text.trim();
|
final url = link.text.trim();
|
||||||
return url.isEmpty ? null : LinkMedia(url);
|
return url.isEmpty ? null : LinkMedia(url, relay: _linkRelay);
|
||||||
case MediaKind.file:
|
case MediaKind.file:
|
||||||
final path = file.text.trim();
|
final path = file.text.trim();
|
||||||
return path.isEmpty ? null : FileMedia(path);
|
return path.isEmpty ? null : FileMedia(path);
|
||||||
@@ -117,15 +125,21 @@ class MediaPicker extends StatefulWidget {
|
|||||||
final bool expanded;
|
final bool expanded;
|
||||||
/// Where the Jellyfin browser opens instead of its remembered location.
|
/// Where the Jellyfin browser opens instead of its remembered location.
|
||||||
final BrowseStart? jellyfinStart;
|
final BrowseStart? jellyfinStart;
|
||||||
/// False hides the File source (only the host can serve a file).
|
/// False hides the File source.
|
||||||
final bool allowFile;
|
final bool allowFile;
|
||||||
/// Shown under the Link field instead of the default help.
|
/// Shown under the Link field instead of the default help.
|
||||||
final String? linkHelp;
|
final String? linkHelp;
|
||||||
|
/// Shown under the File field instead of the default help.
|
||||||
|
final String? fileHelp;
|
||||||
|
/// Offer the [LinkRelay] choice under the Link field (in a session).
|
||||||
|
final bool linkRelayChoice;
|
||||||
const MediaPicker({
|
const MediaPicker({
|
||||||
super.key,
|
super.key,
|
||||||
this.jellyfinStart,
|
this.jellyfinStart,
|
||||||
this.allowFile = true,
|
this.allowFile = true,
|
||||||
this.linkHelp,
|
this.linkHelp,
|
||||||
|
this.fileHelp,
|
||||||
|
this.linkRelayChoice = false,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
this.onSubmit,
|
this.onSubmit,
|
||||||
@@ -247,10 +261,25 @@ class _MediaPickerState extends State<MediaPicker> {
|
|||||||
icon: const Icon(Icons.content_paste, size: 18),
|
icon: const Icon(Icons.content_paste, size: 18),
|
||||||
label: const Text('Paste'),
|
label: const Text('Paste'),
|
||||||
),
|
),
|
||||||
help: widget.linkHelp ??
|
help: widget.linkRelayChoice
|
||||||
'A direct http(s) link to a video file; everyone streams it from there. '
|
? _linkRelayHelp(_c.linkRelay)
|
||||||
'Jellyfin links are shared as the item, without your sign-in.',
|
: widget.linkHelp ??
|
||||||
|
'A direct http(s) link to a video file; everyone streams it from there. '
|
||||||
|
'Jellyfin links are shared as the item, without your sign-in.',
|
||||||
notice: _c.notice,
|
notice: _c.notice,
|
||||||
|
extra: widget.linkRelayChoice
|
||||||
|
? SegmentedButton<LinkRelay>(
|
||||||
|
key: const ValueKey('link-relay'),
|
||||||
|
showSelectedIcon: false,
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: LinkRelay.direct, label: Text('Everyone fetches it')),
|
||||||
|
ButtonSegment(value: LinkRelay.server, label: Text('Server fetches it')),
|
||||||
|
ButtonSegment(value: LinkRelay.me, label: Text('Through me')),
|
||||||
|
],
|
||||||
|
selected: {_c.linkRelay},
|
||||||
|
onSelectionChanged: (s) => _c.linkRelay = s.first,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
MediaKind.file => _FieldRow(
|
MediaKind.file => _FieldRow(
|
||||||
key: const ValueKey('media-file'),
|
key: const ValueKey('media-file'),
|
||||||
@@ -264,7 +293,8 @@ class _MediaPickerState extends State<MediaPicker> {
|
|||||||
icon: const Icon(Icons.folder_open, size: 18),
|
icon: const Icon(Icons.folder_open, size: 18),
|
||||||
label: const Text('Browse'),
|
label: const Text('Browse'),
|
||||||
),
|
),
|
||||||
help: 'Friends stream it from you while you watch. Optional — you can host without media.',
|
help: widget.fileHelp ??
|
||||||
|
'Friends stream it from you while you watch. Optional — you can host without media.',
|
||||||
notice: _c.notice,
|
notice: _c.notice,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -325,8 +355,10 @@ class _FieldRow extends StatelessWidget {
|
|||||||
final Widget button;
|
final Widget button;
|
||||||
final String help;
|
final String help;
|
||||||
final String? notice;
|
final String? notice;
|
||||||
|
/// Under the field, above the help (e.g. the link relay choice).
|
||||||
|
final Widget? extra;
|
||||||
const _FieldRow(
|
const _FieldRow(
|
||||||
{super.key, required this.field, required this.button, required this.help, this.notice});
|
{super.key, required this.field, required this.button, required this.help, this.notice, this.extra});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -346,6 +378,7 @@ class _FieldRow extends StatelessWidget {
|
|||||||
button,
|
button,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (extra != null) ...[const SizedBox(height: 10), extra!],
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(notice ?? help, style: small),
|
Text(notice ?? help, style: small),
|
||||||
],
|
],
|
||||||
@@ -354,3 +387,12 @@ class _FieldRow extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _linkRelayHelp(LinkRelay r) => switch (r) {
|
||||||
|
LinkRelay.direct => 'Everyone streams the link themselves. Jellyfin links are shared as the item, '
|
||||||
|
'without your sign-in.',
|
||||||
|
LinkRelay.server => 'The server downloads it once and streams it to everyone: for links that are '
|
||||||
|
'slow or blocked for some. The server must be able to reach the link; it is not shown to anyone.',
|
||||||
|
LinkRelay.me => 'This computer downloads it and passes it on through the server: for links only you '
|
||||||
|
'can open. Keep the app open while it plays; the link is not shown to anyone.',
|
||||||
|
};
|
||||||
@@ -7,10 +7,16 @@ sealed class PickedMedia {
|
|||||||
String get label;
|
String get label;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A direct http(s) link everyone streams from.
|
/// How the others get a link: each fetches it ([direct]), the server
|
||||||
|
/// fetches it once for everyone ([server]), or this computer does and
|
||||||
|
/// passes it on through the server ([me]; for links only you can reach).
|
||||||
|
enum LinkRelay { direct, server, me }
|
||||||
|
|
||||||
|
/// An http(s) link.
|
||||||
final class LinkMedia extends PickedMedia {
|
final class LinkMedia extends PickedMedia {
|
||||||
final String url;
|
final String url;
|
||||||
const LinkMedia(this.url);
|
final LinkRelay relay;
|
||||||
|
const LinkMedia(this.url, {this.relay = LinkRelay.direct});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get label {
|
String get label {
|
||||||
@@ -25,14 +31,15 @@ final class LinkMedia extends PickedMedia {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => other is LinkMedia && other.url == url;
|
bool operator ==(Object other) => other is LinkMedia && other.url == url && other.relay == relay;
|
||||||
@override
|
@override
|
||||||
int get hashCode => url.hashCode;
|
int get hashCode => Object.hash(url, relay);
|
||||||
@override
|
@override
|
||||||
String toString() => 'LinkMedia($url)';
|
String toString() => 'LinkMedia($url)';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A file on this computer; friends stream it from the host.
|
/// A file on this computer; the others stream it from here (the host's
|
||||||
|
/// app serves it itself, anyone else's goes through the server's relay).
|
||||||
final class FileMedia extends PickedMedia {
|
final class FileMedia extends PickedMedia {
|
||||||
final String path;
|
final String path;
|
||||||
const FileMedia(this.path);
|
const FileMedia(this.path);
|
||||||
|
|||||||
@@ -144,7 +144,9 @@ class JellyfinBlockedCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(blocked.message, textAlign: TextAlign.center, style: t.titleSmall),
|
Text(blocked.message, textAlign: TextAlign.center, style: t.titleSmall),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text('Link your own account in Integrations, or ask the host to relay it.',
|
Text(
|
||||||
|
'Whoever put it up passes it on automatically while their app is open; '
|
||||||
|
'if they left, ask them to put it up again. Or link your own account in Integrations.',
|
||||||
textAlign: TextAlign.center, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
textAlign: TextAlign.center, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Wrap(
|
Wrap(
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import '../home/media_picker.dart';
|
|||||||
|
|
||||||
/// "Change video" in a session: the same [MediaPicker] as Home's Host tab
|
/// "Change video" in a session: the same [MediaPicker] as Home's Host tab
|
||||||
/// (Jellyfin browser, Link, File) and a button that puts the pick up for
|
/// (Jellyfin browser, Link, File) and a button that puts the pick up for
|
||||||
/// everyone. File is offered to the host only (the file streams from the
|
/// everyone. A file streams from this computer: the host's app serves it,
|
||||||
/// host's disk). [onSwap] returns an error text, or null when sent.
|
/// anyone else's goes through the server's relay. [onSwap] returns an
|
||||||
|
/// error text, or null when sent.
|
||||||
class SwapPanel extends StatefulWidget {
|
class SwapPanel extends StatefulWidget {
|
||||||
final MediaPickerController controller;
|
final MediaPickerController controller;
|
||||||
final JellyfinAccounts accounts;
|
final JellyfinAccounts accounts;
|
||||||
@@ -35,13 +36,6 @@ class _SwapPanelState extends State<SwapPanel> {
|
|||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
final c = widget.controller;
|
|
||||||
if (!widget.isHost && c.source == MediaKind.file) c.source = MediaKind.link;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _swap() async {
|
Future<void> _swap() async {
|
||||||
final media = widget.controller.selection;
|
final media = widget.controller.selection;
|
||||||
if (media == null || _busy) return;
|
if (media == null || _busy) return;
|
||||||
@@ -91,7 +85,10 @@ class _SwapPanelState extends State<SwapPanel> {
|
|||||||
controller: widget.controller,
|
controller: widget.controller,
|
||||||
jellyfin: widget.accounts,
|
jellyfin: widget.accounts,
|
||||||
enabled: !_busy,
|
enabled: !_busy,
|
||||||
allowFile: widget.isHost,
|
linkRelayChoice: true,
|
||||||
|
fileHelp: widget.isHost
|
||||||
|
? 'Friends stream it from your computer.'
|
||||||
|
: 'Streams from your computer through the server. Keep the app open while it plays.',
|
||||||
jellyfinStart: widget.jellyfinStart,
|
jellyfinStart: widget.jellyfinStart,
|
||||||
onOpenIntegrations: widget.onOpenIntegrations,
|
onOpenIntegrations: widget.onOpenIntegrations,
|
||||||
onSubmit: _swap,
|
onSubmit: _swap,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
@@ -92,6 +93,35 @@ class ToastItem {
|
|||||||
|
|
||||||
const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'};
|
const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'};
|
||||||
|
|
||||||
|
/// `media.relay` of a state, or null when the media is not relayed.
|
||||||
|
MediaRelay? stateMediaRelay(StateBroadcast state) {
|
||||||
|
final r = state.media?['relay'];
|
||||||
|
return r is Map ? MediaRelay.fromJson(Map<String, Object?>.from(r)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What this member serves when its own swap is relayed: set at swap time,
|
||||||
|
/// claimed by the state that names this member as the source.
|
||||||
|
sealed class _RelayOrigin {
|
||||||
|
const _RelayOrigin();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FileOrigin extends _RelayOrigin {
|
||||||
|
final String path;
|
||||||
|
const _FileOrigin(this.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _LinkOrigin extends _RelayOrigin {
|
||||||
|
final String url;
|
||||||
|
const _LinkOrigin(this.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Jellyfin item: served with this install's account, else with the
|
||||||
|
/// pasted [link] (its api_key stays on this machine).
|
||||||
|
final class _JellyfinOrigin extends _RelayOrigin {
|
||||||
|
final String? link;
|
||||||
|
const _JellyfinOrigin(this.link);
|
||||||
|
}
|
||||||
|
|
||||||
String? stateMediaUrl(StateBroadcast state) {
|
String? stateMediaUrl(StateBroadcast state) {
|
||||||
final media = state.media;
|
final media = state.media;
|
||||||
if (media == null) return null;
|
if (media == null) return null;
|
||||||
@@ -184,6 +214,14 @@ class SessionCoordinator {
|
|||||||
String? _openUrl;
|
String? _openUrl;
|
||||||
String _wsHost = '';
|
String _wsHost = '';
|
||||||
int _wsPort = 0;
|
int _wsPort = 0;
|
||||||
|
_RelayOrigin? _pendingOrigin;
|
||||||
|
// What this member serves for media [$1]; survives a reconnect (the
|
||||||
|
// state then looks new again).
|
||||||
|
(int, _RelayOrigin)? _activeOrigin;
|
||||||
|
RelaySource? _relaySource;
|
||||||
|
int _relaySourceMediaId = -1;
|
||||||
|
/// This member serves the current media's bytes through the server.
|
||||||
|
final relaying = signal<bool>(false);
|
||||||
// Server seek applied to the player (event id and when), checked once it
|
// Server seek applied to the player (event id and when), checked once it
|
||||||
// should have landed.
|
// should have landed.
|
||||||
int? _pendingSeekEvent;
|
int? _pendingSeekEvent;
|
||||||
@@ -392,6 +430,78 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _relayWaitShown = false;
|
||||||
|
|
||||||
|
/// Relayed through a member whose channel is down: say who everyone is
|
||||||
|
/// waiting for (the buffer still plays what it has).
|
||||||
|
void _relayStatus(StateBroadcast r) {
|
||||||
|
final relay = stateMediaRelay(r);
|
||||||
|
final waiting = relay != null &&
|
||||||
|
relay.source.isNotEmpty &&
|
||||||
|
relay.source != sync.memberId &&
|
||||||
|
!relay.live &&
|
||||||
|
_openUrl != null &&
|
||||||
|
jellyfinPlan.peek() is! OwnPlay;
|
||||||
|
if (waiting) {
|
||||||
|
final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name;
|
||||||
|
health.value = HealthStatus(HealthSeverity.warn,
|
||||||
|
who == null ? 'the member who put this up left' : 'waiting for $who to pass the video on');
|
||||||
|
_relayWaitShown = true;
|
||||||
|
} else if (_relayWaitShown) {
|
||||||
|
_relayWaitShown = false;
|
||||||
|
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _relayMediaUrlFor(String name, int mediaId) =>
|
||||||
|
'http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId/${Uri.encodeComponent(name)}';
|
||||||
|
|
||||||
|
/// Serves [bytes] for media [mediaId] on this member's relay channel.
|
||||||
|
/// The server only pulls while someone watches through it.
|
||||||
|
void _startRelaySource(int mediaId, RelayBytes bytes, {bool announce = false}) {
|
||||||
|
// Back after a reconnect on the same media: it is still serving (and
|
||||||
|
// redials by itself).
|
||||||
|
if (_relaySource != null && _relaySourceMediaId == mediaId) {
|
||||||
|
unawaited(bytes.close());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_stopRelaySource();
|
||||||
|
final src = RelaySource(
|
||||||
|
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${sync.token}/source'), bytes);
|
||||||
|
_relaySource = src;
|
||||||
|
_relaySourceMediaId = mediaId;
|
||||||
|
relaying.value = true;
|
||||||
|
if (announce) {
|
||||||
|
_pushToast('Passing the video on through the server. Keep the app open while it plays.');
|
||||||
|
}
|
||||||
|
unawaited(() async {
|
||||||
|
try {
|
||||||
|
await src.start();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('relay source failed: $e');
|
||||||
|
if (identical(_relaySource, src)) {
|
||||||
|
_relaySource = null;
|
||||||
|
relaying.value = false;
|
||||||
|
_pushToast('Cannot pass the video on: $e');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await src.done;
|
||||||
|
if (identical(_relaySource, src)) {
|
||||||
|
_relaySource = null;
|
||||||
|
relaying.value = false;
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopRelaySource() {
|
||||||
|
final src = _relaySource;
|
||||||
|
_relaySource = null;
|
||||||
|
_relaySourceMediaId = -1;
|
||||||
|
relaying.value = false;
|
||||||
|
if (src != null) unawaited(src.close());
|
||||||
|
}
|
||||||
|
|
||||||
String _fileUrlFor(String name) {
|
String _fileUrlFor(String name) {
|
||||||
return 'http://$_wsHost:$_wsPort/m/default/${sync.token}/${Uri.encodeComponent(name)}';
|
return 'http://$_wsHost:$_wsPort/m/default/${sync.token}/${Uri.encodeComponent(name)}';
|
||||||
}
|
}
|
||||||
@@ -440,12 +550,35 @@ class SessionCoordinator {
|
|||||||
r.mediaId == _jfMediaId &&
|
r.mediaId == _jfMediaId &&
|
||||||
sameJellyfinItem(ref, _jfRef) &&
|
sameJellyfinItem(ref, _jfRef) &&
|
||||||
jellyfinPlan.peek() is OwnPlay;
|
jellyfinPlan.peek() is OwnPlay;
|
||||||
|
final relay = stateMediaRelay(r);
|
||||||
|
final mine = relay != null && relay.source.isNotEmpty && relay.source == sync.memberId;
|
||||||
|
_RelayOrigin? origin;
|
||||||
|
if (mine) {
|
||||||
|
final pending = _pendingOrigin;
|
||||||
|
if (pending != null) {
|
||||||
|
_pendingOrigin = null;
|
||||||
|
_activeOrigin = (r.mediaId, pending);
|
||||||
|
}
|
||||||
|
origin = _activeOrigin?.$1 == r.mediaId ? _activeOrigin!.$2 : null;
|
||||||
|
}
|
||||||
|
if (_relaySourceMediaId != r.mediaId) _stopRelaySource();
|
||||||
if (!same) {
|
if (!same) {
|
||||||
_seekGate.reset();
|
_seekGate.reset();
|
||||||
_clearPendingSeek();
|
_clearPendingSeek();
|
||||||
_leaveJellyfinItem();
|
_leaveJellyfinItem();
|
||||||
|
final name = (r.media?['name'] as String?) ?? '';
|
||||||
if (ref != null) {
|
if (ref != null) {
|
||||||
await _playJellyfin(ref, r);
|
await _playJellyfin(ref, r,
|
||||||
|
origin: origin is _JellyfinOrigin ? origin : null,
|
||||||
|
memberRelayUrl: relay != null && !mine ? _relayMediaUrlFor(name, r.mediaId) : null);
|
||||||
|
} else if (origin is _FileOrigin) {
|
||||||
|
await _openMedia(origin.path);
|
||||||
|
_startRelaySource(r.mediaId, FileRelayBytes(File(origin.path)), announce: true);
|
||||||
|
} else if (origin is _LinkOrigin) {
|
||||||
|
await _openMedia(origin.url);
|
||||||
|
_startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(origin.url)), announce: true);
|
||||||
|
} else if (relay != null && !mine) {
|
||||||
|
await _openMedia(_relayMediaUrlFor(name, r.mediaId));
|
||||||
} else {
|
} else {
|
||||||
var url = stateMediaUrl(r);
|
var url = stateMediaUrl(r);
|
||||||
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
|
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
|
||||||
@@ -463,6 +596,7 @@ class SessionCoordinator {
|
|||||||
_clearPendingSeek();
|
_clearPendingSeek();
|
||||||
}
|
}
|
||||||
_jellyfinTransport(r);
|
_jellyfinTransport(r);
|
||||||
|
_relayStatus(r);
|
||||||
if (!_playerReady || _openUrl == null) {
|
if (!_playerReady || _openUrl == null) {
|
||||||
_wasPlayAllowed = false;
|
_wasPlayAllowed = false;
|
||||||
_updateStarving(r, 0);
|
_updateStarving(r, 0);
|
||||||
@@ -825,35 +959,77 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A pasted link: a recognised Jellyfin item link goes up as a ref
|
/// A pasted link: a recognised Jellyfin item link goes up as a ref
|
||||||
/// (see [mediaForLink]), anything else as the link.
|
/// (see [mediaForLink]) that this member relays for viewers without an
|
||||||
Future<void> swapLink(String url) async {
|
/// account (with the link's key, which stays here); anything else as the
|
||||||
|
/// link, or relayed per [relay]. Returns an error text, or null when sent.
|
||||||
|
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct}) async {
|
||||||
markInput();
|
markInput();
|
||||||
sync.sendSwap(await mediaForLink(jellyfin, url));
|
final media = await mediaForLink(jellyfin, url);
|
||||||
|
if (media.jellyfin != null) {
|
||||||
|
_pendingOrigin = _JellyfinOrigin(url);
|
||||||
|
sync.sendSwap(media.copyWith(relay: const MediaRelay()));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (relay == LinkRelay.direct) {
|
||||||
|
sync.sendSwap(media);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Relayed: it must be a single file the relay can range-read (not a
|
||||||
|
// page like YouTube, which only the player can resolve).
|
||||||
|
final probe = HttpRelayBytes(Uri.parse(url));
|
||||||
|
final int size;
|
||||||
|
try {
|
||||||
|
size = await probe.size().timeout(const Duration(seconds: 15));
|
||||||
|
} catch (e) {
|
||||||
|
return relay == LinkRelay.me
|
||||||
|
? 'cannot pass this link on: $e'
|
||||||
|
: 'cannot check this link: $e';
|
||||||
|
} finally {
|
||||||
|
await probe.close();
|
||||||
|
}
|
||||||
|
if (relay == LinkRelay.me) {
|
||||||
|
_pendingOrigin = _LinkOrigin(url);
|
||||||
|
sync.sendSwap(HelloMedia(name: media.name, size: size, duration: 0, relay: const MediaRelay()));
|
||||||
|
} else {
|
||||||
|
sync.sendSwap(HelloMedia(
|
||||||
|
name: media.name, size: size, duration: 0, url: url, relay: const MediaRelay(fetch: true)));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A Jellyfin pick (browser, next / previous episode) goes up as a ref.
|
/// A Jellyfin pick (browser, next / previous episode) goes up as a ref,
|
||||||
|
/// relayed by this member for viewers without an account.
|
||||||
void swapJellyfin(JellyfinMedia media) {
|
void swapJellyfin(JellyfinMedia media) {
|
||||||
markInput();
|
markInput();
|
||||||
sync.sendSwap(mediaForPick(media));
|
_pendingOrigin = const _JellyfinOrigin(null);
|
||||||
|
sync.sendSwap(mediaForPick(media).copyWith(relay: const MediaRelay()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The host's File swap: the server takes the file, then the swap puts
|
/// A file on this computer. The host's app serves it itself; anyone
|
||||||
/// it up. Returns an error text, or null when sent.
|
/// else's goes through the server's relay. Returns an error text, or null
|
||||||
|
/// when sent.
|
||||||
Future<String?> swapFile(String path) async {
|
Future<String?> swapFile(String path) async {
|
||||||
markInput();
|
markInput();
|
||||||
final host = hostFile;
|
final host = hostFile;
|
||||||
if (host == null) return 'only the host can share a file';
|
if (host != null) {
|
||||||
final file = await host(path);
|
final file = await host(path);
|
||||||
if (file == null) return 'file not found — check the path';
|
if (file == null) return 'file not found — check the path';
|
||||||
sync.sendSwap(HelloMedia(name: file.name, size: file.size, duration: 0));
|
sync.sendSwap(HelloMedia(name: file.name, size: file.size, duration: 0));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final file = File(path);
|
||||||
|
if (!await file.exists()) return 'file not found — check the path';
|
||||||
|
_pendingOrigin = _FileOrigin(path);
|
||||||
|
sync.sendSwap(HelloMedia(
|
||||||
|
name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay()));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Any picker selection.
|
/// Any picker selection.
|
||||||
Future<String?> swapPicked(PickedMedia media) async {
|
Future<String?> swapPicked(PickedMedia media) async {
|
||||||
switch (media) {
|
switch (media) {
|
||||||
case LinkMedia(:final url):
|
case LinkMedia(:final url, :final relay):
|
||||||
await swapLink(url);
|
return swapLink(url, relay: relay);
|
||||||
case JellyfinMedia():
|
case JellyfinMedia():
|
||||||
swapJellyfin(media);
|
swapJellyfin(media);
|
||||||
case FileMedia(:final path):
|
case FileMedia(:final path):
|
||||||
@@ -978,7 +1154,8 @@ class SessionCoordinator {
|
|||||||
/// Resolves [ref] for this viewer and opens it: own account (direct
|
/// Resolves [ref] for this viewer and opens it: own account (direct
|
||||||
/// play, progress reports, neighbouring episodes), the relay, or the
|
/// play, progress reports, neighbouring episodes), the relay, or the
|
||||||
/// blocked state. Then carries the audio/subtitle choice over.
|
/// blocked state. Then carries the audio/subtitle choice over.
|
||||||
Future<void> _playJellyfin(JellyfinRef ref, StateBroadcast r) async {
|
Future<void> _playJellyfin(JellyfinRef ref, StateBroadcast r,
|
||||||
|
{_JellyfinOrigin? origin, String? memberRelayUrl}) async {
|
||||||
final gen = ++_jfGen;
|
final gen = ++_jfGen;
|
||||||
_jfRef = ref;
|
_jfRef = ref;
|
||||||
_jfMediaId = r.mediaId;
|
_jfMediaId = r.mediaId;
|
||||||
@@ -992,13 +1169,24 @@ class SessionCoordinator {
|
|||||||
await playback.player.pause();
|
await playback.player.pause();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
final JellyfinPlan plan;
|
JellyfinPlan plan;
|
||||||
try {
|
try {
|
||||||
plan = await resolveJellyfinRef(jellyfin, ref, relay: r.relay, relayUrl: _relayUrlFor(ref));
|
plan = await resolveJellyfinRef(jellyfin, ref,
|
||||||
|
relay: r.relay, relayUrl: _relayUrlFor(ref), memberRelayUrl: memberRelayUrl);
|
||||||
} finally {
|
} finally {
|
||||||
if (gen == _jfGen) _opening = false;
|
if (gen == _jfGen) _opening = false;
|
||||||
}
|
}
|
||||||
if (gen != _jfGen) return;
|
if (gen != _jfGen) return;
|
||||||
|
// This member put it up: serve it to viewers without an account, with
|
||||||
|
// its own sign-in, else the pasted link (played directly here too).
|
||||||
|
if (origin != null) {
|
||||||
|
if (plan is OwnPlay) {
|
||||||
|
_startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers));
|
||||||
|
} else if (origin.link != null) {
|
||||||
|
plan = RelayPlay(origin.link!);
|
||||||
|
_startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(origin.link!)));
|
||||||
|
}
|
||||||
|
}
|
||||||
jellyfinPlan.value = plan;
|
jellyfinPlan.value = plan;
|
||||||
switch (plan) {
|
switch (plan) {
|
||||||
case BlockedPlay():
|
case BlockedPlay():
|
||||||
@@ -1321,6 +1509,9 @@ class SessionCoordinator {
|
|||||||
_seekGate.reset();
|
_seekGate.reset();
|
||||||
reconnecting.value = false;
|
reconnecting.value = false;
|
||||||
await _stopJellyfin();
|
await _stopJellyfin();
|
||||||
|
_stopRelaySource();
|
||||||
|
_pendingOrigin = null;
|
||||||
|
_activeOrigin = null;
|
||||||
_playerReady = false;
|
_playerReady = false;
|
||||||
try {
|
try {
|
||||||
await playback.dispose();
|
await playback.dispose();
|
||||||
|
|||||||
@@ -723,14 +723,14 @@ packages:
|
|||||||
path: "../../packages/core"
|
path: "../../packages/core"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.2.1"
|
version: "0.2.2"
|
||||||
sync_relay_server:
|
sync_relay_server:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "../../packages/server"
|
path: "../../packages/server"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.2.1"
|
version: "0.2.2"
|
||||||
synchronized:
|
synchronized:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:watch_together/src/jellyfin/accounts.dart';
|
|||||||
import 'package:watch_together/src/jellyfin/session_media.dart';
|
import 'package:watch_together/src/jellyfin/session_media.dart';
|
||||||
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
||||||
import 'package:watch_together/src/playback/media_marks.dart';
|
import 'package:watch_together/src/playback/media_marks.dart';
|
||||||
|
import 'package:watch_together/src/screens/home/picked_media.dart';
|
||||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||||
|
|
||||||
// Two account holders and a guest watch Jellyfin episodes together over a
|
// Two account holders and a guest watch Jellyfin episodes together over a
|
||||||
@@ -444,7 +445,7 @@ void main() {
|
|||||||
await b.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: '', name: 'bob'));
|
await b.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: '', name: 'bob'));
|
||||||
await _poll(() => b.jellyfinBlocked.peek() != null, 'blocked');
|
await _poll(() => b.jellyfinBlocked.peek() != null, 'blocked');
|
||||||
expect(b.serverState.peek()!.relay, isFalse);
|
expect(b.serverState.peek()!.relay, isFalse);
|
||||||
expect(b.jellyfinBlocked.peek()!.message, contains('needs a Jellyfin account'));
|
expect(b.jellyfinBlocked.peek()!.message, contains('nobody is passing it on'));
|
||||||
// Linking an account (Integrations) retries by itself.
|
// Linking an account (Integrations) retries by itself.
|
||||||
await accB.link(
|
await accB.link(
|
||||||
baseUrl: jelly.base,
|
baseUrl: jelly.base,
|
||||||
@@ -462,4 +463,74 @@ void main() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The VPS case: a dedicated server with no Jellyfin login. Whoever puts
|
||||||
|
// the item up passes it on to guests without an account.
|
||||||
|
test(
|
||||||
|
'dedicated server without a login: the guest plays through the member who put it up',
|
||||||
|
timeout: const Timeout(Duration(minutes: 3)),
|
||||||
|
() async {
|
||||||
|
MediaKit.ensureInitialized();
|
||||||
|
final jelly = _FakeJellyfin(await _media());
|
||||||
|
await jelly.start();
|
||||||
|
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'vps'));
|
||||||
|
await server.start();
|
||||||
|
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
|
||||||
|
Future<JellyfinAccounts> none() async {
|
||||||
|
final x = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||||
|
await x.ensureLoaded(validate: false);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
final a = SessionCoordinator(jellyfin: accA, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||||
|
final b = SessionCoordinator(jellyfin: await none(), trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||||
|
final d = SessionCoordinator(jellyfin: await none(), trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||||
|
ConnectParams params(String name) =>
|
||||||
|
ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: '', name: name);
|
||||||
|
try {
|
||||||
|
await a.connect(params('alice'));
|
||||||
|
await b.connect(params('guest'));
|
||||||
|
await d.connect(params('dave'));
|
||||||
|
for (final x in [a, b, d]) {
|
||||||
|
await x.playback.player.setVolume(0);
|
||||||
|
}
|
||||||
|
// Alice picks from her browser: a ref plus a relay she sources.
|
||||||
|
await a.swapPicked(JellyfinMedia(serverId: _sid, baseUrl: jelly.base, itemId: _ep1, title: 'Pilot'));
|
||||||
|
await _poll(() => b.jellyfinPlan.peek() is RelayPlay && b.playback.durationSecs > 19, 'guest plays');
|
||||||
|
expect((b.jellyfinPlan.peek() as RelayPlay).url, contains('/r/default/'));
|
||||||
|
expect(a.jellyfinPlan.peek(), isA<OwnPlay>());
|
||||||
|
expect(a.relaying.peek(), isTrue);
|
||||||
|
expect(b.jellyfinBlocked.peek(), isNull);
|
||||||
|
for (final x in [a, b]) {
|
||||||
|
x.toggleReady();
|
||||||
|
}
|
||||||
|
await _poll(() => a.playback.player.state.playing && b.playback.player.state.playing, 'playing');
|
||||||
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
|
expect((a.playback.positionSecs - b.playback.positionSecs).abs(), lessThan(1.0));
|
||||||
|
// Every stream request carried alice's token, from her machine.
|
||||||
|
expect(jelly.streams('ATOKEN'), isNotEmpty);
|
||||||
|
expect(jelly.calls.where((c) => c.path.startsWith('/Videos/')).every((c) =>
|
||||||
|
_FakeJellyfin.authFields(c.auth)['Token'] == 'ATOKEN'), isTrue);
|
||||||
|
for (final x in [a, b]) {
|
||||||
|
x.toggleReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dave has no account but a link with a key: he passes it on, the
|
||||||
|
// key never leaves his machine.
|
||||||
|
final link = '${jelly.base}/Videos/$_ep2/stream?static=true&api_key=ATOKEN';
|
||||||
|
expect(await d.swapPicked(LinkMedia(link)), isNull);
|
||||||
|
await _poll(() => (b.serverState.peek()?.media?['jellyfin'] as Map?)?['itemId'] == _ep2, 'ep2 up');
|
||||||
|
expect(b.serverState.peek()!.toJson().toString(), isNot(contains('api_key')));
|
||||||
|
await _poll(() => d.relaying.peek() && d.playback.durationSecs > 19, 'dave plays his link');
|
||||||
|
await _poll(() => b.jellyfinPlan.peek() is RelayPlay && b.playback.durationSecs > 19, 'guest plays ep2');
|
||||||
|
expect((b.jellyfinPlan.peek() as RelayPlay).url, contains('/r/default/'));
|
||||||
|
} finally {
|
||||||
|
for (final x in [a, b, d]) {
|
||||||
|
await x.dispose();
|
||||||
|
}
|
||||||
|
await server.stop();
|
||||||
|
await jelly.stop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@ void main() {
|
|||||||
final plan = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null);
|
final plan = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null);
|
||||||
expect(plan, isA<BlockedPlay>());
|
expect(plan, isA<BlockedPlay>());
|
||||||
expect((plan as BlockedPlay).message,
|
expect((plan as BlockedPlay).message,
|
||||||
'This needs a Jellyfin account on that Jellyfin server (or the host can relay)');
|
'No Jellyfin account here for that Jellyfin server, and nobody is passing it on');
|
||||||
expect(plan.jfAccess, isNull);
|
expect(plan.jfAccess, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ void main() {
|
|||||||
expect(plan, isA<RelayPlay>());
|
expect(plan, isA<RelayPlay>());
|
||||||
expect(accounts.statusOf(fixtureServerId), JellyfinAccountStatus.expired);
|
expect(accounts.statusOf(fixtureServerId), JellyfinAccountStatus.expired);
|
||||||
final blocked = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null);
|
final blocked = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null);
|
||||||
expect((blocked as BlockedPlay).message, contains('on Example Server'));
|
expect((blocked as BlockedPlay).message, contains('for Example Server'));
|
||||||
expect(blocked.message, contains('sign-in expired'));
|
expect(blocked.message, contains('sign-in expired'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -511,13 +511,13 @@ void main() {
|
|||||||
blocked: const BlockedPlay(serverId: fixtureServerId, serverName: 'Example Server'),
|
blocked: const BlockedPlay(serverId: fixtureServerId, serverName: 'Example Server'),
|
||||||
onLinkAccount: () => link++,
|
onLinkAccount: () => link++,
|
||||||
onRetry: () {})));
|
onRetry: () {})));
|
||||||
expect(find.text('This needs a Jellyfin account on Example Server (or the host can relay)'),
|
expect(find.text('No Jellyfin account here for Example Server, and nobody is passing it on'),
|
||||||
findsOneWidget);
|
findsOneWidget);
|
||||||
await tester.tap(find.byKey(const ValueKey('link-jellyfin-account')));
|
await tester.tap(find.byKey(const ValueKey('link-jellyfin-account')));
|
||||||
expect(link, 1);
|
expect(link, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('swap panel: the shared picker, File only for the host', (tester) async {
|
testWidgets('swap panel: the shared picker, File for everyone, link relay choice', (tester) async {
|
||||||
await setSize(tester, const Size(1400, 900));
|
await setSize(tester, const Size(1400, 900));
|
||||||
final (accounts, _) = await linkedAccounts();
|
final (accounts, _) = await linkedAccounts();
|
||||||
final c = MediaPickerController(source: MediaKind.link);
|
final c = MediaPickerController(source: MediaKind.link);
|
||||||
@@ -535,14 +535,16 @@ void main() {
|
|||||||
onClose: () => closed++,
|
onClose: () => closed++,
|
||||||
)));
|
)));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
expect(find.widgetWithText(ButtonSegment<MediaKind>, 'File'), findsNothing);
|
// A guest's file goes through the server's relay.
|
||||||
expect(find.text('File'), findsNothing);
|
expect(find.text('File'), findsOneWidget);
|
||||||
expect(find.text('Jellyfin'), findsOneWidget);
|
expect(find.text('Jellyfin'), findsOneWidget);
|
||||||
await tester.enterText(find.byType(TextField), 'https://cdn.example/movie.mkv');
|
await tester.enterText(find.byType(TextField), 'https://cdn.example/movie.mkv');
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Through me'));
|
||||||
|
await tester.pump();
|
||||||
await tester.tap(find.byKey(const ValueKey('swap-go')));
|
await tester.tap(find.byKey(const ValueKey('swap-go')));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
expect(swapped, [const LinkMedia('https://cdn.example/movie.mkv')]);
|
expect(swapped, [const LinkMedia('https://cdn.example/movie.mkv', relay: LinkRelay.me)]);
|
||||||
expect(closed, 1);
|
expect(closed, 1);
|
||||||
|
|
||||||
// The Jellyfin source is the library browser; a pick goes up as is.
|
// The Jellyfin source is the library browser; a pick goes up as is.
|
||||||
@@ -554,6 +556,9 @@ void main() {
|
|||||||
controller: c, accounts: accounts, isHost: true, onSwap: (_) async => 'x', onClose: () {})));
|
controller: c, accounts: accounts, isHost: true, onSwap: (_) async => 'x', onClose: () {})));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
expect(find.text('File'), findsOneWidget);
|
expect(find.text('File'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('File'));
|
||||||
|
await settle(tester);
|
||||||
|
expect(find.text('Friends stream it from your computer.'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('swap panel opens on the series of the episode playing, episode highlighted',
|
testWidgets('swap panel opens on the series of the episode playing, episode highlighted',
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:sync_relay_server/server.dart';
|
||||||
|
import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||||
|
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
||||||
|
import 'package:watch_together/src/screens/home/picked_media.dart';
|
||||||
|
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||||
|
|
||||||
|
// The relay with real coordinators, real mpv and a dedicated server that
|
||||||
|
// has no file and no Jellyfin login of its own (the VPS setup): a guest's
|
||||||
|
// file, and links passed on by a member or fetched by the server.
|
||||||
|
|
||||||
|
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||||
|
|
||||||
|
Future<String> _media() async {
|
||||||
|
final path = '$_tmp/relay_e2e_20s.mkv';
|
||||||
|
if (await File(path).exists()) return path;
|
||||||
|
final r = await Process.run('ffmpeg', [
|
||||||
|
'-y', '-v', 'error',
|
||||||
|
'-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24',
|
||||||
|
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
|
||||||
|
'-t', '20', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24', '-pix_fmt', 'yuv420p',
|
||||||
|
'-c:a', 'aac', path,
|
||||||
|
]);
|
||||||
|
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _poll(FutureOr<bool> Function() cond, String what,
|
||||||
|
{Duration timeout = const Duration(seconds: 30)}) async {
|
||||||
|
final deadline = DateTime.now().add(timeout);
|
||||||
|
while (DateTime.now().isBefore(deadline)) {
|
||||||
|
if (await cond()) return;
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
fail('timed out waiting for $what');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serves one file with ranges; [requests] counts hits.
|
||||||
|
Future<(HttpServer, List<String>)> _origin(String file) async {
|
||||||
|
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
final requests = <String>[];
|
||||||
|
s.listen((req) async {
|
||||||
|
requests.add(req.uri.toString());
|
||||||
|
final size = await File(file).length();
|
||||||
|
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mkv');
|
||||||
|
req.response.statusCode = r.status;
|
||||||
|
r.headers.forEach(req.response.headers.set);
|
||||||
|
try {
|
||||||
|
if (r.status == 200 || r.status == 206) {
|
||||||
|
await req.response.addStream(File(file).openRead(r.start, (r.end ?? size - 1) + 1));
|
||||||
|
}
|
||||||
|
await req.response.close();
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
return (s, requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late SyncServer server;
|
||||||
|
late List<SessionCoordinator> all;
|
||||||
|
|
||||||
|
Future<SessionCoordinator> member(String name) async {
|
||||||
|
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||||
|
await acc.ensureLoaded(validate: false);
|
||||||
|
final c = SessionCoordinator(jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||||
|
all.add(c);
|
||||||
|
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name));
|
||||||
|
await c.playback.player.setVolume(0);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> playTogether(List<SessionCoordinator> xs) async {
|
||||||
|
await _poll(() => xs.every((x) => x.playback.durationSecs > 19), 'all opened it');
|
||||||
|
for (final x in xs) {
|
||||||
|
x.toggleReady();
|
||||||
|
}
|
||||||
|
await _poll(() => xs.every((x) => x.playback.player.state.playing), 'all playing');
|
||||||
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
|
final pos = [for (final x in xs) x.playback.positionSecs];
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('positions after 3 s: $pos');
|
||||||
|
for (final p in pos) {
|
||||||
|
expect((p - pos.first).abs(), lessThan(1.0), reason: 'in sync: $pos');
|
||||||
|
}
|
||||||
|
for (final x in xs) {
|
||||||
|
x.toggleReady();
|
||||||
|
}
|
||||||
|
await _poll(() => xs.every((x) => !x.playback.player.state.playing), 'held');
|
||||||
|
}
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
MediaKit.ensureInitialized();
|
||||||
|
all = [];
|
||||||
|
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'vps'));
|
||||||
|
await server.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
for (final x in all) {
|
||||||
|
await x.dispose();
|
||||||
|
}
|
||||||
|
await server.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a guest puts up a file: it plays for everyone through the relay', timeout: const Timeout(Duration(minutes: 2)),
|
||||||
|
() async {
|
||||||
|
final file = await _media();
|
||||||
|
final alice = await member('alice');
|
||||||
|
final bob = await member('bob');
|
||||||
|
expect(await bob.swapPicked(FileMedia(file)), isNull);
|
||||||
|
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source == bob.sync.memberId, 'relay state');
|
||||||
|
final state = alice.serverState.peek()!;
|
||||||
|
expect(state.media!.containsKey('url'), isFalse);
|
||||||
|
expect(bob.relaying.peek(), isTrue);
|
||||||
|
await playTogether([alice, bob]);
|
||||||
|
final relay = server.hub.relay!;
|
||||||
|
expect(relay.buffer.bytesToViewers, greaterThan(0), reason: 'alice played through the relay');
|
||||||
|
expect(relay.sourceToken, bob.sync.token);
|
||||||
|
|
||||||
|
// The next swap retires bob's source and the buffer.
|
||||||
|
alice.swapPicked(const LinkMedia('http://127.0.0.1:1/none.mkv'));
|
||||||
|
await _poll(() => !bob.relaying.peek(), 'source stopped');
|
||||||
|
expect(server.hub.relay, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('links: through me, and fetched by the server; never broadcast', timeout: const Timeout(Duration(minutes: 3)),
|
||||||
|
() async {
|
||||||
|
final (origin, hits) = await _origin(await _media());
|
||||||
|
try {
|
||||||
|
final alice = await member('alice');
|
||||||
|
final bob = await member('bob');
|
||||||
|
final link = 'http://127.0.0.1:${origin.port}/private/ep.mkv?api_key=SECRET';
|
||||||
|
|
||||||
|
expect(await bob.swapPicked(LinkMedia(link, relay: LinkRelay.me)), isNull);
|
||||||
|
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source == bob.sync.memberId, 'me state');
|
||||||
|
expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
|
||||||
|
await playTogether([alice, bob]);
|
||||||
|
expect(server.hub.relay!.buffer.bytesToViewers, greaterThan(0));
|
||||||
|
|
||||||
|
final before = hits.length;
|
||||||
|
expect(await alice.swapPicked(LinkMedia(link, relay: LinkRelay.server)), isNull);
|
||||||
|
await _poll(() => stateMediaRelay(bob.serverState.peek()!)?.fetch == true, 'fetch state');
|
||||||
|
expect(bob.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
|
||||||
|
expect(alice.relaying.peek(), isFalse, reason: 'the server fetches, nobody sources');
|
||||||
|
await playTogether([alice, bob]);
|
||||||
|
expect(hits.length, greaterThan(before));
|
||||||
|
|
||||||
|
// A page instead of a file is refused up front.
|
||||||
|
final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
page.listen((r) {
|
||||||
|
r.response.headers.contentType = ContentType.html;
|
||||||
|
r.response.write('<html></html>');
|
||||||
|
r.response.close();
|
||||||
|
});
|
||||||
|
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)),
|
||||||
|
contains('cannot pass this link on'));
|
||||||
|
await page.close(force: true);
|
||||||
|
} finally {
|
||||||
|
await origin.close(force: true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the source drops uncleanly and resumes: it keeps its copy and keeps serving',
|
||||||
|
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||||
|
final file = await _media();
|
||||||
|
final proxy = await _Blackhole.start(server.boundPort);
|
||||||
|
try {
|
||||||
|
final alice = await member('alice');
|
||||||
|
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||||
|
await acc.ensureLoaded(validate: false);
|
||||||
|
final bob = SessionCoordinator(jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||||
|
all.add(bob);
|
||||||
|
await bob.connect(ConnectParams(host: '127.0.0.1', port: proxy.port, secret: 'pw', name: 'bob'));
|
||||||
|
await bob.playback.player.setVolume(0);
|
||||||
|
expect(await bob.swapPicked(FileMedia(file)), isNull);
|
||||||
|
await _poll(() => server.hub.relay?.live == true, 'source live');
|
||||||
|
await _poll(() => alice.playback.durationSecs > 19 && bob.playback.durationSecs > 19, 'both opened');
|
||||||
|
final token = bob.sync.token;
|
||||||
|
|
||||||
|
// Bob's network goes silent (no FIN): states stop, he reconnects
|
||||||
|
// with `resume`, the server still has him.
|
||||||
|
proxy.cut();
|
||||||
|
await _poll(() => bob.reconnecting.peek(), 'reconnect engaged', timeout: const Duration(seconds: 15));
|
||||||
|
await _poll(() => !bob.reconnecting.peek() && bob.serverState.peek() != null, 'back',
|
||||||
|
timeout: const Duration(seconds: 20));
|
||||||
|
expect(bob.sync.token, token, reason: 'resumed as the same member');
|
||||||
|
await _poll(() => server.hub.relay?.live == true, 'source redialled', timeout: const Duration(seconds: 15));
|
||||||
|
expect(bob.relaying.peek(), isTrue);
|
||||||
|
expect(bob.playback.durationSecs, greaterThan(19), reason: 'still his own copy');
|
||||||
|
|
||||||
|
// Alice can still get bytes the buffer never had.
|
||||||
|
alice.seekTo(17);
|
||||||
|
await _poll(() => (alice.playback.positionSecs - 17).abs() < 1.5, 'alice seeked');
|
||||||
|
} finally {
|
||||||
|
await proxy.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TCP proxy whose [cut] silently stops forwarding on every connection made
|
||||||
|
/// so far (no FIN, like a dead Wi-Fi); later connections work.
|
||||||
|
class _Blackhole {
|
||||||
|
final ServerSocket _server;
|
||||||
|
final _conns = <(Socket, Socket, List<bool>)>[];
|
||||||
|
_Blackhole(this._server);
|
||||||
|
int get port => _server.port;
|
||||||
|
|
||||||
|
static Future<_Blackhole> start(int target) async {
|
||||||
|
final b = _Blackhole(await ServerSocket.bind(InternetAddress.loopbackIPv4, 0));
|
||||||
|
b._server.listen((c) async {
|
||||||
|
final up = await Socket.connect('127.0.0.1', target);
|
||||||
|
final dead = [false];
|
||||||
|
b._conns.add((c, up, dead));
|
||||||
|
c.listen((d) {
|
||||||
|
if (!dead[0]) up.add(d);
|
||||||
|
}, onDone: () => dead[0] ? null : up.destroy(), onError: (_) {});
|
||||||
|
up.listen((d) {
|
||||||
|
if (!dead[0]) c.add(d);
|
||||||
|
}, onDone: () => dead[0] ? null : c.destroy(), onError: (_) {});
|
||||||
|
});
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cut() {
|
||||||
|
for (final (_, _, dead) in _conns) {
|
||||||
|
dead[0] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> close() async {
|
||||||
|
await _server.close();
|
||||||
|
for (final (a, b, _) in _conns) {
|
||||||
|
a.destroy();
|
||||||
|
b.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
-7
@@ -8,7 +8,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
|
|||||||
| Path | What | Rule |
|
| Path | What | Rule |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client | pure Dart, no Flutter |
|
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client | pure Dart, no Flutter |
|
||||||
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
|
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
|
||||||
| `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, updater | ships with `watch-together-server` |
|
| `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, updater | ships with `watch-together-server` |
|
||||||
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
|
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
|
||||||
| `deploy` | systemd unit for the dedicated server | |
|
| `deploy` | systemd unit for the dedicated server | |
|
||||||
@@ -144,7 +144,8 @@ Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit.
|
|||||||
|
|
||||||
## Media
|
## Media
|
||||||
|
|
||||||
Three sources, same sync.
|
Three sources, same sync. Any of them can also go through the relay
|
||||||
|
(below).
|
||||||
|
|
||||||
- **Link**: the media field holds an http(s) URL. It travels as `media.url`
|
- **Link**: the media field holds an http(s) URL. It travels as `media.url`
|
||||||
in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it
|
in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it
|
||||||
@@ -166,8 +167,8 @@ Three sources, same sync.
|
|||||||
- **Jellyfin item**: the media is a ref (`{serverId, itemId, ...}`, wire in
|
- **Jellyfin item**: the media is a ref (`{serverId, itemId, ...}`, wire in
|
||||||
[PROTOCOL.md](PROTOCOL.md) "Jellyfin media"); the server never stores or
|
[PROTOCOL.md](PROTOCOL.md) "Jellyfin media"); the server never stores or
|
||||||
sends a URL or token for it. Each app resolves it with its own account and
|
sends a URL or token for it. Each app resolves it with its own account and
|
||||||
direct-plays the static stream; guests use the relay (see "Jellyfin
|
direct-plays the static stream; guests use the server's own login when
|
||||||
relay").
|
it has one ("Jellyfin relay"), else the member who put it up ("Relay").
|
||||||
|
|
||||||
Range table (`resolveMediaRequest`, tested byte-exact):
|
Range table (`resolveMediaRequest`, tested byte-exact):
|
||||||
|
|
||||||
@@ -362,6 +363,86 @@ errors leave it out).
|
|||||||
subscription does).
|
subscription does).
|
||||||
- No progress reporting in the server: watched state is per client.
|
- No progress reporting in the server: watched state is per client.
|
||||||
|
|
||||||
|
## Relay
|
||||||
|
|
||||||
|
A member's media through the server's memory, for viewers who cannot
|
||||||
|
reach it themselves (`packages/core/lib/src/relay.dart` for the wire and
|
||||||
|
the source, `packages/server/lib/src/relay_buffer.dart`,
|
||||||
|
`relay_routes.dart`; wire in [PROTOCOL.md](PROTOCOL.md) "Relay").
|
||||||
|
|
||||||
|
What gets relayed (`SessionCoordinator.swapPicked`):
|
||||||
|
|
||||||
|
| Put up | Others get it |
|
||||||
|
|---|---|
|
||||||
|
| File, by the host | `/m` from the hosting app (no relay) |
|
||||||
|
| File, by anyone else | relayed from that member's computer |
|
||||||
|
| Link, "Everyone fetches it" (default) | each app fetches it |
|
||||||
|
| Link, "Server fetches it" | the server reads it once into the buffer; the link is never broadcast; refused on an open server (it would fetch any address it can reach for anyone) |
|
||||||
|
| Link, "Through me" | relayed from that member's computer; for links only they can open |
|
||||||
|
| Jellyfin item (browser pick or pasted link) | own account first, then the server's login (`/j`), then relayed from the member who put it up (with their sign-in, or the pasted link's key, which stays on their machine) |
|
||||||
|
|
||||||
|
Relayed links must be one file the relay can range-read: a `text/*`
|
||||||
|
response (YouTube and other pages, which only the player resolves) is
|
||||||
|
refused before the swap.
|
||||||
|
|
||||||
|
How bytes move:
|
||||||
|
- The source dials `/r/.../source` (outbound, so NAT/CGNAT are fine) and
|
||||||
|
answers the server's pulls from the file or the URL (`RelaySource`,
|
||||||
|
`FileRelayBytes`, `HttpRelayBytes`; a Jellyfin source sends its own
|
||||||
|
`Authorization` header from this machine). Viewers only ever talk HTTP
|
||||||
|
to the server, so the source never sees mpv's request pattern.
|
||||||
|
- The server keeps one `RelayBuffer` per relayed media: 1 MiB slices in
|
||||||
|
memory, filled by one sequential pull at a time. Kept: the first and
|
||||||
|
last 2 MiB (container indexes; mpv reads both on open and on seeks),
|
||||||
|
64 MiB behind the slowest viewer and 256 MiB past the furthest, the
|
||||||
|
same as each viewer's mpv buffer (`PlaybackController`), so a skip
|
||||||
|
inside the viewers' buffer window is served from memory. Evicted by
|
||||||
|
distance from the viewers, never LRU. With 2+ viewers the source sends
|
||||||
|
each byte about once.
|
||||||
|
- Filling: a viewer blocked on a slice first (oldest wait first, handing
|
||||||
|
the source over at slice boundaries), then the pinned slices, then
|
||||||
|
read-ahead from the slowest viewer. Nothing is fetched while nobody
|
||||||
|
watches through the relay (a Jellyfin source with only account holders
|
||||||
|
uploads nothing).
|
||||||
|
- mpv leaves superseded requests open but unread after a seek: only a
|
||||||
|
viewer's newest request moves its position or asks the source for
|
||||||
|
anything; older ones still get bytes that are already buffered.
|
||||||
|
- Flow control on the source (`RelayWindow`): one in-flight budget per
|
||||||
|
connection, 2 x delivery rate x min RTT (rate max-filtered over 2 s),
|
||||||
|
clamped 256 KiB to 8 MiB; the server acks bytes as they arrive. A
|
||||||
|
cancelled pull's unacked bytes are written off, and a cancel that lands
|
||||||
|
while a frame is being read stops it before it is sent.
|
||||||
|
- 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
|
||||||
|
stop. A crash frees it with the process.
|
||||||
|
- Source away: viewers keep playing from the buffer; a request waits up
|
||||||
|
to 60 s for missing bytes. The source reconnects with backoff (same
|
||||||
|
token after a resume); a different file size drops the buffer.
|
||||||
|
`state.media.relay.live` follows the channel, and viewers show "waiting
|
||||||
|
for <name> to pass the video on" while it is down.
|
||||||
|
|
||||||
|
Measured (mpv 0.41 viewers, real server and source, a shaped link
|
||||||
|
between source and server; `tool/relay_e2e.dart`, 2-minute 1080p test
|
||||||
|
file averaging 10 Mbit/s with a 15 Mbit/s busy section):
|
||||||
|
|
||||||
|
| Source uplink | Open | Seeks (incl. +90 s skip) | Stalls in 50 s |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 400 Mbit/s, 20 ms | 0.8 s | 0.01 to 0.03 s | 0 |
|
||||||
|
| 40 Mbit/s, 40 ms | 2.1 s | 1.1 s first, then 0.03 s | 0 |
|
||||||
|
| 20 Mbit/s, 150 ms | 5.4 s | 1.1 to 5.3 s | 3 |
|
||||||
|
| 12 Mbit/s, 40 ms | 5.4 s | 0.5 to 2.9 s | 3 |
|
||||||
|
|
||||||
|
3 viewers skipping together on 40 Mbit/s: no stalls, the source sent
|
||||||
|
101 MiB for 287 MiB served. A late joiner opened in 0.04 s from the
|
||||||
|
buffer. A 5 s source outage: no stall; 30 s: one stall once the buffer ran
|
||||||
|
out, then it recovered. Freezes of 0.3 to 0.8 s in 30% of seconds: no
|
||||||
|
stall. So the source's upload must beat the media's average bitrate
|
||||||
|
(peaks are covered by read-ahead once it has some lead), and comfortably
|
||||||
|
so for quick seeks. Rejected along the way: a fixed per-request window
|
||||||
|
(seeks of 14 s on slow links, data for abandoned positions clogging the
|
||||||
|
pipe), one shared window across requests (deadlocked on mpv's unread
|
||||||
|
requests), Dart `http2` (connection window stuck at 64 KiB).
|
||||||
|
|
||||||
## Jellyfin in a session
|
## Jellyfin in a session
|
||||||
|
|
||||||
Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
||||||
@@ -374,8 +455,9 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
|||||||
MediaBrowser` with this install's fields), not in the URL: `OwnPlay`,
|
MediaBrowser` with this install's fields), not in the URL: `OwnPlay`,
|
||||||
`report.jf {serverId: true}`. Else, when `state.relay`, `welcome.relayUrl`
|
`report.jf {serverId: true}`. Else, when `state.relay`, `welcome.relayUrl`
|
||||||
or `/j/default/<token>/<itemId>` on the dialled host: `RelayPlay`,
|
or `/j/default/<token>/<itemId>` on the dialled host: `RelayPlay`,
|
||||||
`jf: false`. Else `BlockedPlay`: the session shows "This needs a
|
`jf: false`. Else, when `media.relay` names a source, the `/r/` URL:
|
||||||
Jellyfin account on <server> (or the host can relay)" with a button that
|
`RelayPlay`. Else `BlockedPlay`: the session shows "No Jellyfin account
|
||||||
|
here for <server>, and nobody is passing it on" with a button that
|
||||||
opens Integrations over the session; linking (or reconnecting) the
|
opens Integrations over the session; linking (or reconnecting) the
|
||||||
account retries by itself. A 401 marks the account expired. The member
|
account retries by itself. A 401 marks the account expired. The member
|
||||||
list shows "via host" for members whose `jf` is false for the current
|
list shows "via host" for members whose `jf` is false for the current
|
||||||
@@ -577,6 +659,13 @@ this repo, written by `tool/release.sh`.
|
|||||||
- Plain `ws://`, no TLS: friends use LAN, VPN or a forwarded port; TLS
|
- Plain `ws://`, no TLS: friends use LAN, VPN or a forwarded port; TLS
|
||||||
(e.g. a reverse proxy) is a documentation item.
|
(e.g. a reverse proxy) is a documentation item.
|
||||||
- One session per server, id `default`: the map is keyed for later but unused.
|
- One session per server, id `default`: the map is keyed for later but unused.
|
||||||
- No server storage: config and logs only; media never touches the server disk.
|
- No server storage: config and logs only; media never touches the server
|
||||||
|
disk. The relay buffer is memory only and sized like a viewer's buffer
|
||||||
|
(about 320 MiB at most), so a small VPS can carry it.
|
||||||
|
- The relay is the source's own connection through the server, not a
|
||||||
|
shared token: a member's Jellyfin sign-in or a link's key never leaves
|
||||||
|
their machine. Prior art (Plex Relay, Tailscale DERP) relays as a
|
||||||
|
bandwidth-capped fallback on someone else's servers; here the server is
|
||||||
|
the owner's, so it carries full quality.
|
||||||
- Avatars ride in the 1 Hz `state` broadcast (cap 256 KiB base64 per avatar):
|
- Avatars ride in the 1 Hz `state` broadcast (cap 256 KiB base64 per avatar):
|
||||||
simple, costs host uplink; hash-based serving is on the roadmap.
|
simple, costs host uplink; hash-based serving is on the roadmap.
|
||||||
@@ -19,8 +19,11 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
|
|||||||
video bytes come from somewhere else:
|
video bytes come from somewhere else:
|
||||||
- a link (any http/https URL): each app fetches it directly
|
- a link (any http/https URL): each app fetches it directly
|
||||||
- a Jellyfin item: each app plays it with its own Jellyfin account,
|
- a Jellyfin item: each app plays it with its own Jellyfin account,
|
||||||
friends without one through the server (/j/... endpoint)
|
friends without one through the server
|
||||||
- a file on the host's disk: the server streams it (/m/... endpoint)
|
- a file on the host's disk: the server streams it (/m/... endpoint)
|
||||||
|
- the relay (/r/...): a member's computer passes a file, a link or a
|
||||||
|
Jellyfin item on to the server, which keeps a few minutes in memory
|
||||||
|
and streams it to everyone who needs it
|
||||||
```
|
```
|
||||||
|
|
||||||
- **App**: the desktop window. It draws the video with mpv, shows who is
|
- **App**: the desktop window. It draws the video with mpv, shows who is
|
||||||
@@ -31,7 +34,8 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
|
|||||||
- **Jellyfin** (or any web host): where the video lives. For a Jellyfin
|
- **Jellyfin** (or any web host): where the video lives. For a Jellyfin
|
||||||
item the server only knows which item it is; each app signs in with its
|
item the server only knows which item it is; each app signs in with its
|
||||||
own account and reports what it watched. For friends without an account
|
own account and reports what it watched. For friends without an account
|
||||||
the server fetches the item with the host's sign-in and passes it on.
|
the member who put it up passes it on through the server (their sign-in
|
||||||
|
stays on their computer).
|
||||||
|
|
||||||
## Ways to run
|
## Ways to run
|
||||||
|
|
||||||
|
|||||||
+56
-7
@@ -1,4 +1,4 @@
|
|||||||
# Protocol (proto = 7)
|
# Protocol (proto = 8)
|
||||||
|
|
||||||
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:
|
||||||
@@ -22,7 +22,8 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
|||||||
`uptimeSecs`; unauthenticated). `GET /m/<sid>/<token>/<name>`
|
`uptimeSecs`; unauthenticated). `GET /m/<sid>/<token>/<name>`
|
||||||
serves the host's file (Range table in ARCHITECTURE.md).
|
serves the host's file (Range table in ARCHITECTURE.md).
|
||||||
`GET /j/<sid>/<token>/<itemId>` relays the current Jellyfin media (see
|
`GET /j/<sid>/<token>/<itemId>` relays the current Jellyfin media (see
|
||||||
"Jellyfin media" below).
|
"Jellyfin media" below). `/r/<sid>/<token>/...` is the relay of a
|
||||||
|
member's media (see "Relay" below).
|
||||||
|
|
||||||
## Client to server
|
## Client to server
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
|||||||
{"t":"seek","position":123.4,"seq":3}
|
{"t":"seek","position":123.4,"seq":3}
|
||||||
{"t":"swap","media":{"name":"","size":0,"duration":0,"url":"..."}}
|
{"t":"swap","media":{"name":"","size":0,"duration":0,"url":"..."}}
|
||||||
{"t":"swap","media":{"name":"","size":0,"duration":0,"jellyfin":{<ref>}}}
|
{"t":"swap","media":{"name":"","size":0,"duration":0,"jellyfin":{<ref>}}}
|
||||||
|
{"t":"swap","media":{"name":"","size":123,"duration":0,"url":"...","relay":{}}}
|
||||||
{"t":"report","position":123.1,"buffered":6.2,"eventId":41,
|
{"t":"report","position":123.1,"buffered":6.2,"eventId":41,
|
||||||
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true}}
|
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true}}
|
||||||
{"t":"ping","c":1767225600000}
|
{"t":"ping","c":1767225600000}
|
||||||
@@ -71,7 +73,7 @@ Field notes:
|
|||||||
- `chat.text` is cut to 500 characters.
|
- `chat.text` is cut to 500 characters.
|
||||||
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
|
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
|
||||||
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
|
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
|
||||||
dropped.
|
dropped. `swap.media.relay` makes it relayed media (see "Relay").
|
||||||
- Frames before `welcome` (other than `hello`) are ignored; unparsable or
|
- Frames before `welcome` (other than `hello`) are ignored; unparsable or
|
||||||
unknown frames are ignored silently.
|
unknown frames are ignored silently.
|
||||||
|
|
||||||
@@ -104,7 +106,8 @@ Field notes:
|
|||||||
`playing`, on the server's clock (see `pong`). `finished`: the media
|
`playing`, on the server's clock (see `pong`). `finished`: the media
|
||||||
reached its end (`position` = duration); it stays until a seek or swap.
|
reached its end (`position` = duration); it stays until a seek or swap.
|
||||||
`eventId` increases on seek, swap and transport changes.
|
`eventId` increases on seek, swap and transport changes.
|
||||||
`mediaId` increases on swap. `media.duration` is 0 until a client reports
|
`mediaId` increases on swap. `media.relay` is present for relayed media
|
||||||
|
(see "Relay"). `media.duration` is 0 until a client reports
|
||||||
it. `seekBy` (member id) and `seekSeq` are present only while the current
|
it. `seekBy` (member id) and `seekSeq` are present only while the current
|
||||||
`eventId` came from a seek. `relay` (omitted when false): the server can
|
`eventId` came from a seek. `relay` (omitted when false): the server can
|
||||||
relay the current Jellyfin media. `members[].jf` is the member's last
|
relay the current Jellyfin media. `members[].jf` is the member's last
|
||||||
@@ -152,6 +155,55 @@ Field notes:
|
|||||||
credential. Bytes count toward `bytesServed`.
|
credential. Bytes count toward `bytesServed`.
|
||||||
- Pasted Jellyfin links sent as plain `url` stay plain links.
|
- Pasted Jellyfin links sent as plain `url` stay plain links.
|
||||||
|
|
||||||
|
## Relay
|
||||||
|
|
||||||
|
Media whose bytes go through the server's buffer: a file on a member's
|
||||||
|
computer, a link passed on by a member, a link the server fetches itself,
|
||||||
|
or a Jellyfin item for viewers without an account. Behavior (buffer,
|
||||||
|
flow control, cases) is in [ARCHITECTURE.md](ARCHITECTURE.md), "Relay".
|
||||||
|
|
||||||
|
```
|
||||||
|
swap media.relay = {} the swapper's machine serves the bytes
|
||||||
|
media.relay = {"fetch":true} the server reads media.url itself
|
||||||
|
state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `swap`: `relay` with `jellyfin` keeps the ref (the swapper serves it to
|
||||||
|
viewers without an account; `fetch` is ignored there). `relay` without
|
||||||
|
`jellyfin`: no URL is stored or broadcast; `fetch` needs an http(s)
|
||||||
|
`url` (else `relay fetch needs an http(s) url`), which the server keeps
|
||||||
|
to itself, and a server with a password (else `server fetch needs a
|
||||||
|
server password`: an open server would fetch any address it can reach
|
||||||
|
for anyone). Any later swap ends the relay.
|
||||||
|
- `state.media.relay.source`: member id of the source (absent with
|
||||||
|
`fetch`). `live` (omitted when false): bytes can flow (the source's
|
||||||
|
channel is connected, or the server could read the link).
|
||||||
|
- `welcome.mediaUrl` for relayed media without `jellyfin`: this member's
|
||||||
|
`/r/` URL; absent for the source itself (it plays its own copy). Clients build it after a swap like the `/m` one:
|
||||||
|
`http://<host>/r/default/<token>/<mediaId>/<name>` (name URL-encoded).
|
||||||
|
- `GET /r/<sid>/<token>/<mediaId>/<name>`: the media, Range table as for
|
||||||
|
`/m`. Errors: 401 bad token, 403 kicked, 404 not the current relayed
|
||||||
|
media (a player still on an older `mediaId` gets 404, never the new
|
||||||
|
bytes), 410 unknown session, 503 + `retry-after` the source never
|
||||||
|
connected (60 s). A request whose bytes do not arrive within 60 s ends
|
||||||
|
early. Bytes count toward `bytesServed`.
|
||||||
|
- `GET /r/<sid>/<token>/source` (WebSocket): the source's relay channel,
|
||||||
|
dialled by the source (works behind NAT). Only the current source's
|
||||||
|
token is accepted; anyone else, or the source after the next swap, is
|
||||||
|
closed with code 4001 (the source stops instead of reconnecting).
|
||||||
|
Frames:
|
||||||
|
|
||||||
|
```
|
||||||
|
source -> server {"t":"info","size":<bytes>} first frame
|
||||||
|
binary: [u32 big-endian pull id][bytes] in order per pull
|
||||||
|
{"t":"end","id":1} {"t":"fail","id":1,"reason":"..."}
|
||||||
|
server -> source {"t":"pull","id":1,"start":0,"end":1048575} inclusive
|
||||||
|
{"t":"ack","id":1,"n":65536} bytes arrived
|
||||||
|
{"t":"cancel","id":1} stop that pull
|
||||||
|
```
|
||||||
|
|
||||||
|
A `size` different from the one before (a reconnect) drops the buffer.
|
||||||
|
|
||||||
## Join and reconnect
|
## Join and reconnect
|
||||||
|
|
||||||
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the server
|
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the server
|
||||||
@@ -164,6 +216,3 @@ drop), `resume` takes it over and the token and media URL stay the same.
|
|||||||
## Reserved, not implemented
|
## Reserved, not implemented
|
||||||
|
|
||||||
Server-held queue (`queue-add`, `queue-skip`) would auto-advance like a swap.
|
Server-held queue (`queue-add`, `queue-skip`) would auto-advance like a swap.
|
||||||
`RelayConstants.chunkBytes`, `creditWindowChunks`, `maxConcurrentOpen` and the
|
|
||||||
`open`, `ack`, `cancel`, `eof`, `buffered`, `state`, `event` kinds in
|
|
||||||
`MessageKind` are unused leftovers (the `state` frame does not use the enum).
|
|
||||||
+16
-7
@@ -6,16 +6,25 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
|||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
1. **Windows: first real install.** CI builds and publishes
|
1. **Windows: first real use.** CI builds and publishes
|
||||||
`watch-together-<ver>-windows-x64.zip` (libmpv bundled) and runs the
|
`watch-together-<ver>-windows-x64.zip` (libmpv bundled) and runs the
|
||||||
updater tests on NTFS, but nobody has launched the app on Windows yet.
|
updater tests on NTFS; a friend has joined from it. Still unchecked:
|
||||||
Check: playback, fullscreen controls, hosting (firewall prompt), in-app
|
fullscreen controls, hosting (firewall prompt), in-app update from one
|
||||||
update from one release to the next. The rest of the desktop tests are
|
release to the next. The rest of the desktop tests are not in CI.
|
||||||
not in CI.
|
|
||||||
|
|
||||||
2. **Jellyfin** (epic, below).
|
2. **Relay follow-ups** (see ARCHITECTURE "Relay"). First real use: owner's
|
||||||
|
PC -> VPS -> friend, Jellyfin and a file. Then:
|
||||||
|
- Source takeover: when the source leaves, another member with an
|
||||||
|
account on that Jellyfin (or the same file: name, size, hash) takes
|
||||||
|
over instead of the buffer running dry.
|
||||||
|
- "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.
|
||||||
|
- Show who is relaying and how fast in the member list; warn the source
|
||||||
|
when its upload is below the media's bitrate.
|
||||||
|
|
||||||
3. **Smaller items**
|
3. **Jellyfin** (epic, below).
|
||||||
|
|
||||||
|
4. **Smaller items**
|
||||||
- Video output robustness: if media_kit's hardware path regresses to
|
- Video output robustness: if media_kit's hardware path regresses to
|
||||||
software (see ARCHITECTURE, Media: render mode), vendor a
|
software (see ARCHITECTURE, Media: render mode), vendor a
|
||||||
media_kit_video fix that builds the EGL display from GDK's native
|
media_kit_video fix that builds the EGL display from GDK's native
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export 'src/logging.dart';
|
|||||||
export 'src/clock_sync.dart';
|
export 'src/clock_sync.dart';
|
||||||
export 'src/config.dart';
|
export 'src/config.dart';
|
||||||
export 'src/protocol.dart';
|
export 'src/protocol.dart';
|
||||||
|
export 'src/relay.dart';
|
||||||
export 'src/media_source.dart';
|
export 'src/media_source.dart';
|
||||||
export 'src/sync_engine.dart';
|
export 'src/sync_engine.dart';
|
||||||
export 'src/jellyfin/client.dart';
|
export 'src/jellyfin/client.dart';
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
abstract class RelayConstants {
|
abstract class RelayConstants {
|
||||||
static const int protoVersion = 7;
|
static const int protoVersion = 8;
|
||||||
|
|
||||||
static const int chunkBytes = 256 * 1024;
|
// Relay (`/r/...`): a member's bytes through the server's buffer.
|
||||||
static const int creditWindowChunks = 8;
|
static const int relaySliceBytes = 1 << 20;
|
||||||
static const int maxConcurrentOpen = 16;
|
static const int relayFrameBytes = 64 * 1024;
|
||||||
|
/// Defaults for the buffer; they mirror a viewer's mpv buffer (150 s /
|
||||||
|
/// 256 MiB ahead, 64 MiB back), so whatever a viewer may seek to soon is
|
||||||
|
/// already on the server.
|
||||||
|
static const int relayAheadBytes = 256 << 20;
|
||||||
|
static const int relayBackBytes = 64 << 20;
|
||||||
|
/// Kept regardless of position: containers keep their index at the start
|
||||||
|
/// or the end, and players read both on open and on seeks.
|
||||||
|
static const int relayPinBytes = 2 << 20;
|
||||||
|
static const int relayMinWindowBytes = 256 * 1024;
|
||||||
|
static const int relayMaxWindowBytes = 8 << 20;
|
||||||
|
/// A viewer request waits this long for its bytes (source away) before
|
||||||
|
/// the server gives up on it.
|
||||||
|
static const int relayWaitSecs = 60;
|
||||||
|
|
||||||
static const int seekDebounceMs = 300;
|
static const int seekDebounceMs = 300;
|
||||||
static const double driftSeekThresholdSecs = 5.0;
|
static const double driftSeekThresholdSecs = 5.0;
|
||||||
|
|||||||
@@ -1,22 +1,6 @@
|
|||||||
import 'constants.dart';
|
import 'constants.dart';
|
||||||
import 'errors.dart';
|
import 'errors.dart';
|
||||||
|
|
||||||
enum MessageKind {
|
|
||||||
hello,
|
|
||||||
welcome,
|
|
||||||
state,
|
|
||||||
event,
|
|
||||||
chat,
|
|
||||||
buffered,
|
|
||||||
kicked,
|
|
||||||
kick,
|
|
||||||
eof,
|
|
||||||
err,
|
|
||||||
open,
|
|
||||||
ack,
|
|
||||||
cancel,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads an optional JSON number as a finite double. JSON like 1e400 decodes to
|
/// Reads an optional JSON number as a finite double. JSON like 1e400 decodes to
|
||||||
/// Infinity, which would later break jsonEncode on every broadcast.
|
/// Infinity, which would later break jsonEncode on every broadcast.
|
||||||
double finiteDouble(Object? value, String field, {double fallback = 0.0}) {
|
double finiteDouble(Object? value, String field, {double fallback = 0.0}) {
|
||||||
@@ -111,11 +95,23 @@ class HelloMedia {
|
|||||||
final String? url;
|
final String? url;
|
||||||
/// Set for Jellyfin media; then there is no [url] (the server drops one).
|
/// Set for Jellyfin media; then there is no [url] (the server drops one).
|
||||||
final JellyfinRef? jellyfin;
|
final JellyfinRef? jellyfin;
|
||||||
|
/// Relayed media (bytes from the source member through the server).
|
||||||
|
final MediaRelay? relay;
|
||||||
const HelloMedia(
|
const HelloMedia(
|
||||||
{required this.name, required this.size, required this.duration, this.url, this.jellyfin});
|
{required this.name,
|
||||||
|
required this.size,
|
||||||
|
required this.duration,
|
||||||
|
this.url,
|
||||||
|
this.jellyfin,
|
||||||
|
this.relay});
|
||||||
|
|
||||||
HelloMedia copyWith({double? duration}) => HelloMedia(
|
HelloMedia copyWith({double? duration, int? size, MediaRelay? relay}) => HelloMedia(
|
||||||
name: name, size: size, duration: duration ?? this.duration, url: url, jellyfin: jellyfin);
|
name: name,
|
||||||
|
size: size ?? this.size,
|
||||||
|
duration: duration ?? this.duration,
|
||||||
|
url: url,
|
||||||
|
jellyfin: jellyfin,
|
||||||
|
relay: relay ?? this.relay);
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {
|
Map<String, Object?> toJson() => {
|
||||||
'name': name,
|
'name': name,
|
||||||
@@ -123,20 +119,48 @@ class HelloMedia {
|
|||||||
'duration': duration,
|
'duration': duration,
|
||||||
if (url != null) 'url': url,
|
if (url != null) 'url': url,
|
||||||
if (jellyfin != null) 'jellyfin': jellyfin!.toJson(),
|
if (jellyfin != null) 'jellyfin': jellyfin!.toJson(),
|
||||||
|
if (relay != null) 'relay': relay!.toJson(),
|
||||||
};
|
};
|
||||||
|
|
||||||
factory HelloMedia.fromJson(Map<String, Object?> json) {
|
factory HelloMedia.fromJson(Map<String, Object?> json) {
|
||||||
final jf = json['jellyfin'];
|
final jf = json['jellyfin'];
|
||||||
|
final relay = json['relay'];
|
||||||
return HelloMedia(
|
return HelloMedia(
|
||||||
name: (json['name'] as String?) ?? '',
|
name: (json['name'] as String?) ?? '',
|
||||||
size: finiteInt(json['size'], 'size', fallback: 0),
|
size: finiteInt(json['size'], 'size', fallback: 0),
|
||||||
duration: finiteDouble(json['duration'], 'duration'),
|
duration: finiteDouble(json['duration'], 'duration'),
|
||||||
url: json['url'] as String?,
|
url: json['url'] as String?,
|
||||||
jellyfin: jf is Map ? JellyfinRef.fromJson(Map<String, Object?>.from(jf)) : null,
|
jellyfin: jf is Map ? JellyfinRef.fromJson(Map<String, Object?>.from(jf)) : null,
|
||||||
|
relay: relay is Map ? MediaRelay.fromJson(Map<String, Object?>.from(relay)) : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Relayed media: the bytes go through the server's buffer (`/r/...`).
|
||||||
|
/// Either from the [source] member's machine (it dials the relay channel),
|
||||||
|
/// or, with [fetch], the server reads the swap's link itself. A swap sends
|
||||||
|
/// `{}` or `{"fetch":true}`; the server fills in [source] (the swapper's
|
||||||
|
/// member id, empty with [fetch]) and whether bytes can flow ([live]).
|
||||||
|
class MediaRelay {
|
||||||
|
final String source;
|
||||||
|
final bool fetch;
|
||||||
|
final bool live;
|
||||||
|
const MediaRelay({this.source = '', this.fetch = false, this.live = false});
|
||||||
|
|
||||||
|
MediaRelay copyWith({bool? live}) => MediaRelay(source: source, fetch: fetch, live: live ?? this.live);
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
if (source.isNotEmpty) 'source': source,
|
||||||
|
if (fetch) 'fetch': true,
|
||||||
|
if (live) 'live': true,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory MediaRelay.fromJson(Map<String, Object?> json) => MediaRelay(
|
||||||
|
source: json['source'] is String ? json['source'] as String : '',
|
||||||
|
fetch: json['fetch'] == true,
|
||||||
|
live: json['live'] == true);
|
||||||
|
}
|
||||||
|
|
||||||
class HelloMessage {
|
class HelloMessage {
|
||||||
final int proto;
|
final int proto;
|
||||||
final String secret;
|
final String secret;
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'constants.dart';
|
||||||
|
import 'logging.dart';
|
||||||
|
|
||||||
|
// The relay channel: a member's machine (the source) serves the current
|
||||||
|
// media's bytes to the server, which buffers them for every viewer (`/r/...`).
|
||||||
|
// The source dials out (works behind NAT/CGNAT) on its own WebSocket, so
|
||||||
|
// video bytes never queue behind `/sync` messages.
|
||||||
|
//
|
||||||
|
// source -> server {"t":"info","size":N} first frame
|
||||||
|
// binary [u32 BE pull id][bytes] in order per pull
|
||||||
|
// {"t":"end","id":1} pull complete
|
||||||
|
// {"t":"fail","id":1,"reason":"..."} cannot read
|
||||||
|
// server -> source {"t":"pull","id":1,"start":0,"end":N} bytes start..end
|
||||||
|
// {"t":"ack","id":1,"n":65536} n bytes arrived
|
||||||
|
// {"t":"cancel","id":1} stop this pull
|
||||||
|
//
|
||||||
|
// Flow control is on the source: one adaptive in-flight budget per
|
||||||
|
// connection (see [RelayWindow]); a cancelled pull's unacked bytes are
|
||||||
|
// written off.
|
||||||
|
|
||||||
|
final _log = createLogger('relay');
|
||||||
|
|
||||||
|
/// Binary relay frame: 4-byte big-endian pull id, then payload.
|
||||||
|
Uint8List encodeRelayFrame(int id, List<int> payload) {
|
||||||
|
final out = Uint8List(4 + payload.length);
|
||||||
|
ByteData.sublistView(out).setUint32(0, id);
|
||||||
|
out.setRange(4, out.length, payload);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
(int id, Uint8List payload) decodeRelayFrame(Uint8List frame) {
|
||||||
|
if (frame.length < 4) throw const FormatException('short relay frame');
|
||||||
|
return (ByteData.sublistView(frame, 0, 4).getUint32(0), Uint8List.sublistView(frame, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a source reads the media from.
|
||||||
|
abstract interface class RelayBytes {
|
||||||
|
/// Total length in bytes.
|
||||||
|
Future<int> size();
|
||||||
|
|
||||||
|
/// Bytes [start]..[end] (inclusive), in order. Cancelling the
|
||||||
|
/// subscription stops reading.
|
||||||
|
Stream<List<int>> read(int start, int end);
|
||||||
|
|
||||||
|
Future<void> close();
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileRelayBytes implements RelayBytes {
|
||||||
|
final File file;
|
||||||
|
FileRelayBytes(this.file);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> size() => file.length();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<int>> read(int start, int end) => file.openRead(start, end + 1);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An HTTP(S) resource with Range support, e.g. a link or a Jellyfin
|
||||||
|
/// stream; [headers] (auth) never leave this machine.
|
||||||
|
class HttpRelayBytes implements RelayBytes {
|
||||||
|
final Uri url;
|
||||||
|
final Map<String, String> headers;
|
||||||
|
final HttpClient _client;
|
||||||
|
int? _size;
|
||||||
|
HttpRelayBytes(this.url, {this.headers = const {}, HttpClient? client})
|
||||||
|
: _client = client ?? HttpClient();
|
||||||
|
|
||||||
|
Future<HttpClientResponse> _get(String range) async {
|
||||||
|
final req = await _client.getUrl(url).timeout(const Duration(seconds: 20));
|
||||||
|
headers.forEach(req.headers.set);
|
||||||
|
req.headers
|
||||||
|
..set(HttpHeaders.rangeHeader, range)
|
||||||
|
..set(HttpHeaders.acceptEncodingHeader, 'identity');
|
||||||
|
final res = await req.close().timeout(const Duration(seconds: 20));
|
||||||
|
if (res.statusCode != 200 && res.statusCode != 206) {
|
||||||
|
await res.drain<void>().catchError((_) {});
|
||||||
|
throw HttpException('upstream HTTP ${res.statusCode}', uri: url);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> size() async {
|
||||||
|
if (_size != null) return _size!;
|
||||||
|
final res = await _get('bytes=0-0');
|
||||||
|
await res.drain<void>().catchError((_) {});
|
||||||
|
// A web page (YouTube and co.) only a player can resolve, not a file.
|
||||||
|
final type = res.headers.contentType?.mimeType ?? '';
|
||||||
|
if (type.startsWith('text/')) throw HttpException('not a video file ($type)', uri: url);
|
||||||
|
// `bytes 0-0/12345`; a server ignoring Range answers 200 with the length.
|
||||||
|
final total = res.headers.value(HttpHeaders.contentRangeHeader)?.split('/').last;
|
||||||
|
final n = int.tryParse(total ?? '') ?? (res.statusCode == 200 ? res.contentLength : -1);
|
||||||
|
if (n <= 0) throw HttpException('upstream size unknown', uri: url);
|
||||||
|
return _size = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<int>> read(int start, int end) async* {
|
||||||
|
final res = await _get('bytes=$start-$end');
|
||||||
|
if (res.statusCode == 200 && start > 0) {
|
||||||
|
throw HttpException('upstream ignores Range', uri: url);
|
||||||
|
}
|
||||||
|
yield* res;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async => _client.close(force: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-connection in-flight budget: 2 x (delivery rate x min RTT),
|
||||||
|
/// clamped to [RelayConstants.relayMinWindowBytes]..[RelayConstants.relayMaxWindowBytes].
|
||||||
|
/// The rate is the max of the last 2 s of samples, so a pause or a burst of
|
||||||
|
/// cancels does not collapse it (BBR's bandwidth max-filter); spike results
|
||||||
|
/// in docs/ARCHITECTURE.md ("Relay").
|
||||||
|
class RelayWindow {
|
||||||
|
var window = RelayConstants.relayMinWindowBytes;
|
||||||
|
var inflight = 0;
|
||||||
|
var minRttUs = 1 << 40;
|
||||||
|
var _sent = 0, _acked = 0, _writtenOff = 0;
|
||||||
|
final _sw = Stopwatch()..start();
|
||||||
|
final _sendTimes = Queue<(int, int)>(); // (cumulative sent, time us)
|
||||||
|
final _acks = Queue<(int, int)>(); // (time us, cumulative acked)
|
||||||
|
final _rates = Queue<(int, double)>(); // (time us, bytes/s)
|
||||||
|
Completer<void>? _room;
|
||||||
|
|
||||||
|
bool get full => inflight >= window;
|
||||||
|
|
||||||
|
void sent(int n) {
|
||||||
|
_sent += n;
|
||||||
|
inflight += n;
|
||||||
|
_sendTimes.add((_sent, _sw.elapsedMicroseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
void acked(int n) {
|
||||||
|
inflight -= n;
|
||||||
|
_acked += n;
|
||||||
|
final now = _sw.elapsedMicroseconds;
|
||||||
|
while (_sendTimes.isNotEmpty && _sendTimes.first.$1 <= _acked + _writtenOff) {
|
||||||
|
minRttUs = min(minRttUs, now - _sendTimes.removeFirst().$2);
|
||||||
|
}
|
||||||
|
_acks.add((now, _acked));
|
||||||
|
while (_acks.length > 2 && now - _acks.first.$1 > max(2 * minRttUs, 200000)) {
|
||||||
|
_acks.removeFirst();
|
||||||
|
}
|
||||||
|
if (_acks.length >= 2 && minRttUs < (1 << 40)) {
|
||||||
|
final dt = (now - _acks.first.$1) / 1e6;
|
||||||
|
if (dt > 0.02) {
|
||||||
|
_rates.add((now, (_acked - _acks.first.$2) / dt));
|
||||||
|
while (now - _rates.first.$1 > 2000000) {
|
||||||
|
_rates.removeFirst();
|
||||||
|
}
|
||||||
|
final rate = _rates.map((r) => r.$2).reduce(max);
|
||||||
|
window = (2 * rate * minRttUs / 1e6)
|
||||||
|
.clamp(RelayConstants.relayMinWindowBytes, RelayConstants.relayMaxWindowBytes)
|
||||||
|
.toInt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes of a cancelled pull that will never be acked.
|
||||||
|
void writeOff(int n) {
|
||||||
|
inflight -= n;
|
||||||
|
_writtenOff += n;
|
||||||
|
_wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _wake() {
|
||||||
|
final r = _room;
|
||||||
|
_room = null;
|
||||||
|
r?.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> room() async {
|
||||||
|
while (full) {
|
||||||
|
await (_room ??= Completer<void>()).future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Pull {
|
||||||
|
final int id;
|
||||||
|
var sent = 0, acked = 0;
|
||||||
|
var cancelled = false, finished = false;
|
||||||
|
StreamIterator<List<int>>? it;
|
||||||
|
_Pull(this.id);
|
||||||
|
bool get settled => cancelled || (finished && acked >= sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close code the server uses for "you are not (or no longer) the source of
|
||||||
|
/// the current media": the source stops instead of reconnecting.
|
||||||
|
const relayCloseNotSource = 4001;
|
||||||
|
|
||||||
|
/// Serves [bytes] on the relay channel at [url] (`ws://host/r/<sid>/<token>/source`)
|
||||||
|
/// until [close]. Reconnects with backoff when the channel drops; stops for
|
||||||
|
/// good when the server closes it with [relayCloseNotSource].
|
||||||
|
class RelaySource {
|
||||||
|
final Uri url;
|
||||||
|
final RelayBytes bytes;
|
||||||
|
final Duration Function(int attempt) backoff;
|
||||||
|
RelaySource(this.url, this.bytes, {Duration Function(int attempt)? backoff})
|
||||||
|
: backoff = backoff ?? ((a) => Duration(milliseconds: min(8000, 500 * (1 << min(a, 4)))));
|
||||||
|
|
||||||
|
final window = RelayWindow();
|
||||||
|
final _pulls = <int, _Pull>{};
|
||||||
|
WebSocket? _ws;
|
||||||
|
var _closed = false;
|
||||||
|
final _done = Completer<void>();
|
||||||
|
|
||||||
|
/// Completes when the source stopped for good (closed or refused).
|
||||||
|
Future<void> get done => _done.future;
|
||||||
|
|
||||||
|
/// Bytes sent to the server, for stats.
|
||||||
|
int bytesSent = 0;
|
||||||
|
|
||||||
|
Future<void> start() async {
|
||||||
|
final size = await bytes.size();
|
||||||
|
unawaited(_loop(size));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loop(int size) async {
|
||||||
|
var attempt = 0;
|
||||||
|
while (!_closed) {
|
||||||
|
try {
|
||||||
|
final ws = await WebSocket.connect(url.toString()).timeout(const Duration(seconds: 15));
|
||||||
|
if (_closed) {
|
||||||
|
await ws.close();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ws = ws;
|
||||||
|
attempt = 0;
|
||||||
|
ws.add(jsonEncode({'t': 'info', 'size': size}));
|
||||||
|
await for (final m in ws) {
|
||||||
|
if (m is String) _onText(ws, m);
|
||||||
|
}
|
||||||
|
if (ws.closeCode == relayCloseNotSource) {
|
||||||
|
_log.info('relay channel: not the source any more, stopping');
|
||||||
|
_closed = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.fine('relay channel: $e');
|
||||||
|
}
|
||||||
|
_ws = null;
|
||||||
|
_abandonAll();
|
||||||
|
if (_closed) break;
|
||||||
|
await Future<void>.delayed(backoff(attempt++));
|
||||||
|
}
|
||||||
|
_abandonAll();
|
||||||
|
await bytes.close();
|
||||||
|
if (!_done.isCompleted) _done.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onText(WebSocket ws, String text) {
|
||||||
|
final Map j;
|
||||||
|
try {
|
||||||
|
j = jsonDecode(text) as Map;
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final id = j['id'];
|
||||||
|
if (id is! int) return;
|
||||||
|
switch (j['t']) {
|
||||||
|
case 'pull':
|
||||||
|
final start = j['start'], end = j['end'];
|
||||||
|
if (start is! int || end is! int || start < 0 || end < start) return;
|
||||||
|
final p = _Pull(id);
|
||||||
|
_pulls[id] = p;
|
||||||
|
unawaited(_serve(ws, p, start, end));
|
||||||
|
case 'ack':
|
||||||
|
final p = _pulls[id];
|
||||||
|
final n = j['n'];
|
||||||
|
if (p == null || p.cancelled || n is! int) return;
|
||||||
|
p.acked += n;
|
||||||
|
window.acked(n);
|
||||||
|
if (p.settled) _pulls.remove(id);
|
||||||
|
case 'cancel':
|
||||||
|
final p = _pulls.remove(id);
|
||||||
|
if (p == null) return;
|
||||||
|
_cancel(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancel(_Pull p) {
|
||||||
|
if (p.cancelled) return;
|
||||||
|
p.cancelled = true;
|
||||||
|
window.writeOff(p.sent - p.acked);
|
||||||
|
p.acked = p.sent;
|
||||||
|
unawaited(p.it?.cancel());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _abandonAll() {
|
||||||
|
for (final p in _pulls.values.toList()) {
|
||||||
|
_cancel(p);
|
||||||
|
}
|
||||||
|
_pulls.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _serve(WebSocket ws, _Pull p, int start, int end) async {
|
||||||
|
final it = p.it = StreamIterator(bytes.read(start, end));
|
||||||
|
final pending = BytesBuilder(copy: false);
|
||||||
|
const frame = RelayConstants.relayFrameBytes;
|
||||||
|
try {
|
||||||
|
var more = true;
|
||||||
|
while (!p.cancelled) {
|
||||||
|
while (more && pending.length < frame) {
|
||||||
|
more = await it.moveNext();
|
||||||
|
if (more) pending.add(it.current);
|
||||||
|
}
|
||||||
|
if (pending.isEmpty) break;
|
||||||
|
await window.room();
|
||||||
|
// Check and send in one synchronous step: a cancel that lands
|
||||||
|
// during an await must not be followed by a sent frame.
|
||||||
|
if (p.cancelled) break;
|
||||||
|
final all = pending.takeBytes();
|
||||||
|
final n = min(frame, all.length);
|
||||||
|
ws.add(encodeRelayFrame(p.id, Uint8List.sublistView(all, 0, n)));
|
||||||
|
if (n < all.length) pending.add(Uint8List.sublistView(all, n));
|
||||||
|
p.sent += n;
|
||||||
|
bytesSent += n;
|
||||||
|
window.sent(n);
|
||||||
|
}
|
||||||
|
if (!p.cancelled) ws.add(jsonEncode({'t': 'end', 'id': p.id}));
|
||||||
|
} catch (e) {
|
||||||
|
if (!p.cancelled) {
|
||||||
|
_log.warning('relay read ${p.id} failed: $e');
|
||||||
|
try {
|
||||||
|
ws.add(jsonEncode({'t': 'fail', 'id': p.id, 'reason': '$e'}));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
p.finished = true;
|
||||||
|
if (p.settled) _pulls.remove(p.id);
|
||||||
|
await it.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops serving; [done] completes once the loop has wound down (a
|
||||||
|
/// pending reconnect delay runs out first).
|
||||||
|
Future<void> close() async {
|
||||||
|
_closed = true;
|
||||||
|
await _ws?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,3 +4,5 @@ export 'src/server_hub.dart';
|
|||||||
export 'src/session.dart';
|
export 'src/session.dart';
|
||||||
export 'src/sync_server.dart';
|
export 'src/sync_server.dart';
|
||||||
export 'src/jellyfin_relay.dart';
|
export 'src/jellyfin_relay.dart';
|
||||||
|
export 'src/relay_buffer.dart';
|
||||||
|
export 'src/relay_routes.dart';
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
final _log = createLogger('relay');
|
||||||
|
|
||||||
|
/// What feeds a [RelayBuffer]: a member's relay channel, or the server
|
||||||
|
/// itself fetching a link ([LocalRelayFeed]).
|
||||||
|
abstract class RelayFeed {
|
||||||
|
/// Ask for bytes [start]..[end]; they arrive via [RelayBuffer.feedBytes]
|
||||||
|
/// in order, then [RelayBuffer.feedEnd].
|
||||||
|
void pull(int id, int start, int end);
|
||||||
|
void cancel(int id);
|
||||||
|
|
||||||
|
/// Bytes of [id] reached the buffer (the remote feed acks them).
|
||||||
|
void arrived(int id, int n) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches from [bytes] in this process (the "server fetches it" mode).
|
||||||
|
class LocalRelayFeed extends RelayFeed {
|
||||||
|
final RelayBytes bytes;
|
||||||
|
final RelayBuffer buffer;
|
||||||
|
final _subs = <int, StreamSubscription<List<int>>>{};
|
||||||
|
LocalRelayFeed(this.bytes, this.buffer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pull(int id, int start, int end) {
|
||||||
|
_subs[id] = bytes.read(start, end).listen(
|
||||||
|
(c) => buffer.feedBytes(id, c is Uint8List ? c : Uint8List.fromList(c)),
|
||||||
|
onError: (Object e) {
|
||||||
|
_subs.remove(id)?.cancel();
|
||||||
|
buffer.feedFail(id, '$e');
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
_subs.remove(id);
|
||||||
|
buffer.feedEnd(id);
|
||||||
|
},
|
||||||
|
cancelOnError: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void cancel(int id) => _subs.remove(id)?.cancel();
|
||||||
|
|
||||||
|
Future<void> close() async {
|
||||||
|
for (final s in _subs.values) {
|
||||||
|
await s.cancel();
|
||||||
|
}
|
||||||
|
_subs.clear();
|
||||||
|
await bytes.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A relay channel (`/r/.../source`) as a feed.
|
||||||
|
class ChannelRelayFeed extends RelayFeed {
|
||||||
|
final void Function(String text) send;
|
||||||
|
ChannelRelayFeed(this.send);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pull(int id, int start, int end) =>
|
||||||
|
send(jsonEncode({'t': 'pull', 'id': id, 'start': start, 'end': end}));
|
||||||
|
|
||||||
|
@override
|
||||||
|
void cancel(int id) => send(jsonEncode({'t': 'cancel', 'id': id}));
|
||||||
|
|
||||||
|
@override
|
||||||
|
void arrived(int id, int n) => send(jsonEncode({'t': 'ack', 'id': id, 'n': n}));
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Pull {
|
||||||
|
final int id, start, end;
|
||||||
|
int pos;
|
||||||
|
final buf = BytesBuilder(copy: false);
|
||||||
|
_Pull(this.id, this.start, this.end) : pos = start;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Viewer {
|
||||||
|
int pos = 0;
|
||||||
|
int open = 0; // requests in flight
|
||||||
|
int newest = 0; // sequence of its newest request
|
||||||
|
DateTime lastActive = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server side of the relay for one media: an in-memory buffer of
|
||||||
|
/// [RelayConstants.relaySliceBytes] slices filled from one [RelayFeed] with
|
||||||
|
/// one sequential pull at a time, served to any number of viewers.
|
||||||
|
///
|
||||||
|
/// - Viewers only ever talk HTTP to the server; the feed never sees their
|
||||||
|
/// request pattern (mpv leaves superseded requests open but unread, and
|
||||||
|
/// jumps to the file's end for the index).
|
||||||
|
/// - Kept: the file's first and last [pin] bytes, plus [back] behind the
|
||||||
|
/// slowest and [ahead] past the furthest viewer. Evicted by distance from
|
||||||
|
/// the viewers, never LRU.
|
||||||
|
/// - Filled only while someone watches: a viewer blocked on a slice comes
|
||||||
|
/// first (oldest wait first); then read-ahead.
|
||||||
|
/// - Only a viewer's newest request moves its position or asks the feed
|
||||||
|
/// for anything; older ones still get bytes once they are buffered.
|
||||||
|
/// Everything lives in memory: dropping the buffer is the cleanup.
|
||||||
|
class RelayBuffer {
|
||||||
|
final int ahead, back, pin;
|
||||||
|
final Duration waitLimit;
|
||||||
|
RelayBuffer({
|
||||||
|
this.ahead = RelayConstants.relayAheadBytes,
|
||||||
|
this.back = RelayConstants.relayBackBytes,
|
||||||
|
this.pin = RelayConstants.relayPinBytes,
|
||||||
|
this.waitLimit = const Duration(seconds: RelayConstants.relayWaitSecs),
|
||||||
|
});
|
||||||
|
|
||||||
|
static const slice = RelayConstants.relaySliceBytes;
|
||||||
|
|
||||||
|
RelayFeed? _feed;
|
||||||
|
int size = 0;
|
||||||
|
final _slices = <int, Uint8List>{};
|
||||||
|
final _waiters = <int, List<Completer<void>>>{};
|
||||||
|
final _blocked = <int, int>{}; // slice -> wait start (us)
|
||||||
|
final _viewers = <String, _Viewer>{};
|
||||||
|
_Pull? _pull;
|
||||||
|
var _nextId = 1, _reqSeq = 0;
|
||||||
|
var _disposed = false;
|
||||||
|
Completer<void> _kick = Completer();
|
||||||
|
final Completer<void> _ready = Completer();
|
||||||
|
|
||||||
|
/// Totals, for logs and tests.
|
||||||
|
int bytesFromFeed = 0, bytesToViewers = 0;
|
||||||
|
|
||||||
|
bool get live => _feed != null && size > 0;
|
||||||
|
int get cachedBytes => _slices.values.fold(0, (a, s) => a + s.length);
|
||||||
|
int get _nSlices => (size + slice - 1) ~/ slice;
|
||||||
|
int _sliceEnd(int s) => min((s + 1) * slice, size) - 1;
|
||||||
|
|
||||||
|
/// Attaches a (re)connected feed for a file of [size] bytes. A different
|
||||||
|
/// size means different content: the buffer starts over.
|
||||||
|
void attach(RelayFeed feed, int size) {
|
||||||
|
if (_disposed) return;
|
||||||
|
if (this.size != 0 && this.size != size) {
|
||||||
|
_log.info('relay source changed size ${this.size} -> $size, dropping buffer');
|
||||||
|
_slices.clear();
|
||||||
|
}
|
||||||
|
_feed = feed;
|
||||||
|
this.size = size;
|
||||||
|
if (!_ready.isCompleted) _ready.complete();
|
||||||
|
if (_fillerRunning) {
|
||||||
|
_wake();
|
||||||
|
} else {
|
||||||
|
unawaited(_filler());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The feed went away; buffered slices keep serving.
|
||||||
|
void detach(RelayFeed feed) {
|
||||||
|
if (!identical(_feed, feed)) return;
|
||||||
|
_feed = null;
|
||||||
|
_pull = null;
|
||||||
|
_wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_feed = null;
|
||||||
|
_pull = null;
|
||||||
|
_slices.clear();
|
||||||
|
for (final list in _waiters.values) {
|
||||||
|
for (final c in list) {
|
||||||
|
if (!c.isCompleted) c.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_waiters.clear();
|
||||||
|
if (!_ready.isCompleted) _ready.complete();
|
||||||
|
_wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _wake() {
|
||||||
|
if (!_kick.isCompleted) _kick.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ feed input
|
||||||
|
|
||||||
|
void feedBytes(int id, Uint8List data) {
|
||||||
|
final pl = _pull;
|
||||||
|
if (pl == null || pl.id != id || _disposed) return;
|
||||||
|
_feed?.arrived(id, data.length);
|
||||||
|
bytesFromFeed += data.length;
|
||||||
|
var off = 0;
|
||||||
|
while (off < data.length) {
|
||||||
|
final s = pl.pos ~/ slice;
|
||||||
|
final take = min(_sliceEnd(s) + 1 - pl.pos, data.length - off);
|
||||||
|
if (take <= 0) {
|
||||||
|
// Past the end the feed claimed: protocol violation.
|
||||||
|
_endPull(pl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pl.buf.add(Uint8List.sublistView(data, off, off + take));
|
||||||
|
off += take;
|
||||||
|
pl.pos += take;
|
||||||
|
if (pl.pos == _sliceEnd(s) + 1) {
|
||||||
|
_slices[s] = pl.buf.takeBytes();
|
||||||
|
for (final c in _waiters.remove(s) ?? const <Completer<void>>[]) {
|
||||||
|
c.complete();
|
||||||
|
}
|
||||||
|
if (_shouldStop(pl, s)) {
|
||||||
|
_endPull(pl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void feedEnd(int id) {
|
||||||
|
final pl = _pull;
|
||||||
|
if (pl != null && pl.id == id) {
|
||||||
|
_pull = null;
|
||||||
|
_wake();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void feedFail(int id, String reason) {
|
||||||
|
_log.warning('relay pull $id failed: $reason');
|
||||||
|
feedEnd(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- scheduling
|
||||||
|
|
||||||
|
bool _shouldStop(_Pull pl, int done) {
|
||||||
|
final next = done + 1;
|
||||||
|
if (next >= _nSlices || next * slice > pl.end) return false; // ends naturally
|
||||||
|
// Someone is blocked elsewhere: hand the feed over at this boundary.
|
||||||
|
if (_blocked.keys.any((w) => w != next && w != next + 1 && !_slices.containsKey(w))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (_slices.containsKey(next)) return true;
|
||||||
|
return !_wanted(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _endPull(_Pull pl) {
|
||||||
|
if (!identical(_pull, pl)) return;
|
||||||
|
_pull = null;
|
||||||
|
if (pl.pos <= pl.end) _feed?.cancel(pl.id);
|
||||||
|
_wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
Iterable<_Viewer> get _active {
|
||||||
|
final now = DateTime.now();
|
||||||
|
return _viewers.values
|
||||||
|
.where((v) => v.open > 0 || now.difference(v.lastActive) < const Duration(seconds: 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _pinned(int s) => s * slice < pin || s * slice >= size - pin;
|
||||||
|
|
||||||
|
bool _wanted(int s) {
|
||||||
|
if (_pinned(s)) return true;
|
||||||
|
final pos = _active.map((v) => v.pos).toList();
|
||||||
|
if (pos.isEmpty) return false;
|
||||||
|
final off = s * slice;
|
||||||
|
return off >= pos.reduce(min) - back && off <= pos.reduce(max) + ahead;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _distance(int s) {
|
||||||
|
if (_pinned(s)) return -1;
|
||||||
|
final off = s * slice;
|
||||||
|
var d = 1 << 62;
|
||||||
|
for (final v in _active) {
|
||||||
|
// Behind a viewer costs more than ahead (it was already watched).
|
||||||
|
d = min(d, off >= v.pos ? off - v.pos : (v.pos - off) * 4);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _evict() {
|
||||||
|
final limit = ahead + back + 2 * pin;
|
||||||
|
var total = _slices.length * slice;
|
||||||
|
if (total <= limit) return;
|
||||||
|
final order = _slices.keys.toList()..sort((a, b) => _distance(b).compareTo(_distance(a)));
|
||||||
|
for (final s in order) {
|
||||||
|
if (total <= limit || _distance(s) < 0) break;
|
||||||
|
if (_waiters.containsKey(s)) continue;
|
||||||
|
_slices.remove(s);
|
||||||
|
total -= slice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _nextMissing() {
|
||||||
|
final blocked = _blocked.entries.where((e) => !_slices.containsKey(e.key)).toList()
|
||||||
|
..sort((a, b) => a.value.compareTo(b.value));
|
||||||
|
if (blocked.isNotEmpty) return blocked.first.key;
|
||||||
|
final active = _active.toList();
|
||||||
|
if (active.isEmpty) return null; // nobody watches: fetch nothing
|
||||||
|
for (var s = 0; s * slice < pin && s < _nSlices; s++) {
|
||||||
|
if (!_slices.containsKey(s)) return s;
|
||||||
|
}
|
||||||
|
for (var s = max(0, (size - pin) ~/ slice); s < _nSlices; s++) {
|
||||||
|
if (!_slices.containsKey(s)) return s;
|
||||||
|
}
|
||||||
|
final starts = active.map((v) => v.pos).toList()..sort();
|
||||||
|
for (final p in starts) {
|
||||||
|
for (var s = p ~/ slice; s * slice <= p + ahead && s < _nSlices; s++) {
|
||||||
|
if (!_slices.containsKey(s)) return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var _fillerRunning = false;
|
||||||
|
|
||||||
|
Future<void> _filler() async {
|
||||||
|
_fillerRunning = true;
|
||||||
|
try {
|
||||||
|
while (!_disposed) {
|
||||||
|
final feed = _feed;
|
||||||
|
if (feed == null || _pull != null) {
|
||||||
|
await _sleep();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_evict();
|
||||||
|
final s = _nextMissing();
|
||||||
|
if (s == null) {
|
||||||
|
await _sleep();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var e = s;
|
||||||
|
while (e + 1 < _nSlices && !_slices.containsKey(e + 1) && _wanted(e + 1)) {
|
||||||
|
e++;
|
||||||
|
}
|
||||||
|
final pl = _Pull(_nextId++, s * slice, _sliceEnd(e));
|
||||||
|
_pull = pl;
|
||||||
|
feed.pull(pl.id, pl.start, pl.end);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_fillerRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sleep() async {
|
||||||
|
_kick = Completer();
|
||||||
|
// Also wake periodically: viewers age out of [_active].
|
||||||
|
await _kick.future.timeout(const Duration(seconds: 5), onTimeout: () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _waitSlice(int s, bool drives) async {
|
||||||
|
final deadline = DateTime.now().add(waitLimit);
|
||||||
|
while (!_slices.containsKey(s)) {
|
||||||
|
if (_disposed || DateTime.now().isAfter(deadline)) return false;
|
||||||
|
if (drives) {
|
||||||
|
_blocked.putIfAbsent(s, () => DateTime.now().microsecondsSinceEpoch);
|
||||||
|
// A pull nobody waits on gives way at once; one serving another
|
||||||
|
// blocked viewer hands over at its next slice boundary.
|
||||||
|
final pl = _pull;
|
||||||
|
final at = pl == null ? -1 : pl.pos ~/ slice;
|
||||||
|
if (pl != null && at != s && !_blocked.containsKey(at)) _endPull(pl);
|
||||||
|
_wake();
|
||||||
|
}
|
||||||
|
final c = Completer<void>();
|
||||||
|
_waiters.putIfAbsent(s, () => []).add(c);
|
||||||
|
await c.future.timeout(const Duration(seconds: 2), onTimeout: () {});
|
||||||
|
}
|
||||||
|
_blocked.remove(s);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- viewers
|
||||||
|
|
||||||
|
/// Waits for the size (the feed's first connect), up to [waitLimit].
|
||||||
|
Future<bool> ready() async {
|
||||||
|
if (size > 0) return true;
|
||||||
|
await _ready.future.timeout(waitLimit, onTimeout: () {});
|
||||||
|
return size > 0 && !_disposed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streams [start]..[end] to [viewer]. Cancelling the subscription (the
|
||||||
|
/// viewer went away) ends it; a slice that never arrives ends it with an
|
||||||
|
/// error after [waitLimit].
|
||||||
|
Stream<Uint8List> read(String viewer, int start, int end, {void Function(int n)? onSent}) async* {
|
||||||
|
final v = _viewers.putIfAbsent(viewer, _Viewer.new);
|
||||||
|
final tail = start >= size - pin;
|
||||||
|
final me = ++_reqSeq;
|
||||||
|
if (!tail) {
|
||||||
|
v.newest = me;
|
||||||
|
v.pos = start;
|
||||||
|
}
|
||||||
|
v.open++;
|
||||||
|
v.lastActive = DateTime.now();
|
||||||
|
_wake();
|
||||||
|
try {
|
||||||
|
var off = start;
|
||||||
|
while (off <= end) {
|
||||||
|
final s = off ~/ slice;
|
||||||
|
if (!await _waitSlice(s, tail || v.newest == me)) {
|
||||||
|
throw StateError('relay: bytes at $off did not arrive');
|
||||||
|
}
|
||||||
|
final data = _slices[s];
|
||||||
|
if (data == null) continue; // evicted between wake and read
|
||||||
|
final from = off - s * slice, to = min(data.length, end - s * slice + 1);
|
||||||
|
yield Uint8List.sublistView(data, from, to);
|
||||||
|
bytesToViewers += to - from;
|
||||||
|
onSent?.call(to - from);
|
||||||
|
off += to - from;
|
||||||
|
v.lastActive = DateTime.now();
|
||||||
|
if (!tail && v.newest == me) v.pos = off;
|
||||||
|
_wake();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
v.open--;
|
||||||
|
v.lastActive = DateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:relic/relic.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:web_socket/web_socket.dart';
|
||||||
|
|
||||||
|
import 'media_endpoint.dart';
|
||||||
|
import 'relay_buffer.dart';
|
||||||
|
import 'session.dart';
|
||||||
|
|
||||||
|
final _log = createLogger('relay');
|
||||||
|
|
||||||
|
/// The relay of the current media: its buffer and where the bytes come
|
||||||
|
/// from. One per relayed swap; [dispose] on the next swap or server stop
|
||||||
|
/// (the buffer is memory only, so that is the whole cleanup).
|
||||||
|
class ActiveRelay {
|
||||||
|
final int mediaId;
|
||||||
|
final String name;
|
||||||
|
/// Token of the member whose machine serves the bytes; null when the
|
||||||
|
/// server fetches the link itself ([local]).
|
||||||
|
final String? sourceToken;
|
||||||
|
final RelayBuffer buffer;
|
||||||
|
final LocalRelayFeed? local;
|
||||||
|
final void Function() onLiveChanged;
|
||||||
|
RelicWebSocket? _channel;
|
||||||
|
ChannelRelayFeed? _channelFeed;
|
||||||
|
|
||||||
|
ActiveRelay._(this.mediaId, this.name, this.sourceToken, this.buffer, this.local, this.onLiveChanged);
|
||||||
|
|
||||||
|
/// Bytes from member [sourceToken] over its relay channel.
|
||||||
|
factory ActiveRelay.fromMember(int mediaId, String name, String sourceToken,
|
||||||
|
{RelayBuffer? buffer, required void Function() onLiveChanged}) =>
|
||||||
|
ActiveRelay._(mediaId, name, sourceToken, buffer ?? RelayBuffer(), null, onLiveChanged);
|
||||||
|
|
||||||
|
/// The server reads [url] itself.
|
||||||
|
static ActiveRelay fetch(int mediaId, String name, Uri url,
|
||||||
|
{RelayBuffer? buffer, required void Function() onLiveChanged}) {
|
||||||
|
final b = buffer ?? RelayBuffer();
|
||||||
|
final local = LocalRelayFeed(HttpRelayBytes(url), b);
|
||||||
|
final r = ActiveRelay._(mediaId, name, null, b, local, onLiveChanged);
|
||||||
|
unawaited(() async {
|
||||||
|
try {
|
||||||
|
final size = await local.bytes.size();
|
||||||
|
if (r._disposed) return;
|
||||||
|
b.attach(local, size);
|
||||||
|
onLiveChanged();
|
||||||
|
} catch (e) {
|
||||||
|
_log.warning('relay fetch: cannot read the link: $e');
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
var _disposed = false;
|
||||||
|
bool get live => buffer.live;
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
buffer.dispose();
|
||||||
|
unawaited(local?.close());
|
||||||
|
final ch = _channel;
|
||||||
|
_channel = null;
|
||||||
|
if (ch != null) unawaited(_closeNotSource(ch));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source member gone for good (kicked): stop its channel.
|
||||||
|
void dropChannel() {
|
||||||
|
final ch = _channel;
|
||||||
|
_channel = null;
|
||||||
|
if (ch != null) unawaited(_closeNotSource(ch));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _attachChannel(RelicWebSocket ws, int size) {
|
||||||
|
final old = _channel;
|
||||||
|
if (old != null && !identical(old, ws)) unawaited(old.tryClose());
|
||||||
|
_channel = ws;
|
||||||
|
final feed = _channelFeed = ChannelRelayFeed((t) => ws.trySendText(t));
|
||||||
|
buffer.attach(feed, size);
|
||||||
|
onLiveChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _detachChannel(RelicWebSocket ws) {
|
||||||
|
if (!identical(_channel, ws)) return;
|
||||||
|
_channel = null;
|
||||||
|
final feed = _channelFeed;
|
||||||
|
_channelFeed = null;
|
||||||
|
if (feed != null) buffer.detach(feed);
|
||||||
|
onLiveChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _closeNotSource(RelicWebSocket ws) async {
|
||||||
|
try {
|
||||||
|
await ws.close(relayCloseNotSource, 'not the source');
|
||||||
|
} catch (_) {
|
||||||
|
await ws.tryClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /r/<sid>/<token>/source` (WebSocket): the source member's relay
|
||||||
|
/// channel. `GET /r/<sid>/<token>/<mediaId>/<name>`: a viewer reads the
|
||||||
|
/// relayed media, Range as for `/m`. [relay] gives the current relay.
|
||||||
|
void mountRelayRoutes(
|
||||||
|
RelicApp app,
|
||||||
|
SessionMap sessions,
|
||||||
|
ActiveRelay? Function() relay, {
|
||||||
|
void Function(String token, MediaLog log)? onLog,
|
||||||
|
}) {
|
||||||
|
app.get('/r/:sid/:token/source', (final req) {
|
||||||
|
final sid = req.pathParameters.raw[#sid] ?? '';
|
||||||
|
final token = req.pathParameters.raw[#token] ?? '';
|
||||||
|
final session = sessions.get(sid);
|
||||||
|
final r = relay();
|
||||||
|
final ok = session != null &&
|
||||||
|
session.gate(token) == MediaGate.ok &&
|
||||||
|
r != null &&
|
||||||
|
r.sourceToken == token;
|
||||||
|
return WebSocketUpgrade((final ws) async {
|
||||||
|
if (!ok) {
|
||||||
|
await _closeNotSource(ws);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final r = relay()!;
|
||||||
|
ws.pingInterval = Duration(seconds: RelayConstants.pingIntervalSecs);
|
||||||
|
var attached = false;
|
||||||
|
try {
|
||||||
|
await for (final e in ws.events) {
|
||||||
|
if (r._disposed) break;
|
||||||
|
if (e is TextDataReceived) {
|
||||||
|
final Map j;
|
||||||
|
try {
|
||||||
|
j = jsonDecode(e.text) as Map;
|
||||||
|
} catch (_) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final id = j['id'];
|
||||||
|
switch (j['t']) {
|
||||||
|
case 'info':
|
||||||
|
final size = j['size'];
|
||||||
|
if (size is int && size > 0 && !attached) {
|
||||||
|
attached = true;
|
||||||
|
_log.info('relay channel up (${r.name}, $size bytes)');
|
||||||
|
r._attachChannel(ws, size);
|
||||||
|
}
|
||||||
|
case 'end':
|
||||||
|
if (id is int) r.buffer.feedEnd(id);
|
||||||
|
case 'fail':
|
||||||
|
if (id is int) r.buffer.feedFail(id, '${j['reason']}');
|
||||||
|
}
|
||||||
|
} else if (e is BinaryDataReceived && attached) {
|
||||||
|
try {
|
||||||
|
final (id, payload) = decodeRelayFrame(e.data);
|
||||||
|
r.buffer.feedBytes(id, payload);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// channel broke
|
||||||
|
} finally {
|
||||||
|
r._detachChannel(ws);
|
||||||
|
_log.info('relay channel down (${r.name})');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/r/:sid/:token/:mediaId/:name', (final req) async {
|
||||||
|
final stopwatch = Stopwatch()..start();
|
||||||
|
final sid = req.pathParameters.raw[#sid] ?? '';
|
||||||
|
final token = req.pathParameters.raw[#token] ?? '';
|
||||||
|
final mediaId = int.tryParse(req.pathParameters.raw[#mediaId] ?? '');
|
||||||
|
final session = sessions.get(sid);
|
||||||
|
if (session == null) return _plain(410);
|
||||||
|
switch (session.gate(token)) {
|
||||||
|
case MediaGate.ok:
|
||||||
|
break;
|
||||||
|
case MediaGate.forbidden:
|
||||||
|
return _plain(403);
|
||||||
|
default:
|
||||||
|
return _plain(401);
|
||||||
|
}
|
||||||
|
final r = relay();
|
||||||
|
// A player still on an older media must not get the new one's bytes.
|
||||||
|
if (r == null || r.mediaId != mediaId) return _plain(404);
|
||||||
|
if (!await r.buffer.ready()) {
|
||||||
|
return Response(503,
|
||||||
|
body: Body.fromString('relay source not connected'),
|
||||||
|
headers: Headers.build((h) => h['retry-after'] = ['2']));
|
||||||
|
}
|
||||||
|
final size = r.buffer.size;
|
||||||
|
final typed = req.headers.range;
|
||||||
|
String? rangeHeader;
|
||||||
|
if (typed != null && typed.ranges.isNotEmpty) {
|
||||||
|
final x = typed.ranges.first;
|
||||||
|
rangeHeader = '${typed.unit}=${x.start?.toString() ?? ''}-${x.end?.toString() ?? ''}';
|
||||||
|
}
|
||||||
|
final res = resolveMediaRequest(rangeHeader: rangeHeader, size: size, filename: r.name);
|
||||||
|
final headers = Headers.build((h) => res.headers.forEach((k, v) => h[k] = [v]));
|
||||||
|
if (res.status != 200 && res.status != 206) {
|
||||||
|
return Response(res.status, body: Body.fromString('status ${res.status}'), headers: headers);
|
||||||
|
}
|
||||||
|
final end = res.end ?? size - 1;
|
||||||
|
final shortToken = token.length > 4 ? '…${token.substring(token.length - 4)}' : token;
|
||||||
|
onLog?.call(
|
||||||
|
token,
|
||||||
|
MediaLog(
|
||||||
|
method: req.method.name,
|
||||||
|
path: '/r/$sid/$shortToken/$mediaId/${r.name}',
|
||||||
|
rangeHeader: rangeHeader,
|
||||||
|
status: res.status,
|
||||||
|
bytesWritten: end - res.start + 1,
|
||||||
|
durationMs: stopwatch.elapsedMilliseconds,
|
||||||
|
));
|
||||||
|
final Stream<Uint8List> body =
|
||||||
|
r.buffer.read(token, res.start, end, onSent: (n) => session.addBytes(token, n));
|
||||||
|
MimeType mime;
|
||||||
|
try {
|
||||||
|
mime = MimeType.parse(res.headers['content-type'] ?? '');
|
||||||
|
} catch (_) {
|
||||||
|
mime = MimeType.octetStream;
|
||||||
|
}
|
||||||
|
return Response(res.status,
|
||||||
|
body: Body.fromDataStream(body, mimeType: mime, contentLength: end - res.start + 1),
|
||||||
|
headers: headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Response _plain(int status) => Response(status, body: Body.fromString('status $status'));
|
||||||
@@ -5,6 +5,7 @@ import 'package:relic/relic.dart';
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:web_socket/web_socket.dart';
|
import 'package:web_socket/web_socket.dart';
|
||||||
|
|
||||||
|
import 'relay_routes.dart';
|
||||||
import 'session.dart';
|
import 'session.dart';
|
||||||
|
|
||||||
final _hubLog = createLogger('server_hub');
|
final _hubLog = createLogger('server_hub');
|
||||||
@@ -32,6 +33,29 @@ class ServerHub {
|
|||||||
|
|
||||||
StateBroadcast stateBroadcast() => engine.broadcast(relay: relayAvailable);
|
StateBroadcast stateBroadcast() => engine.broadcast(relay: relayAvailable);
|
||||||
|
|
||||||
|
/// The relay of the current media (`/r/...`), if it is relayed.
|
||||||
|
ActiveRelay? relay;
|
||||||
|
|
||||||
|
/// Replaces the relay (null: the new media is not relayed). The old
|
||||||
|
/// buffer is dropped with it.
|
||||||
|
void setRelay(ActiveRelay? next) {
|
||||||
|
relay?.dispose();
|
||||||
|
relay = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `media.relay` for a relayed swap by [token]: who serves the bytes.
|
||||||
|
MediaRelay relayFor(String token, MediaRelay requested) => MediaRelay(
|
||||||
|
source: requested.fetch ? '' : (engine.members[token]?.id ?? ''), fetch: requested.fetch);
|
||||||
|
|
||||||
|
/// The relay's channel came up or went down: `media.relay.live` follows.
|
||||||
|
void relayLiveChanged(ActiveRelay r) {
|
||||||
|
final m = engine.media;
|
||||||
|
if (!identical(relay, r) || m?.relay == null) return;
|
||||||
|
if (m!.relay!.live == r.live) return;
|
||||||
|
engine.media = m.copyWith(relay: m.relay!.copyWith(live: r.live));
|
||||||
|
changed();
|
||||||
|
}
|
||||||
|
|
||||||
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
|
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
|
||||||
|
|
||||||
/// The member a reconnecting client may take over with its old token:
|
/// The member a reconnecting client may take over with its old token:
|
||||||
@@ -177,6 +201,7 @@ void mountSyncRoute(
|
|||||||
String Function(String sid, String token, SessionMedia? media, String host)
|
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, String host)? relayMediaUrlBuilder,
|
||||||
}) {
|
}) {
|
||||||
app.get('/sync', (final req) {
|
app.get('/sync', (final req) {
|
||||||
// Host the client dialled (Host header), so file URLs work for remote joiners.
|
// Host the client dialled (Host header), so file URLs work for remote joiners.
|
||||||
@@ -233,13 +258,17 @@ void mountSyncRoute(
|
|||||||
// By reference only: no URL (it could carry a token) is
|
// By reference only: no URL (it could carry a token) is
|
||||||
// stored or broadcast for Jellyfin media.
|
// stored or broadcast for Jellyfin media.
|
||||||
final ref = sanitizeJellyfinRef(parsed.jellyfin!);
|
final ref = sanitizeJellyfinRef(parsed.jellyfin!);
|
||||||
|
// Relayed for viewers without access: from the swapper's
|
||||||
|
// machine (its own sign-in never leaves it).
|
||||||
|
final relayReq = parsed.relay == null || parsed.relay!.fetch ? null : parsed.relay;
|
||||||
final media = ref == null
|
final media = ref == null
|
||||||
? null
|
? null
|
||||||
: HelloMedia(
|
: HelloMedia(
|
||||||
name: sanitizeMediaName(parsed.name.isEmpty ? ref.title : parsed.name),
|
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, relayReq));
|
||||||
if (media == null || !hub.engine.applySwap(token, media)) {
|
if (media == null || !hub.engine.applySwap(token, media)) {
|
||||||
wsErr(ws, 'bad swap media');
|
wsErr(ws, 'bad swap media');
|
||||||
return;
|
return;
|
||||||
@@ -250,6 +279,46 @@ 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));
|
||||||
|
hub.changed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final relayReq = parsed.relay;
|
||||||
|
if (relayReq != null) {
|
||||||
|
// Relayed file or link: no URL is stored or broadcast (a
|
||||||
|
// link may be private to the swapper; the server keeps a
|
||||||
|
// fetched one to itself).
|
||||||
|
final name = sanitizeMediaName(parsed.name.isEmpty ? 'media' : parsed.name);
|
||||||
|
final link = Uri.tryParse(parsed.url ?? '');
|
||||||
|
if (relayReq.fetch &&
|
||||||
|
(link == null || !(link.isScheme('http') || link.isScheme('https')) || link.host.isEmpty)) {
|
||||||
|
wsErr(ws, 'relay fetch needs an http(s) url');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The server would fetch any address it can reach (its own
|
||||||
|
// network, a VPN) for whoever asks: only for members who
|
||||||
|
// know the password.
|
||||||
|
if (relayReq.fetch && session.secret.isEmpty) {
|
||||||
|
wsErr(ws, 'server fetch needs a server password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final media = HelloMedia(
|
||||||
|
name: name,
|
||||||
|
size: parsed.size,
|
||||||
|
duration: parsed.duration,
|
||||||
|
relay: hub.relayFor(token, relayReq));
|
||||||
|
if (!hub.engine.applySwap(token, media)) {
|
||||||
|
wsErr(ws, 'bad swap media');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.media = SessionMedia(
|
||||||
|
name: name, size: parsed.size, duration: parsed.duration, mediaId: hub.engine.mediaId);
|
||||||
|
late final ActiveRelay active;
|
||||||
|
active = relayReq.fetch
|
||||||
|
? ActiveRelay.fetch(hub.engine.mediaId, name, link!,
|
||||||
|
onLiveChanged: () => hub.relayLiveChanged(active))
|
||||||
|
: _memberRelay(hub, token, name);
|
||||||
|
hub.setRelay(active);
|
||||||
hub.changed();
|
hub.changed();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -271,6 +340,7 @@ void mountSyncRoute(
|
|||||||
wsErr(ws, 'bad swap media');
|
wsErr(ws, 'bad swap media');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
hub.setRelay(null);
|
||||||
if (matchesFile) {
|
if (matchesFile) {
|
||||||
session.media = SessionMedia(
|
session.media = SessionMedia(
|
||||||
name: current.name,
|
name: current.name,
|
||||||
@@ -406,6 +476,7 @@ void mountSyncRoute(
|
|||||||
final activeToken = token;
|
final activeToken = token;
|
||||||
hub.sockets[activeToken] = ws;
|
hub.sockets[activeToken] = ws;
|
||||||
session.tokens[activeToken]!.onKick = (reason) async {
|
session.tokens[activeToken]!.onKick = (reason) async {
|
||||||
|
if (hub.relay?.sourceToken == activeToken) hub.relay!.dropChannel();
|
||||||
ws.trySendText(jsonEncode(KickedMessage(reason: reason).toJson()));
|
ws.trySendText(jsonEncode(KickedMessage(reason: reason).toJson()));
|
||||||
await Future.delayed(const Duration(milliseconds: 200));
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
await ws.tryClose();
|
await ws.tryClose();
|
||||||
@@ -413,10 +484,23 @@ void mountSyncRoute(
|
|||||||
};
|
};
|
||||||
final media = session.media;
|
final media = session.media;
|
||||||
final relay = hub.relayAvailable;
|
final relay = hub.relayAvailable;
|
||||||
|
final relayed = hub.relay;
|
||||||
final welcome = WelcomeMessage(
|
final welcome = WelcomeMessage(
|
||||||
token: activeToken,
|
token: activeToken,
|
||||||
memberId: memberId,
|
memberId: memberId,
|
||||||
mediaUrl: media?.file != null ? urlBuilder('default', activeToken, media, dialledHost) : media?.url,
|
// Jellyfin media resolves per member (own account first); only
|
||||||
|
// relayed files and links start from the /r/ URL, and never for
|
||||||
|
// the source itself (it plays its own copy).
|
||||||
|
mediaUrl: relayed != null &&
|
||||||
|
media != null &&
|
||||||
|
media.jellyfin == null &&
|
||||||
|
relayed.sourceToken != activeToken &&
|
||||||
|
relayed.mediaId == media.mediaId &&
|
||||||
|
relayMediaUrlBuilder != null
|
||||||
|
? relayMediaUrlBuilder('default', activeToken, relayed.mediaId, relayed.name, dialledHost)
|
||||||
|
: media?.file != null
|
||||||
|
? urlBuilder('default', activeToken, media, dialledHost)
|
||||||
|
: media?.url,
|
||||||
relayUrl: relay && relayUrlBuilder != null
|
relayUrl: relay && relayUrlBuilder != null
|
||||||
? relayUrlBuilder('default', activeToken, media!.jellyfin!.itemId, dialledHost)
|
? relayUrlBuilder('default', activeToken, media!.jellyfin!.itemId, dialledHost)
|
||||||
: null,
|
: null,
|
||||||
@@ -437,3 +521,9 @@ void mountSyncRoute(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ActiveRelay _memberRelay(ServerHub hub, String token, String name) {
|
||||||
|
late final ActiveRelay r;
|
||||||
|
r = ActiveRelay.fromMember(hub.engine.mediaId, name, token, onLiveChanged: () => hub.relayLiveChanged(r));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
|
|
||||||
import 'jellyfin_relay.dart';
|
import 'jellyfin_relay.dart';
|
||||||
import 'media_endpoint.dart';
|
import 'media_endpoint.dart';
|
||||||
|
import 'relay_routes.dart';
|
||||||
import 'server_hub.dart';
|
import 'server_hub.dart';
|
||||||
import 'session.dart';
|
import 'session.dart';
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ class SyncServer {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
mountSessionMediaRoute(_app, sessions);
|
mountSessionMediaRoute(_app, sessions);
|
||||||
|
mountRelayRoutes(_app, sessions, () => hub.relay);
|
||||||
mountJellyfinRelayRoute(
|
mountJellyfinRelayRoute(
|
||||||
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient());
|
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient());
|
||||||
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
|
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
|
||||||
@@ -72,7 +74,9 @@ class SyncServer {
|
|||||||
final name = Uri.encodeComponent(media?.name ?? 'file');
|
final name = Uri.encodeComponent(media?.name ?? 'file');
|
||||||
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, dialledHost) =>
|
||||||
|
'http://${authority(dialledHost)}/r/$sid/$token/$mediaId/${Uri.encodeComponent(name)}');
|
||||||
_app.fallback = (final req) => Response.notFound(
|
_app.fallback = (final req) => Response.notFound(
|
||||||
body: Body.fromString('not found'),
|
body: Body.fromString('not found'),
|
||||||
);
|
);
|
||||||
@@ -143,6 +147,7 @@ class SyncServer {
|
|||||||
_heartbeat = null;
|
_heartbeat = null;
|
||||||
_relayClient?.close(force: true);
|
_relayClient?.close(force: true);
|
||||||
_relayClient = null;
|
_relayClient = null;
|
||||||
|
hub.setRelay(null);
|
||||||
await hub.closeAllSockets();
|
await hub.closeAllSockets();
|
||||||
await _app.close();
|
await _app.close();
|
||||||
_server = null;
|
_server = null;
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:sync_relay_server/server.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
// The relay end to end inside one process: a real SyncServer, the real
|
||||||
|
// RelaySource from core, viewers as plain HTTP range requests.
|
||||||
|
|
||||||
|
class _Member {
|
||||||
|
final WebSocket ws;
|
||||||
|
final raw = <String>[];
|
||||||
|
late WelcomeMessage welcome;
|
||||||
|
_Member(this.ws);
|
||||||
|
|
||||||
|
static Future<_Member> join(int port, String name, {String secret = 'pw'}) async {
|
||||||
|
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
|
||||||
|
final m = _Member(ws);
|
||||||
|
final w = Completer<String>();
|
||||||
|
ws.listen((f) {
|
||||||
|
m.raw.add('$f');
|
||||||
|
if ('$f'.contains('"t":"welcome"') && !w.isCompleted) w.complete('$f');
|
||||||
|
}, onError: (_) {});
|
||||||
|
ws.add(jsonEncode(HelloMessage(
|
||||||
|
proto: RelayConstants.protoVersion,
|
||||||
|
secret: secret,
|
||||||
|
user: HelloUser(name: name),
|
||||||
|
media: const HelloMedia(name: '', size: 0, duration: 0),
|
||||||
|
).toJson()));
|
||||||
|
m.welcome = WelcomeMessage.fromJson(
|
||||||
|
Map<String, Object?>.from(jsonDecode(await w.future.timeout(const Duration(seconds: 5))) as Map));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
void send(Map<String, Object?> j) => ws.add(jsonEncode(j));
|
||||||
|
|
||||||
|
Map<String, Object?>? get lastState {
|
||||||
|
for (final f in raw.reversed) {
|
||||||
|
final d = jsonDecode(f) as Map;
|
||||||
|
if (d['t'] == 'state') return Map<String, Object?>.from(d);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?>? get media => lastState?['media'] as Map<String, Object?>?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _until(bool Function() cond, String what, {int secs = 5}) async {
|
||||||
|
final deadline = DateTime.now().add(Duration(seconds: secs));
|
||||||
|
while (!cond()) {
|
||||||
|
if (DateTime.now().isAfter(deadline)) fail('timed out waiting for $what');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Directory tmp;
|
||||||
|
late File file;
|
||||||
|
late Uint8List bytes;
|
||||||
|
late SyncServer server;
|
||||||
|
late HttpClient http;
|
||||||
|
final cleanup = <Future<void> Function()>[];
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
tmp = await Directory.systemTemp.createTemp('relay-test');
|
||||||
|
final rnd = Random(7);
|
||||||
|
// 5.5 slices: exercises the short last slice.
|
||||||
|
bytes = Uint8List.fromList(List.generate(5 * RelayBuffer.slice + 500000, (_) => rnd.nextInt(256)));
|
||||||
|
file = File('${tmp.path}/ep 01.mkv')..writeAsBytesSync(bytes);
|
||||||
|
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 't'));
|
||||||
|
await server.start();
|
||||||
|
http = HttpClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
for (final c in cleanup.reversed) {
|
||||||
|
await c();
|
||||||
|
}
|
||||||
|
cleanup.clear();
|
||||||
|
http.close(force: true);
|
||||||
|
await server.stop();
|
||||||
|
await tmp.delete(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<_Member> join(String name) async {
|
||||||
|
final m = await _Member.join(server.boundPort, name);
|
||||||
|
cleanup.add(() => m.ws.close());
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<RelaySource> startSource(_Member m, {RelayBytes? from}) async {
|
||||||
|
final src = RelaySource(
|
||||||
|
Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/${m.welcome.token}/source'),
|
||||||
|
from ?? FileRelayBytes(file),
|
||||||
|
backoff: (_) => const Duration(milliseconds: 100));
|
||||||
|
await src.start();
|
||||||
|
cleanup.add(src.close);
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> swapRelay(_Member m, {String name = 'ep 01.mkv', Map<String, Object?> relay = const {}, String? url}) async {
|
||||||
|
final before = m.raw.length;
|
||||||
|
m.send({
|
||||||
|
't': 'swap',
|
||||||
|
'media': {'name': name, 'size': bytes.length, 'duration': 0, if (url != null) 'url': url, 'relay': relay},
|
||||||
|
});
|
||||||
|
await _until(() => m.raw.skip(before).any((f) => f.contains('"relay"') && f.contains('"t":"state"')), 'relay state');
|
||||||
|
}
|
||||||
|
|
||||||
|
String viewerUrl(_Member v, int mediaId) =>
|
||||||
|
'http://127.0.0.1:${server.boundPort}/r/default/${v.welcome.token}/$mediaId/${Uri.encodeComponent('ep 01.mkv')}';
|
||||||
|
|
||||||
|
Future<(int, Map<String, String>, List<int>)> get(String url, {String? range}) async {
|
||||||
|
final req = await http.getUrl(Uri.parse(url));
|
||||||
|
if (range != null) req.headers.set('range', range);
|
||||||
|
final res = await req.close();
|
||||||
|
final body = <int>[for (final c in await res.toList()) ...c];
|
||||||
|
final h = <String, String>{};
|
||||||
|
res.headers.forEach((k, v) => h[k] = v.join(','));
|
||||||
|
return (res.statusCode, h, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
int mediaIdOf(_Member m) => m.lastState!['mediaId'] as int;
|
||||||
|
|
||||||
|
group('member as source', () {
|
||||||
|
test('state names the source, no URL; live once the channel is up', () async {
|
||||||
|
final alice = await join('alice');
|
||||||
|
await swapRelay(alice);
|
||||||
|
expect(alice.media!['relay'], {'source': alice.welcome.memberId});
|
||||||
|
expect(alice.media!.containsKey('url'), isFalse);
|
||||||
|
await startSource(alice);
|
||||||
|
await _until(() => (alice.media?['relay'] as Map?)?['live'] == true, 'live');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewers get byte-exact ranges, including the short last slice and 416', () async {
|
||||||
|
final alice = await join('alice');
|
||||||
|
final bob = await join('bob');
|
||||||
|
await swapRelay(alice);
|
||||||
|
await startSource(alice);
|
||||||
|
final url = viewerUrl(bob, mediaIdOf(alice));
|
||||||
|
final n = bytes.length;
|
||||||
|
for (final (range, start, end) in [
|
||||||
|
(null, 0, n - 1),
|
||||||
|
('bytes=0-99', 0, 99),
|
||||||
|
('bytes=${RelayBuffer.slice - 10}-${RelayBuffer.slice + 10}', RelayBuffer.slice - 10, RelayBuffer.slice + 10),
|
||||||
|
('bytes=${n - 777}-', n - 777, n - 1),
|
||||||
|
('bytes=-333', n - 333, n - 1),
|
||||||
|
]) {
|
||||||
|
final (status, h, body) = await get(url, range: range);
|
||||||
|
expect(status, range == null ? 200 : 206, reason: '$range');
|
||||||
|
expect(body, bytes.sublist(start, end + 1), reason: '$range');
|
||||||
|
if (range != null) expect(h['content-range'], 'bytes $start-$end/$n');
|
||||||
|
expect(h['content-type'], 'video/x-matroska');
|
||||||
|
}
|
||||||
|
final (status, h, _) = await get(url, range: 'bytes=${n + 5}-');
|
||||||
|
expect(status, 416);
|
||||||
|
expect(h['content-range'], 'bytes */$n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a late joiner gets its own /r/ URL in welcome', () async {
|
||||||
|
final alice = await join('alice');
|
||||||
|
await swapRelay(alice);
|
||||||
|
final carol = await join('carol');
|
||||||
|
expect(carol.welcome.mediaUrl, viewerUrl(carol, mediaIdOf(alice)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nobody watching: the source uploads nothing', () async {
|
||||||
|
final alice = await join('alice');
|
||||||
|
await swapRelay(alice);
|
||||||
|
final src = await startSource(alice);
|
||||||
|
await _until(() => server.hub.relay!.live, 'live');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 600));
|
||||||
|
expect(src.bytesSent, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('several viewers: each byte crosses the source link about once', () async {
|
||||||
|
final alice = await join('alice');
|
||||||
|
final viewers = [for (final n in ['b', 'c', 'd']) await join(n)];
|
||||||
|
await swapRelay(alice);
|
||||||
|
final src = await startSource(alice);
|
||||||
|
final id = mediaIdOf(alice);
|
||||||
|
final got = await Future.wait([for (final v in viewers) get(viewerUrl(v, id))]);
|
||||||
|
for (final (status, _, body) in got) {
|
||||||
|
expect(status, 200);
|
||||||
|
expect(body.length, bytes.length);
|
||||||
|
}
|
||||||
|
expect(src.bytesSent, lessThan(bytes.length * 1.3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only the source may open the channel', () async {
|
||||||
|
final alice = await join('alice');
|
||||||
|
final bob = await join('bob');
|
||||||
|
await swapRelay(alice);
|
||||||
|
final impostor = await startSource(bob);
|
||||||
|
await impostor.done.timeout(const Duration(seconds: 5));
|
||||||
|
expect(server.hub.relay!.live, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a new swap drops the buffer, old URLs 404, the source stops', () async {
|
||||||
|
final alice = await join('alice');
|
||||||
|
final bob = await join('bob');
|
||||||
|
await swapRelay(alice);
|
||||||
|
final src = await startSource(alice);
|
||||||
|
final oldId = mediaIdOf(alice);
|
||||||
|
expect((await get(viewerUrl(bob, oldId), range: 'bytes=0-9')).$1, 206);
|
||||||
|
final buf = server.hub.relay!.buffer;
|
||||||
|
alice.send({
|
||||||
|
't': 'swap',
|
||||||
|
'media': {'name': 'x', 'size': 0, 'duration': 0, 'url': 'http://example.invalid/x.mkv'},
|
||||||
|
});
|
||||||
|
await _until(() => mediaIdOf(alice) != oldId, 'swap');
|
||||||
|
expect(server.hub.relay, isNull);
|
||||||
|
expect(buf.cachedBytes, 0);
|
||||||
|
await src.done.timeout(const Duration(seconds: 5));
|
||||||
|
expect((await get(viewerUrl(bob, oldId), range: 'bytes=0-9')).$1, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('server stop drops the relay', () async {
|
||||||
|
final alice = await join('alice');
|
||||||
|
await swapRelay(alice);
|
||||||
|
final buf = server.hub.relay!.buffer;
|
||||||
|
await server.stop();
|
||||||
|
expect(server.hub.relay, isNull);
|
||||||
|
expect(buf.cachedBytes, 0);
|
||||||
|
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 't'));
|
||||||
|
await server.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('source drops and comes back: buffered bytes keep serving, the rest resumes', () async {
|
||||||
|
final alice = await join('alice');
|
||||||
|
final bob = await join('bob');
|
||||||
|
await swapRelay(alice);
|
||||||
|
final src = await startSource(alice);
|
||||||
|
final url = viewerUrl(bob, mediaIdOf(alice));
|
||||||
|
expect((await get(url, range: 'bytes=0-${RelayBuffer.slice * 2 - 1}')).$3,
|
||||||
|
bytes.sublist(0, RelayBuffer.slice * 2));
|
||||||
|
await src.close();
|
||||||
|
await src.done;
|
||||||
|
await _until(() => !server.hub.relay!.live, 'down');
|
||||||
|
expect((await get(url, range: 'bytes=0-99')).$3, bytes.sublist(0, 100));
|
||||||
|
final pending = get(url, range: 'bytes=${RelayBuffer.slice * 4}-');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||||
|
await startSource(alice);
|
||||||
|
final (status, _, body) = await pending.timeout(const Duration(seconds: 10));
|
||||||
|
expect(status, 206);
|
||||||
|
expect(body, bytes.sublist(RelayBuffer.slice * 4));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Jellyfin swap can carry a relay', () async {
|
||||||
|
final alice = await join('alice');
|
||||||
|
final before = alice.raw.length;
|
||||||
|
alice.send({
|
||||||
|
't': 'swap',
|
||||||
|
'media': {
|
||||||
|
'name': 'Pilot',
|
||||||
|
'size': 0,
|
||||||
|
'duration': 0,
|
||||||
|
'jellyfin': {'serverId': 'srv1', 'itemId': '0123456789abcdef0123456789abcdef', 'title': 'Pilot'},
|
||||||
|
'relay': {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await _until(() => alice.raw.skip(before).any((f) => f.contains('"jellyfin"')), 'state');
|
||||||
|
expect(alice.media!['relay'], {'source': alice.welcome.memberId});
|
||||||
|
expect(server.hub.relay!.sourceToken, alice.welcome.token);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('server fetches the link', () {
|
||||||
|
late HttpServer origin;
|
||||||
|
setUp(() async {
|
||||||
|
origin = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
origin.listen((req) async {
|
||||||
|
final r = resolveMediaRequest(
|
||||||
|
rangeHeader: req.headers.value('range'), size: bytes.length, filename: 'x.mkv');
|
||||||
|
req.response.statusCode = r.status;
|
||||||
|
r.headers.forEach(req.response.headers.set);
|
||||||
|
if (r.status == 200 || r.status == 206) {
|
||||||
|
final end = r.end ?? bytes.length - 1;
|
||||||
|
req.response.add(bytes.sublist(r.start, end + 1));
|
||||||
|
}
|
||||||
|
await req.response.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
tearDown(() => origin.close(force: true));
|
||||||
|
|
||||||
|
test('byte-exact, the link is never broadcast', () async {
|
||||||
|
final alice = await join('alice');
|
||||||
|
final link = 'http://127.0.0.1:${origin.port}/secret/file.mkv?api_key=KEY';
|
||||||
|
await swapRelay(alice, relay: {'fetch': true}, url: link);
|
||||||
|
expect(jsonEncode(alice.lastState), isNot(contains('KEY')));
|
||||||
|
await _until(() => server.hub.relay!.live, 'live');
|
||||||
|
final bob = await join('bob');
|
||||||
|
expect(jsonEncode(bob.welcome.toJson()), isNot(contains('KEY')));
|
||||||
|
final (status, _, body) = await get(bob.welcome.mediaUrl!, range: 'bytes=1000-');
|
||||||
|
expect(status, 206);
|
||||||
|
expect(body, bytes.sublist(1000));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an open server (no password) refuses to fetch', () async {
|
||||||
|
await server.stop();
|
||||||
|
server = SyncServer(const AppConfig(port: 0, secret: '', name: 'open'));
|
||||||
|
await server.start();
|
||||||
|
final m = await _Member.join(server.boundPort, 'mallory', secret: '');
|
||||||
|
cleanup.add(() => m.ws.close());
|
||||||
|
m.send({
|
||||||
|
't': 'swap',
|
||||||
|
'media': {
|
||||||
|
'name': 'x', 'size': 0, 'duration': 0,
|
||||||
|
'url': 'http://127.0.0.1:${origin.port}/x.mkv', 'relay': {'fetch': true},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await _until(() => m.raw.any((f) => f.contains('server fetch needs a server password')), 'err');
|
||||||
|
expect(server.hub.relay, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a non-http link is refused', () async {
|
||||||
|
final alice = await join('alice');
|
||||||
|
alice.send({
|
||||||
|
't': 'swap',
|
||||||
|
'media': {'name': 'x', 'size': 0, 'duration': 0, 'url': 'file:///etc/passwd', 'relay': {'fetch': true}},
|
||||||
|
});
|
||||||
|
await _until(() => alice.raw.any((f) => f.contains('relay fetch needs')), 'err');
|
||||||
|
expect(server.hub.relay, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('buffer', () {
|
||||||
|
// A feed that serves [bytes] in-process and counts what it sends.
|
||||||
|
late RelayBuffer buf;
|
||||||
|
late LocalRelayFeed feed;
|
||||||
|
late _CountingBytes counted;
|
||||||
|
setUp(() {
|
||||||
|
buf = RelayBuffer(ahead: 2 * RelayBuffer.slice, back: RelayBuffer.slice, pin: RelayBuffer.slice);
|
||||||
|
counted = _CountingBytes(FileRelayBytes(file));
|
||||||
|
feed = LocalRelayFeed(counted, buf);
|
||||||
|
buf.attach(feed, bytes.length);
|
||||||
|
});
|
||||||
|
tearDown(() async {
|
||||||
|
buf.dispose();
|
||||||
|
await feed.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<List<int>> read(String v, int start, int end) async =>
|
||||||
|
[for (final c in await buf.read(v, start, end).toList()) ...c];
|
||||||
|
|
||||||
|
test('keeps pinned head/tail plus a window around the viewer', () async {
|
||||||
|
expect(await read('a', 0, bytes.length - 1), bytes);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
// ahead 2 + back 1 + 2 pinned slices: never all 6.
|
||||||
|
expect(buf.cachedBytes, lessThanOrEqualTo(5 * RelayBuffer.slice));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a superseded request does not hold the feed', () async {
|
||||||
|
// Like mpv after a seek: the old request stays open, unread.
|
||||||
|
final old = buf.read('a', 0, bytes.length - 1).listen((_) {})..pause();
|
||||||
|
final s = 3 * RelayBuffer.slice;
|
||||||
|
expect(await read('a', s, s + 999).timeout(const Duration(seconds: 5)), bytes.sublist(s, s + 1000));
|
||||||
|
await old.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispose frees everything and ends waiting readers', () async {
|
||||||
|
await read('a', 0, 10);
|
||||||
|
buf.dispose();
|
||||||
|
expect(buf.cachedBytes, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CountingBytes implements RelayBytes {
|
||||||
|
final RelayBytes inner;
|
||||||
|
int sent = 0;
|
||||||
|
_CountingBytes(this.inner);
|
||||||
|
@override
|
||||||
|
Future<int> size() => inner.size();
|
||||||
|
@override
|
||||||
|
Stream<List<int>> read(int start, int end) => inner.read(start, end).map((c) {
|
||||||
|
sent += c.length;
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Future<void> close() => inner.close();
|
||||||
|
}
|
||||||
@@ -80,8 +80,14 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('core welcome + proto', () {
|
group('core welcome + proto', () {
|
||||||
test('proto 7: Jellyfin refs and relay', () {
|
test('proto 8: relayed media', () {
|
||||||
expect(RelayConstants.protoVersion, 7);
|
expect(RelayConstants.protoVersion, 8);
|
||||||
|
const m = HelloMedia(name: 'a', size: 1, duration: 0, relay: MediaRelay(source: 'm1', live: true));
|
||||||
|
final back = HelloMedia.fromJson(Map<String, Object?>.from(m.toJson()));
|
||||||
|
expect(back.relay!.source, 'm1');
|
||||||
|
expect(back.relay!.live, isTrue);
|
||||||
|
expect(back.relay!.fetch, isFalse);
|
||||||
|
expect(const MediaRelay().toJson(), isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('welcome round-trips with token + member id', () {
|
test('welcome round-trips with token + member id', () {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Relay harness: a real SyncServer, a member that swaps a file in as
|
||||||
|
// relayed media, and that member's RelaySource dialling the server through
|
||||||
|
// [--via] (e.g. a link shaper). Prints `URL <viewer url>` for players, and
|
||||||
|
// `STATS ...` every 5 s. Usage:
|
||||||
|
// dart run tool/relay_e2e.dart --file x.mkv [--port 9100] [--via 9200]
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:args/args.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:sync_relay_server/server.dart';
|
||||||
|
|
||||||
|
Future<void> main(List<String> argv) async {
|
||||||
|
final a = (ArgParser()
|
||||||
|
..addOption('file', mandatory: true)
|
||||||
|
..addOption('port', defaultsTo: '9100')
|
||||||
|
..addOption('via')
|
||||||
|
..addOption('viewers', defaultsTo: '1'))
|
||||||
|
.parse(argv);
|
||||||
|
final file = File(a['file'] as String);
|
||||||
|
final port = int.parse(a['port'] as String);
|
||||||
|
final server = SyncServer(AppConfig(port: port, secret: '', name: 'e2e'));
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
Future<(WebSocket, WelcomeMessage)> join(String name) async {
|
||||||
|
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
|
||||||
|
final welcome = Completer<WelcomeMessage>();
|
||||||
|
ws.listen((f) {
|
||||||
|
final j = jsonDecode('$f') as Map;
|
||||||
|
if (j['t'] == 'welcome' && !welcome.isCompleted) {
|
||||||
|
welcome.complete(WelcomeMessage.fromJson(Map<String, Object?>.from(j)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ws.add(jsonEncode(HelloMessage(
|
||||||
|
proto: RelayConstants.protoVersion,
|
||||||
|
secret: '',
|
||||||
|
user: HelloUser(name: name),
|
||||||
|
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||||
|
.toJson()));
|
||||||
|
// Stay alive like a real client (silent members get dropped).
|
||||||
|
Timer.periodic(const Duration(seconds: 1), (_) => ws.add(jsonEncode({'t': 'report', 'position': 0, 'buffered': 0, 'eventId': 0})));
|
||||||
|
return (ws, await welcome.future);
|
||||||
|
}
|
||||||
|
|
||||||
|
final (srcWs, srcW) = await join('source');
|
||||||
|
final name = file.uri.pathSegments.last;
|
||||||
|
srcWs.add(jsonEncode(SwapIntent(
|
||||||
|
media: HelloMedia(name: name, size: await file.length(), duration: 0, relay: const MediaRelay()))
|
||||||
|
.toJson()));
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||||
|
final via = a['via'] as String? ?? '$port';
|
||||||
|
var source = RelaySource(Uri.parse('ws://127.0.0.1:$via/r/default/${srcW.token}/source'), FileRelayBytes(file));
|
||||||
|
await source.start();
|
||||||
|
final urls = <String>[];
|
||||||
|
for (var i = 0; i < int.parse(a['viewers'] as String); i++) {
|
||||||
|
urls.add((await join('viewer$i')).$2.mediaUrl!);
|
||||||
|
}
|
||||||
|
stdout.writeln('URL ${urls.join(',')}');
|
||||||
|
Timer.periodic(const Duration(seconds: 5), (_) {
|
||||||
|
final b = server.hub.relay?.buffer;
|
||||||
|
stdout.writeln('STATS sourceSent=${source.bytesSent} toViewers=${b?.bytesToViewers} '
|
||||||
|
'cached=${b?.cachedBytes} window=${source.window.window}');
|
||||||
|
});
|
||||||
|
// SIGUSR1: drop the source; SIGUSR2: bring it back (outage tests).
|
||||||
|
ProcessSignal.sigusr1.watch().listen((_) => source.close());
|
||||||
|
ProcessSignal.sigusr2.watch().listen((_) async {
|
||||||
|
source = RelaySource(Uri.parse('ws://127.0.0.1:$via/r/default/${srcW.token}/source'), FileRelayBytes(file));
|
||||||
|
await source.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user