- PlayerHost port (MediaKitHost): players by role, pins, hwdec policy, render probe, log routing, disposal order; the only media_kit user. - MediaPipeline: plan (one pure switch over state, origin, caps, presence) -> resolve -> open (prepared player promoted, else fresh) -> ready; commands queued during a switch apply once (P15); viewers play the buffer and wait for a dropped source without seek loops (D6). - Every web page resolved by our yt-dlp into an EDL with headers and cookies (danmaku excluded); mpv's ytdl_hook no longer used (ytdl=no). - D11: sign-in errors say which cookies were used and whether they hold any for the site. - Relay: 256 KiB slices read as they fill, superseded pulls released (seek-to-play ~4.2 s -> ~2.8 s, P29); own Jellyfin relay channel only when someone needs it (P28). - STARSYNC_SMOKE run and a Windows CI smoke step after the build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
107 lines
4.9 KiB
Dart
107 lines
4.9 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:sync_relay_core/core.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/session_coordinator.dart';
|
|
|
|
// Commands that come while a media is switching (no player yet, or its
|
|
// media not loaded) wait and go to the new player once, so mpv never gets a
|
|
// seek it cannot run ("error running command seek", "event:5").
|
|
|
|
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
|
|
|
Future<String> _media() async {
|
|
final path = '$_tmp/media_switch_60s.mp4';
|
|
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', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24', '-pix_fmt', 'yuv420p',
|
|
'-c:a', 'aac', '-movflags', '+faststart', 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), Duration every = const Duration(milliseconds: 100)}) async {
|
|
final deadline = DateTime.now().add(timeout);
|
|
while (DateTime.now().isBefore(deadline)) {
|
|
if (await cond()) return;
|
|
await Future.delayed(every);
|
|
}
|
|
fail('timed out waiting for $what');
|
|
}
|
|
|
|
void main() {
|
|
test('commands during a switch wait and apply once to the new player, without mpv errors',
|
|
timeout: const Timeout(Duration(minutes: 2)), () async {
|
|
final file = await _media();
|
|
// A host that answers a media request only after 2 s (a slow CDN):
|
|
// the new player has nothing loaded for that long.
|
|
final slow = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
slow.listen((req) async {
|
|
final size = await File(file).length();
|
|
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mp4');
|
|
if (r.status == 206 || r.status == 200) await Future.delayed(const Duration(seconds: 2));
|
|
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 (_) {}
|
|
});
|
|
final server = SyncServer(const AppConfig(port: 0, secret: 'p15', name: 'p15'));
|
|
await server.start();
|
|
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
|
await acc.ensureLoaded(validate: false);
|
|
final c = SessionCoordinator(
|
|
jellyfin: acc,
|
|
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
|
|
ytDlp: AppYtDlp(YtDlp('$_tmp/media_switch_no_ytdlp', enabled: false)));
|
|
final errors = <String>[];
|
|
try {
|
|
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'p15', name: 'solo'));
|
|
await c.playback.setVolume(0);
|
|
final before = c.serverState.peek()?.mediaId ?? 0;
|
|
expect(await c.swapPicked(LinkMedia('http://127.0.0.1:${slow.port}/clip.mp4')), isNull);
|
|
// The switch: the state names the new media, the new player waits on
|
|
// the slow host.
|
|
await _poll(() => (c.serverState.peek()?.mediaId ?? 0) > before && c.media.openUrl != null, 'switching');
|
|
final p = c.playback.player;
|
|
final sub = p.logs.listen((l) {
|
|
if (l.text.contains('error running command') || l.text.contains('event:5')) errors.add(l.text);
|
|
});
|
|
expect(c.playback.switching, isTrue, reason: 'its media is not loaded yet');
|
|
final seeks = c.playback.seeksSent;
|
|
// The user skips ahead twice, changes the volume and readies, all
|
|
// while the media is still on its way.
|
|
c.seekTo(12);
|
|
c.seekTo(20);
|
|
await c.playback.setVolume(30);
|
|
c.toggleReady();
|
|
expect(c.playback.seeksSent, seeks, reason: 'nothing reached mpv yet');
|
|
await _poll(() => !c.playback.switching, 'loaded', timeout: const Duration(seconds: 20));
|
|
await _poll(() => c.playback.playing && c.playback.positionSecs > 20, 'playing from 20 s');
|
|
expect(c.playback.positionSecs, lessThan(23), reason: 'the latest seek, applied once it loaded');
|
|
expect(c.playback.seeksSent - seeks, 1, reason: 'the queued seeks went out as one');
|
|
expect(c.playback.player.state.volume, 30, reason: 'the volume went to the new player');
|
|
await sub.cancel();
|
|
expect(errors, isEmpty, reason: 'no command reached a player without its media');
|
|
} finally {
|
|
await c.dispose();
|
|
await server.stop();
|
|
await slow.close(force: true);
|
|
}
|
|
});
|
|
}
|