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
@@ -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/track_prefs.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';
|
||||
|
||||
// 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 _poll(() => b.jellyfinBlocked.peek() != null, 'blocked');
|
||||
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.
|
||||
await accB.link(
|
||||
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();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user