import 'dart:async'; import 'dart:convert'; 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_core/testing.dart'; import 'package:sync_relay_server/server.dart'; import 'package:starsync/src/jellyfin/accounts.dart'; import 'package:starsync/src/jellyfin/track_prefs.dart'; import 'package:starsync/src/playback/yt_dlp_app.dart'; import 'package:starsync/src/screens/home/picked_media.dart'; import 'package:starsync/src/session/connection.dart' show ConnOnline; import 'package:starsync/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 _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 _poll(FutureOr 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)> _origin(String file) async { final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final requests = []; 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 all; late List serverErrors; Future member(String name) async { final acc = JellyfinAccounts(store: MemoryJellyfinStore()); await acc.ensureLoaded(validate: false); final c = SessionCoordinator( jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: AppYtDlp(YtDlp('$_tmp/relay_e2e_no_ytdlp', enabled: false))); 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 playTogether(List 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')); // Relic reports an error writing a response (a relay read cut short // when its relay is dropped at a swap or at the stop in tearDown) to // the zone the server started in, as it logs it outside tests; // anything else fails the test. serverErrors = []; await runZonedGuarded(server.start, (e, _) { if ('$e'.contains('Content size below specified contentLength') || '$e'.contains('did not arrive')) return; serverErrors.add(e); }); }); tearDown(() async { for (final x in all) { await x.dispose(); } await server.stop(); expect(serverErrors, isEmpty, reason: 'server errors'); }); 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.connection.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.buffers.single.bytesToViewers, greaterThan(0), reason: 'alice played through the relay'); expect(relay.sourceToken, bob.connection.token); // The next swap retires bob's source and the buffer. Nothing answers // at that link, so its probe refuses it unless put up anyway. expect(await alice.swapPicked(const LinkMedia('http://127.0.0.1:1/none.mkv', anyway: true)), isNull); 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.connection.memberId, 'me state'); expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET'))); await playTogether([alice, bob]); expect(server.hub.relay!.buffers.single.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 is refused up front when this computer cannot read it (here // yt-dlp is off, and the refusal says so). final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); page.listen((r) { r.response.headers.contentType = ContentType.html; r.response.write(''); r.response.close(); }); expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)), 'yt-dlp is turned off'); await page.close(force: true); } finally { await origin.close(force: true); } }); test('a source that dials in after mpv would have given up (5 s): viewers wait for it, then play', timeout: const Timeout(Duration(minutes: 2)), () async { final file = await _media(); final alice = await member('alice'); // Bob is a bare client: he puts a file up and starts passing it on // only 7 s later (a slow first upstream read, a slow own player). final ws = await WebSocket.connect('ws://127.0.0.1:${server.boundPort}/sync'); final welcome = Completer(); ws.listen((f) { final j = jsonDecode('$f') as Map; if (j['t'] == 'welcome' && !welcome.isCompleted) welcome.complete(j); }, onError: (_) {}); ws.add(jsonEncode(const HelloMessage(secret: 'pw', user: HelloUser(name: 'bob')).toJson())); final token = (await welcome.future.timeout(const Duration(seconds: 5)))['token'] as String; final size = await File(file).length(); ws.add(jsonEncode({ 't': 'swap', 'media': {'name': 'late.mkv', 'size': size, 'duration': 0, 'relay': {}}, })); await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state'); await Future.delayed(const Duration(seconds: 7)); expect(alice.playerError.peek(), isEmpty, reason: 'no "could not be opened" while the source is on its way'); expect(alice.health.peek().message, contains('waiting for bob')); final src = RelaySource(Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/$token/source'), RelayMedia.single(FileRelayBytes(File(file)))); await src.start(); try { await _poll(() => alice.playback.durationSecs > 19, 'alice opened it once the source was up'); expect(alice.playerError.peek(), isEmpty); } finally { await src.close(); await ws.close(); } }); test('the source drops uncleanly and comes back: the others play on, the relay continues (D6)', timeout: const Timeout(Duration(minutes: 2)), () async { final file = await _media(); final proxy = await FlakyProxy.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.connection.token; final opens = bob.opens; // Bob's network goes silent (no FIN) on every link he has, the sync // socket and his relay channel, and new ones too, long enough for // the server to call him gone: the relay stays (`sourceGone`). proxy.onConnect = (l) => l.blackhole(); for (final l in proxy.links) { l.blackhole(); } await _poll(() => bob.reconnecting.peek(), 'reconnect engaged', timeout: const Duration(seconds: 15)); await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.sourceGone ?? false, 'source gone for alice', timeout: const Duration(seconds: 25)); expect(alice.serverState.peek()!.media, isNotNull); expect(server.hub.relay, isNotNull, reason: 'the relay is kept through the grace'); // His link comes back: the same member, his source dials its channel // again with the same `info` and the relay continues. proxy.onConnect = null; await _poll(() => bob.connection.state.peek() is ConnOnline, 'back', timeout: const Duration(seconds: 30)); expect(bob.connection.token, token, reason: 'resumed as the same member'); expect(bob.serverState.peek(), isNotNull, reason: 'the state stayed through the drop'); await _poll(() => server.hub.relay?.live == true && !(stateMediaRelay(alice.serverState.peek()!)?.sourceGone ?? true), 'source redialled', timeout: const Duration(seconds: 25)); expect(bob.relaying.peek(), isTrue); expect(bob.opens, opens, reason: 'still his own copy, not reopened'); // 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(); } }); }