Restructure step 5: one media pipeline on a player host

- 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>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-16 01:08:54 +03:00
1 parent 8970f2694b
commit 25b19f2d6a
47 files changed
+4083 -1728

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/session/media_plan.dart';
import 'package:starsync/src/session/relay_feeder.dart';
import 'package:starsync/src/session/status.dart';
// The plan step of the media pipeline as a table: the state, what this
// member serves and its capabilities -> where this app reads the media
// from.
const _server = ServerAddress('h', 9, 'TOK');
const _jf = JellyfinRef(serverId: 'S1', itemId: 'I1', title: 'Ep');
MemberView _m(String id, {Presence presence = Presence.watching, Map<String, bool> jf = const {}, ClientCaps caps = const ClientCaps()}) =>
MemberView(id: id, name: id.toUpperCase(), ready: false, inSet: true, buffered: 0, presence: presence, jf: jf, caps: caps);
StateBroadcast _state(Map<String, Object?>? media,
{List<MemberView>? members, bool relay = false, HoldInfo? hold, String transport = 'held'}) =>
StateBroadcast(
eventId: 1,
transport: transport,
position: 0,
serverTime: DateTime.utc(2026),
mediaId: 7,
media: media,
members: members ?? [_m('m1'), _m('m2')],
relay: relay,
hold: hold,
);
Map<String, Object?> _relay(MediaRelay r) => r.toJson();
SourcePlan _plan(StateBroadcast r,
{String self = 'm1', MediaOrigin? origin, bool resolve = true, Set<String> accounts = const {}}) =>
planMedia(PlanInput(
state: r,
selfId: self,
caps: ClientCaps(source: true, resolve: resolve),
server: _server,
origin: origin,
ownJellyfin: accounts.contains,
));
void main() {
group('plan: state + origin + caps -> source', () {
final probe = LinkProbe.file(Uri.parse('https://x/v.mkv'), 10);
final ytDlp = YtDlp('${Directory.systemTemp.path}/plan_no_ytdlp', enabled: false);
final rows = <(String, StateBroadcast, Map<String, Object?>, String)>[
('nothing up', _state(null), {}, 'NoSource'),
('your server\'s file', _state({'name': 'Ep 1.mkv', 'size': 5}), {}, 'ServerFile http://h:9/m/default/TOK/Ep%201.mkv'),
('a media file link', _state({'name': 'v', 'url': 'https://cdn/v.mkv'}), {}, 'DirectLink https://cdn/v.mkv page=false'),
('a page link', _state({'name': 'v', 'url': 'https://site/watch?v=1'}), {}, 'DirectLink https://site/watch?v=1 page=true'),
('a page link, no resolver here, m2 can',
_state({'name': 'v', 'url': 'https://site/watch?v=1'},
members: [_m('m1'), _m('m2', caps: const ClientCaps(resolve: true))]),
{'resolve': false},
"Unresolvable This device can't open web pages; ask M2 to pass it on (Through me)"),
('another member\'s relay, live',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm2', live: true))}), {},
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=false'),
('another member\'s relay, not live yet: wait',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm2'))}), {},
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=true'),
('its source left: open what the server has',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm9'))}), {},
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=false'),
('its source gone: open what the server has',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm2', sourceGone: true))}), {},
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=false'),
('the server fetches, not read yet',
_state({'name': 'p', 'relay': _relay(const MediaRelay(fetch: true))}), {},
'RelayedSource http://h:9/r/default/TOK/7/p waiting=true'),
('a warm relay promoted: its queue id in the URL',
_state({'name': 'p', 'relay': _relay(const MediaRelay(source: 'm2', live: true, queueId: 'q3'))}), {},
'RelayedSource http://h:9/r/default/TOK/q3/p waiting=false'),
('your own file, passed on',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm1'))}), {'origin': const FileOrigin('/v/f.mkv')},
'OwnFile /v/f.mkv'),
('your own link, passed on',
_state({'name': 'p', 'relay': _relay(const MediaRelay(source: 'm1', live: true))}),
{'origin': LinkOrigin('https://site/p', probe, ytDlp)},
'OwnLink https://site/p'),
('your relay, origin forgotten (a restart): read the server\'s buffer',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm1', live: true))}), {},
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=false'),
('Jellyfin, own account',
_state({'name': 'Ep', 'jellyfin': _jf.toJson()}), {'accounts': {'S1'}},
'JellyfinSource own=true login=null member=null origin=false'),
('Jellyfin, the server\'s login',
_state({'name': 'Ep', 'jellyfin': _jf.toJson()}, relay: true), {},
'JellyfinSource own=false login=http://h:9/j/default/TOK/I1 member=null origin=false'),
('Jellyfin, a member passes it on',
_state({'name': 'Ep', 'jellyfin': _jf.toJson(), 'relay': _relay(const MediaRelay(source: 'm2'))}), {},
'JellyfinSource own=false login=null member=http://h:9/r/default/TOK/7/Ep origin=false'),
('Jellyfin, you put it up',
_state({'name': 'Ep', 'jellyfin': _jf.toJson(), 'relay': _relay(const MediaRelay(source: 'm1'))}),
{'accounts': {'S1'}, 'origin': const JellyfinOrigin(null)},
'JellyfinSource own=true login=null member=null origin=true'),
];
for (final (what, r, opts, want) in rows) {
test(what, () {
final got = _plan(r,
origin: opts['origin'] as MediaOrigin?,
resolve: opts['resolve'] as bool? ?? true,
accounts: opts['accounts'] as Set<String>? ?? const {});
expect(_describe(got), want);
});
}
});
test('a Jellyfin relay channel only when someone present needs it', () {
StateBroadcast with_(List<MemberView> ms, {bool relay = false}) =>
_state({'name': 'Ep', 'jellyfin': _jf.toJson()}, members: [_m('m1'), ...ms], relay: relay);
expect(jellyfinRelayNeeded(with_([_m('m2', jf: {'S1': true})]), _jf, 'm1'), isFalse, reason: 'everyone has an account');
expect(jellyfinRelayNeeded(with_([_m('m2', jf: {'S1': false})]), _jf, 'm1'), isTrue);
expect(jellyfinRelayNeeded(with_([_m('m2')]), _jf, 'm1'), isTrue, reason: 'no Jellyfin at all');
expect(jellyfinRelayNeeded(with_([_m('m2', caps: const ClientCaps(jellyfin: true))]), _jf, 'm1'), isFalse,
reason: 'its accounts are not reported yet: asked again with the next state');
expect(jellyfinRelayNeeded(with_([_m('m2')], relay: true), _jf, 'm1'), isFalse, reason: "the server's login passes it on");
expect(jellyfinRelayNeeded(with_([_m('m2', presence: Presence.away)]), _jf, 'm1'), isFalse, reason: 'sitting out');
expect(jellyfinRelayNeeded(with_([]), _jf, 'm1'), isFalse, reason: 'alone');
});
test("D6: held for the source: everyone's status waits for it by name", () {
final r = _state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm2', live: true, sourceGone: true))},
members: [
_m('m1'),
MemberView(id: 'm2', name: 'Bob', ready: true, inSet: true, buffered: 0, presence: Presence.gone, goneSince: DateTime.utc(2026)),
],
hold: const HoldInfo(reason: HoldReason.source, members: ['m1']));
for (final own in [false, true]) {
final line = statusOf(StatusFacts(state: r, selfId: 'm1', ownPlan: own, now: DateTime.utc(2026))).line!;
expect(line.text, 'Waiting for Bob');
expect(line.kind, StatusKind.waiting);
expect(line.level, StatusLevel.warn);
}
});
}
String _describe(SourcePlan p) => switch (p) {
NoSource() => 'NoSource',
ServerFile(:final url) => 'ServerFile $url',
DirectLink(:final url, :final page) => 'DirectLink $url page=$page',
Unresolvable() => 'Unresolvable ${p.reason}',
RelayedSource(:final url, :final waiting) => 'RelayedSource $url waiting=$waiting',
OwnFile(:final path) => 'OwnFile $path',
OwnLink(:final url) => 'OwnLink $url',
JellyfinSource(:final ownAccount, :final serverLogin, :final memberRelay, :final origin) =>
'JellyfinSource own=$ownAccount login=$serverLogin member=$memberRelay origin=${origin != null}',
};
@@ -0,0 +1,106 @@
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);
}
});
}
+39 -21
View File
@@ -1,30 +1,30 @@
// restructure: rewrite in step 5 (Media pipeline): preload and the warm
// relay become one pipeline; keep the timing per source.
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_server/server.dart';
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/session_media.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
import 'package:starsync/src/playback/yt_dlp_app.dart';
import 'package:starsync/src/queue/queue_controller.dart' show WarmState;
import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/session/switch_trace.dart';
// How long an Up next switch takes, end to end, over real mpv and a real
// sync server: two Jellyfin account holders and a guest (through the
// server's /j login) watch an episode, then ⏭ puts up the next episode,
// then a direct link, then a link passed on "through" alice, then one the
// server fetches (both relayed: the guests preload them from the server's
// warm relay of the item). The fake Jellyfin and the link's host answer with a
// delay and a capped rate, like a server elsewhere. Prints each client's
// phases (SwitchTrace) and the wall time from ⏭ to the server playing.
// How long an Up next switch takes through the media pipeline, end to
// end, over real mpv and a real sync server: two Jellyfin account holders
// and a guest (through the server's /j login) watch an episode, then ⏭ puts
// up the next episode, then a direct link, then a link passed on "through"
// alice, then one the server fetches (both relayed: the guests prepare
// them from the server's warm relay of the item). Every client prepares
// the next item in a hidden player (`MediaPipeline.prepare`) and the swap
// promotes it. The fake Jellyfin and the link's host answer with a delay
// and a capped rate, like a server elsewhere. Prints each client's phases
// (SwitchTrace) and the wall time from ⏭ to the server playing.
//
// WT_SWITCH_PRELOAD=0 turns the preload off (the cold path, for comparison);
// WT_SWITCH_DELAY_MS and WT_SWITCH_RATE_KBS set the remote's delay and rate.
@@ -202,7 +202,12 @@ void main() {
'Up next: ⏭ to a Jellyfin episode, then to a direct link, timed per client',
timeout: const Timeout(Duration(minutes: 4)),
() async {
MediaKit.ensureInitialized();
// mpv's warnings, as the host writes them to the app log.
final mpvLines = <String>[];
// (The root's records, the app's logging not being hierarchical.)
final logSub = createLogger('mpv').onRecord.listen((r) {
if (r.loggerName == 'mpv') mpvLines.add(r.message);
});
final preload = Platform.environment['WT_SWITCH_PRELOAD'] != '0';
final file = await _media();
final jelly = _FakeJellyfin(file);
@@ -236,9 +241,15 @@ void main() {
final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC');
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
await accB.ensureLoaded(validate: false);
SessionCoordinator coord(JellyfinAccounts acc) => SessionCoordinator(
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp)
..playback.preloadEnabled = preload;
final hosts = <SessionCoordinator, MediaKitHost>{};
SessionCoordinator coord(JellyfinAccounts acc) {
final host = MediaKitHost()..init();
final c = SessionCoordinator(
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp, players: host)
..media.preloadEnabled = preload;
hosts[c] = host;
return c;
}
final a = coord(accA), b = coord(accB), c = coord(accC);
final all = {'alice': a, 'guest': b, 'carol': c};
ConnectParams params(String name, [HelloMedia? media]) =>
@@ -252,7 +263,7 @@ void main() {
if (preload) {
for (final x in all.values) {
expect(x.queue.warm.peek()?.state, WarmState.ready, reason: 'ready on every client before ⏭');
expect(x.playback.preloadUrl, isNotNull, reason: 'preloaded on every client');
expect(x.media.preloadUrl, isNotNull, reason: 'preloaded on every client');
}
}
final clock = Stopwatch()..start();
@@ -269,11 +280,14 @@ void main() {
}
await _poll(() => all.values.every((x) => x.playback.player.state.playing), 'all play $what');
// The promoted player is the session player: marks, tracks and
// the volume carried over, the clock moves.
// the volume carried over, the clock moves; the old one retired.
for (final x in all.values) {
expect(x.playback.durationSecs, greaterThan(59));
expect(x.playback.player.state.volume, 0, reason: 'volume kept');
await _poll(() => x.playback.tracks.peek().isNotEmpty, 'tracks listed');
final host = hosts[x]!;
await host.retiring;
expect(host.live, lessThanOrEqualTo(2), reason: 'the session player and at most the next prepared one');
}
final at = a.playback.positionSecs;
await Future.delayed(const Duration(seconds: 1));
@@ -286,7 +300,7 @@ void main() {
await b.connect(params('guest'));
await c.connect(params('carol'));
for (final x in all.values) {
await x.playback.player.setVolume(0);
await x.playback.setVolume(0);
}
await _poll(() => all.values.every((x) => x.playback.durationSecs > 59), 'all opened episode 1');
expect(await a.queue.add(pickForRef(accA, const JellyfinRef(serverId: _sid, itemId: _ep2, title: 'Second'))!),
@@ -332,14 +346,17 @@ void main() {
// ignore: avoid_print
print(report.join('\n'));
expect(serverErrors, isEmpty);
// No command reached a player still opening, and the
// cache is in RAM on every player (no "Failed to create file cache").
expect(mpvLines.where((l) => l.contains('error running command') || l.contains('file cache')), isEmpty);
// Nothing queued: no preload. Queued and taken off again: dropped.
if (preload) {
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'nothing preloaded');
await _poll(() => all.values.every((x) => x.media.preloadUrl == null), 'nothing preloaded');
expect(await a.queue.add(LinkMedia(linkUrl, relay: LinkRelay.direct)), isNull);
await _poll(() => all.values.every((x) => x.playback.preloadUrl == linkUrl), 'preloaded again');
await _poll(() => all.values.every((x) => x.media.preloadUrl == linkUrl), 'preloaded again');
a.queue.remove(a.serverState.peek()!.queue.first.id);
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'dropped when taken off');
await _poll(() => all.values.every((x) => x.media.preloadUrl == null), 'dropped when taken off');
}
// Preloaded everywhere: every client starts from its preload.
if (preload) {
@@ -348,6 +365,7 @@ void main() {
}
}
} finally {
await logSub.cancel();
for (final x in all.values) {
await x.dispose();
}
+9 -6
View File
@@ -4,7 +4,8 @@ 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:starsync/src/playback/playback_controller.dart';
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
import 'package:starsync/src/session/media_pipeline.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
@@ -42,13 +43,15 @@ void main() {
final server = SyncServer(AppConfig(
port: 0, secret: '', name: 'readahead', mediaPath: media));
await server.start();
final p = PlaybackController();
final host = MediaKitHost();
final m = MediaPipeline(host: host);
final p = m.playback;
try {
await p.init();
expect(p.pinMismatches, isEmpty,
await m.start();
expect(host.pinMismatches, isEmpty,
reason: 'every pinned mpv property took effect');
final token = server.session.issueToken('probe', '');
await p.openUrl(
await m.open(
'http://127.0.0.1:${server.boundPort}/m/default/$token/readahead.mp4');
var best = 0.0;
final end = DateTime.now().add(const Duration(seconds: 30));
@@ -62,7 +65,7 @@ void main() {
expect(best, greaterThan(120),
reason: 'paused player reads ahead about two minutes');
} finally {
await p.dispose();
await m.dispose();
await server.stop();
}
},
+226 -2
View File
@@ -35,15 +35,130 @@ Future<String> _media() async {
}
Future<void> _poll(FutureOr<bool> Function() cond, String what,
{Duration timeout = const Duration(seconds: 30)}) async {
{Duration timeout = const Duration(seconds: 30), Duration every = const Duration(milliseconds: 200)}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(const Duration(milliseconds: 200));
await Future.delayed(every);
}
fail('timed out waiting for $what');
}
/// 60 s at about 3 Mbit/s: long enough that a slow source's relay holds
/// only part of it. An mp4 with its index first: headless players have no
/// video track selected (vid=no), and mkv's cues list only the video, so
/// an mkv would be seeked by scanning from its start (an app's player,
/// with its video output, uses the cues).
Future<String> _longMedia() async {
final path = '$_tmp/relay_e2e_60s.mp4';
if (await File(path).exists()) return path;
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=24',
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
'-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-b:v', '3M', '-minrate', '3M', '-maxrate', '3M',
'-bufsize', '2M', '-g', '24', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', path,
]);
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
return path;
}
/// A file read at most [bytesPerSec] (a source's uplink).
class _SlowFile implements RelayBytes {
final File file;
final int bytesPerSec;
_SlowFile(this.file, this.bytesPerSec);
@override
Future<int> size() => file.length();
@override
Stream<List<int>> read(int start, int end) async* {
const chunk = 32 * 1024;
final sw = Stopwatch()..start();
var sent = 0;
await for (final c in file.openRead(start, end + 1)) {
for (var o = 0; o < c.length; o += chunk) {
final part = c.sublist(o, o + chunk > c.length ? c.length : o + chunk);
sent += part.length;
final due = sent * 1000 ~/ bytesPerSec - sw.elapsedMilliseconds;
if (due > 0) await Future<void>.delayed(Duration(milliseconds: due));
yield part;
}
}
}
@override
Future<void> close() async {}
}
/// A member that is only a source: it sits out (out of the gate), puts
/// [file] up relayed and feeds it at [bytesPerSec]. [drop] goes away
/// without a `bye` (the member `gone`, the relay kept); [back] returns as
/// the same member and feeds again.
class _BareSource {
final int port;
final File file;
final String name;
static const _client = 'bareSourceClient01';
var _seq = 0;
WebSocket? ws;
String token = '';
RelaySource? src;
Timer? _alive;
_BareSource(this.port, this.file) : name = 'slow.mp4';
Future<void> join() async {
final w = ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
final welcome = Completer<Map>();
w.listen((f) {
final j = jsonDecode('$f') as Map;
if (j['t'] == 'welcome' && !welcome.isCompleted) welcome.complete(j);
}, onError: (_) {});
w.add(jsonEncode(HelloMessage(
secret: 'pw', user: const HelloUser(name: 'bob'), client: _client, seq: ++_seq)
.toJson()));
token = (await welcome.future.timeout(const Duration(seconds: 5)))['token'] as String;
w.add(jsonEncode(const PresenceIntent(state: Presence.away).toJson()));
// Not silent, like an app (a silent member goes gone).
_alive?.cancel();
_alive = Timer.periodic(const Duration(seconds: 1),
(_) => w.add(jsonEncode({'t': 'report', 'position': 0, 'buffered': 0, 'eventId': 0})));
}
Future<void> swap() async {
ws!.add(jsonEncode({
't': 'swap',
'media': {'name': name, 'size': await file.length(), 'duration': 0, 'relay': <String, Object?>{}},
}));
}
Future<void> feed(int bytesPerSec) async {
final s = src = RelaySource(Uri.parse('ws://127.0.0.1:$port/r/default/$token/source'),
RelayMedia.single(_SlowFile(file, bytesPerSec)),
name: name);
await s.start();
}
/// Gone without a word: the channel and the socket close, no `bye`.
Future<void> drop() async {
_alive?.cancel();
await src?.close();
await ws?.close();
}
Future<void> back(int bytesPerSec) async {
await join();
await feed(bytesPerSec);
}
Future<void> close() async {
_alive?.cancel();
await src?.close();
await ws?.close();
}
}
/// Serves one file with ranges; [requests] counts hits.
Future<(HttpServer, List<String>)> _origin(String file) async {
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
@@ -267,4 +382,113 @@ void main() {
await proxy.close();
}
});
// A seek into a part the relay has not buffered yet. The source's
// uplink is about 3x the media's bitrate; the time from the seek to the
// player playing there again is printed and loosely bounded.
test('a seek past what a slow source has sent plays within a few seconds',
timeout: const Timeout(Duration(minutes: 2)), () async {
final file = File(await _longMedia());
final alice = await member('alice');
final bob = _BareSource(server.boundPort, file);
try {
await bob.join();
await bob.swap();
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state');
await bob.feed(1200 * 1024);
await _poll(() => alice.playback.durationSecs > 59, 'alice opened it');
alice.toggleReady();
await _poll(() => alice.playback.playing && alice.playback.positionSecs > 2, 'playing');
final took = <int>[];
for (final to in [40.0, 52.0]) {
final sw = Stopwatch()..start();
alice.seekTo(to);
await _poll(() => alice.playback.playing && alice.playback.positionSecs > to + 0.5, 'played at $to',
every: const Duration(milliseconds: 20));
took.add(sw.elapsedMilliseconds);
}
// ignore: avoid_print
print('relay seek: seek to an unbuffered part until playing there: ${took.join(', ')} ms');
for (final t in took) {
expect(t, lessThan(5000), reason: "a seek into what the source has not sent yet (4 s of buffer at about 3x the bitrate)");
}
} finally {
await bob.close();
}
});
// D6, the viewer's side: the source goes, a seek lands past what
// the server got from it, and the buffer runs dry.
test('the source drops: what is buffered plays, a seek past it waits for the source, no seek loop, it resumes (D6)',
timeout: const Timeout(Duration(minutes: 3)), () async {
final file = File(await _longMedia());
final alice = await member('alice');
final carol = await member('carol');
final bob = _BareSource(server.boundPort, file);
final errors = <String>[];
final subs = <StreamSubscription<Object?>>[];
try {
await bob.join();
await bob.swap();
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state');
// Just above the bitrate: the relay holds only a little ahead.
await bob.feed(560 * 1024);
await _poll(() => alice.playback.durationSecs > 59 && carol.playback.durationSecs > 59, 'both opened it');
for (final x in [alice, carol]) {
subs.add(x.playback.player.logs.listen((l) {
if (l.text.contains('error running command')) errors.add(l.text);
}));
x.toggleReady();
}
await _poll(() => alice.playback.playing && alice.playback.positionSecs > 4, 'playing');
await bob.drop();
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.sourceGone ?? false, 'source gone');
expect(alice.status.peek().line?.text, startsWith("bob's connection dropped"));
final seeks = [alice.playback.seeksSent, carol.playback.seeksSent];
// Carol skips to where the source never got to.
carol.seekTo(50);
await _poll(() => alice.serverState.peek()!.hold?.reason == HoldReason.source, 'held for the source',
timeout: const Duration(seconds: 60));
expect(alice.status.peek().line?.text, 'Waiting for bob');
expect(carol.status.peek().line?.text, 'Waiting for bob');
// Held for a while: no seek loop into bytes nobody sends.
await Future.delayed(const Duration(seconds: 12));
final sent = [alice.playback.seeksSent - seeks[0], carol.playback.seeksSent - seeks[1]];
expect(sent.every((n) => n <= 1), isTrue, reason: 'one try each, no loop: $sent');
expect(alice.serverState.peek()!.transport, 'held');
expect([alice.playerError.peek(), carol.playerError.peek()], everyElement(isEmpty), reason: 'not a broken player');
// Someone joins meanwhile: it opens what the server has and waits too.
final dave = await member('dave');
subs.add(dave.playback.player.logs.listen((l) {
if (l.text.contains('error running command')) errors.add(l.text);
}));
await _poll(() => dave.status.peek().line?.text == 'Waiting for bob', 'dave waits for bob');
dave.toggleReady();
await bob.back(4 * 1024 * 1024);
await _poll(() {
final relay = stateMediaRelay(alice.serverState.peek()!);
return relay != null && relay.live && !relay.sourceGone;
}, 'source back');
await _poll(
() =>
alice.serverState.peek()!.transport == 'playing' &&
alice.playback.playing &&
alice.playback.positionSecs > 50.5 &&
carol.playback.positionSecs > 50.5 &&
dave.playback.positionSecs > 50.5,
'all playing at 50 again',
timeout: const Duration(seconds: 40));
// ignore: avoid_print
print('D6: seeks while the source was gone $sent, held back '
'${alice.media.heldBackSeeks} + ${carol.media.heldBackSeeks}');
expect(errors, isEmpty, reason: 'no command reached a player without its media');
} finally {
for (final s in subs) {
await s.cancel();
}
await bob.close();
}
});
}
+12 -10
View File
@@ -2,7 +2,8 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:starsync/src/playback/playback_controller.dart';
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
import 'package:starsync/src/session/media_pipeline.dart';
import 'package:starsync/src/shaders/shader_assets.dart';
import 'package:starsync/src/shaders/shader_presets.dart';
import 'package:starsync/src/state/settings.dart';
@@ -21,12 +22,14 @@ void main() {
support.createSync(recursive: true);
shaderSupportDir = () async => support;
final s = AppSettings.instance;
final p = PlaybackController();
final m = MediaPipeline(host: MediaKitHost());
final p = m.playback;
try {
// The saved choice goes on at player creation.
s.setShaderPreset(ShaderPreset.a);
await p.init();
final dynamic mpv = p.player.platform;
await m.start();
await p.applyShaders();
final mpv = p.player;
final modeA =
await shaderPathsFor(ShaderPreset.a, ShaderQuality.hq, const []);
expect(shaderListMatches(modeA, await mpv.getProperty('glsl-shaders')), isTrue);
@@ -59,17 +62,16 @@ void main() {
expect(await mpv.getProperty('glsl-shaders'), good.path);
// A recreated player (Reload video) gets the choice again.
await p.recreate();
expect(await (p.player.platform as dynamic).getProperty('glsl-shaders'),
good.path);
await m.reload();
await p.applyShaders();
expect(await p.player.getProperty('glsl-shaders'), good.path);
s.setShaderPreset(ShaderPreset.off);
await p.applyShaders(announce: true);
expect(await (p.player.platform as dynamic).getProperty('glsl-shaders'),
'');
expect(await p.player.getProperty('glsl-shaders'), '');
expect(p.shaderNotice.value?.text, 'Shaders: Off');
} finally {
await p.dispose();
await m.dispose();
}
});
}
+1 -1
View File
@@ -12,7 +12,7 @@ const baseline = {
'UpdateController': 8,
'Profile': 8,
'FolderStore': 6,
'AppYtDlp': 4,
'AppYtDlp': 2,
'RecentLinks': 3,
};
@@ -26,8 +26,7 @@ Future<void> _poll(
Future<void> _quiet(SessionCoordinator c) async {
await c.playback.player.setVolume(0);
try {
final dynamic platform = c.playback.player.platform;
await platform?.setProperty('loop-file', 'inf');
await c.playback.player.setProperty('loop-file', 'inf');
} catch (_) {}
}
+23 -22
View File
@@ -1,14 +1,16 @@
// restructure: rewrite in step 6 (Media pipeline + player host): the
// fresh player per video.
import 'dart:async';
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:starsync/src/platform/desktop/media_kit_host.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/sync/state_diff.dart';
// "Reload video" through the media pipeline: a fresh player from the
// host, the old one retired, the same media opened again and resynced,
// the ready flag untouched; leaving releases every player the host made.
const _mediaPath = '/home/foxyz/Videos/chiyo.mp4';
Future<void> _poll(
@@ -26,47 +28,46 @@ Future<void> _poll(
void main() {
test(
'reload video: new player, same media, resynced, ready untouched; '
'leave disposes the player',
'reload video: a fresh player from the host, same media, resynced, ready untouched; '
'leave releases every player',
timeout: const Timeout(Duration(minutes: 2)),
() async {
MediaKit.ensureInitialized();
final server = SyncServer(const AppConfig(
port: 0, secret: 'reload', name: 'reload', mediaPath: _mediaPath));
final server = SyncServer(const AppConfig(port: 0, secret: 'reload', name: 'reload', mediaPath: _mediaPath));
await server.start();
final c = SessionCoordinator();
final host = MediaKitHost()..init();
final c = SessionCoordinator(players: host);
try {
await c.connect(ConnectParams(
host: '127.0.0.1',
port: server.boundPort,
secret: 'reload',
name: 'solo'));
await c.playback.player.setVolume(0);
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'reload', name: 'solo'));
await c.playback.setVolume(0);
await _poll(() => c.serverState.peek() != null, 'server state');
c.toggleReady();
await _poll(
() => c.playback.player.state.playing && c.playback.positionSecs > 2,
'solo playback past 2 s');
await _poll(() => c.playback.playing && c.playback.positionSecs > 2, 'solo playback past 2 s');
final opens = c.opens;
final old = c.playback.player;
final gen = c.playback.generation.peek();
await c.reloadVideo();
expect(identical(c.playback.player, old), isFalse);
expect(identical(c.playback.player, old), isFalse, reason: 'a fresh player');
expect(old.disposed, isTrue, reason: 'the old one retired');
expect(host.live, 1, reason: 'only the new one is alive');
expect(c.playback.generation.peek(), gen + 1);
expect(c.opens, opens + 1, reason: 'the same media opened again');
expect(c.playback.volume.peek(), 0, reason: 'the volume carried over');
expect(c.selfMember.peek()?.ready, isTrue);
await _poll(() {
final r = c.serverState.peek();
if (r == null || !c.playback.player.state.playing) return false;
if (r == null || !c.playback.playing) return false;
final want = serverPositionAt(r, DateTime.now().toUtc());
return (c.playback.positionSecs - want).abs() < 1.5 &&
c.playback.positionSecs > 2;
return (c.playback.positionSecs - want).abs() < 1.5 && c.playback.positionSecs > 2;
}, 'reloaded player playing at the synced position');
expect(c.selfMember.peek()?.ready, isTrue);
expect(c.serverState.peek()?.transport, 'playing');
await c.leave();
expect(c.playback.alive.peek(), isFalse);
await host.retiring;
expect(host.live, 0, reason: 'leaving releases every player');
} finally {
await c.dispose();
await server.stop();
+1 -1
View File
@@ -27,7 +27,7 @@ void main() {
// dispose would wait on them for ever; the test process ending ends it.
setUpAll(() async {
MediaKit.ensureInitialized();
await coord.playback.init();
await coord.media.start();
});
testWidgets('wheel over the video: readout for about a second', (tester) async {
+91 -27
View File
@@ -2,8 +2,10 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:starsync/src/playback/playback_controller.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
import 'package:starsync/src/playback/yt_dlp_app.dart';
import 'package:starsync/src/session/media_pipeline.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
const _referer = 'https://www.bilibili.com/';
@@ -22,7 +24,8 @@ Future<(String, String)> _media() async {
return (v, a);
}
/// A CDN like Bilibili's: 403 without the page's Referer.
/// A CDN like Bilibili's: 403 without the page's Referer. `/video/...` is
/// the page itself (HTML: the app's probe sends it to yt-dlp).
class _Cdn {
_Cdn(this._server, this._files) {
_server.listen(_serve);
@@ -30,11 +33,27 @@ class _Cdn {
final HttpServer _server;
final Map<String, String> _files;
int ok = 0, forbidden = 0;
final cookies = <String?>[];
String url(String name) => 'http://127.0.0.1:${_server.port}/$name';
String page(String id) => 'http://127.0.0.1:${_server.port}/video/$id';
int subtitles = 0;
Future<void> _serve(HttpRequest req) async {
final res = req.response;
if (req.uri.pathSegments.firstOrNull == 'video') {
res.headers.contentType = ContentType.html;
res.write('<html><title>Fake page</title></html>');
await res.close();
return;
}
if (req.uri.pathSegments.lastOrNull == 'en.vtt') {
subtitles++;
res.write('WEBVTT\n\n00:00.000 --> 00:05.000\nHello\n');
await res.close();
return;
}
final file = _files[req.uri.pathSegments.lastOrNull];
if (file == null || req.headers.value('referer') != _referer) {
forbidden++;
@@ -43,6 +62,7 @@ class _Cdn {
return;
}
ok++;
cookies.add(req.headers.value('cookie'));
final bytes = await File(file).readAsBytes();
var start = 0, end = bytes.length - 1;
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
@@ -59,25 +79,37 @@ class _Cdn {
}
}
/// A stand-in yt-dlp printing what yt-dlp gives for a split-track page:
/// `requested_formats` with per-format `http_headers`, none at the top.
Future<String> _fakeYtDlp(_Cdn cdn, {required bool headers}) async {
final dir = await Directory('$_tmp/ytdl_headers_fake').create(recursive: true);
/// A stand-in yt-dlp (the managed copy in its own folder) printing what
/// yt-dlp gives for a split-track page: `requested_formats` with
/// per-format `http_headers` and `cookies`, subtitles with Bilibili's
/// danmaku among them. [fail]: it finds no formats instead.
Future<AppYtDlp> _fakeYtDlp(_Cdn cdn, {required bool headers, bool fail = false}) async {
final dir = Directory('$_tmp/ytdl_direct_${headers ? 'h' : 'n'}${fail ? 'f' : ''}');
if (dir.existsSync()) dir.deleteSync(recursive: true);
dir.createSync(recursive: true);
final h = headers ? {'Referer': _referer, 'User-Agent': 'fake-agent'} : <String, String>{};
final json = jsonEncode({
'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake',
'format_id': 'v+a', 'ext': 'mp4',
'requested_formats': [
{'format_id': 'v', 'url': cdn.url('v.m4s'), 'protocol': 'https', 'ext': 'mp4',
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h},
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h,
'cookies': 'SESSDATA=abc; Domain=.127.0.0.1; Path=/'},
{'format_id': 'a', 'url': cdn.url('a.m4s'), 'protocol': 'https', 'ext': 'm4a',
'vcodec': 'none', 'acodec': 'mp4a.40.2', 'http_headers': h},
],
'requested_subtitles': {
'en': {'url': cdn.url('en.vtt'), 'ext': 'vtt', 'name': 'English'},
'danmaku': {'url': cdn.url('danmaku.xml'), 'ext': 'xml'},
},
});
final f = File('${dir.path}/yt-dlp-${headers ? 'h' : 'n'}');
await f.writeAsString("#!/bin/sh\ncat <<'EOF'\n$json\nEOF\n");
final f = File('${dir.path}/yt-dlp');
await f.writeAsString('#!/bin/sh\n'
'case "\$1" in --version) echo 2099.01.01; exit 0;; esac\n'
'${fail ? 'echo "ERROR: [fake] BV1: No video formats found!" >&2; exit 1\n' : "cat <<'EOF'\n$json\nEOF\n"}');
await Process.run('chmod', ['+x', f.path]);
return f.path;
await File('${dir.path}/last-check').writeAsString(DateTime.now().toIso8601String());
return AppYtDlp(YtDlp(dir.path, updateEvery: const Duration(days: 9999)));
}
void main() {
@@ -85,32 +117,39 @@ void main() {
late HttpServer server;
setUpAll(() async {
MediaKit.ensureInitialized();
final (v, a) = await _media();
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
cdn = _Cdn(server, {'v.m4s': v, 'a.m4s': a});
});
tearDownAll(() => server.close(force: true));
// A page "everyone fetches" opens through the app's own resolve: its
// stream URLs (an EDL of video + audio) with the CDN's headers; mpv's
// yt-dlp hook is off.
test(
"mpv's yt-dlp hook sends each format's Referer to the CDN",
"a page plays from yt-dlp's stream URLs with each CDN's Referer and the cookies; subtitles but danmaku",
timeout: const Timeout(Duration(minutes: 1)),
() async {
final p = PlaybackController();
final host = MediaKitHost()..init();
final m = MediaPipeline(host: host, links: await _fakeYtDlp(cdn, headers: true));
final p = m.playback;
try {
await p.init();
expect(p.pinMismatches, isEmpty, reason: 'stream-lavf-o and the rest took effect');
await p.useYtDlp(await _fakeYtDlp(cdn, headers: true));
await p.openUrl('https://www.bilibili.com/video/BV1fake');
await m.start();
expect(host.pinMismatches, isEmpty, reason: 'stream-lavf-o, ytdl=no and the rest took effect');
expect(await p.player.getProperty('ytdl'), 'no');
expect(await m.open(cdn.page('BV1fake')), isTrue);
final end = DateTime.now().add(const Duration(seconds: 20));
while (DateTime.now().isBefore(end) && p.duration.peek() == Duration.zero) {
while (DateTime.now().isBefore(end) && (p.duration.peek() == Duration.zero || cdn.subtitles == 0)) {
await Future.delayed(const Duration(milliseconds: 100));
}
expect(p.duration.peek(), greaterThan(Duration.zero), reason: 'both tracks opened');
expect(cdn.forbidden, 0, reason: 'every request had the Referer');
expect(cdn.ok, greaterThanOrEqualTo(2));
expect(cdn.cookies, everyElement('SESSDATA=abc'));
expect(cdn.subtitles, 1, reason: 'the page\'s subtitles are added, danmaku is not');
await _until(() => p.tracks.peek().any((t) => t.isExternal), 'the subtitle track listed');
} finally {
await p.dispose();
await m.dispose();
}
},
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
@@ -120,29 +159,54 @@ void main() {
'a 403 is a load error, not a reconnect loop',
timeout: const Timeout(Duration(minutes: 1)),
() async {
final p = PlaybackController();
final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: false));
final p = m.playback;
final errors = <String>[];
p.onLoadError = errors.add;
try {
await p.init();
await m.start();
var logs = 0;
final sub = p.player.stream.log.listen((_) => logs++);
await p.useYtDlp(await _fakeYtDlp(cdn, headers: false));
final sub = p.player.logs.listen((_) => logs++);
final before = cdn.forbidden;
await p.openUrl('https://www.bilibili.com/video/BV2fake');
await m.open(cdn.page('BV2fake'));
await Future.delayed(const Duration(seconds: 8));
await sub.cancel();
// ignore: avoid_print
print('403s: ${cdn.forbidden - before}, log lines: $logs, errors: $errors');
expect(cdn.forbidden - before, lessThan(10), reason: 'no retry loop on 403');
expect(errors, hasLength(1));
// The status only when ffmpeg logs through this mpv (see _onLog).
// The status only when ffmpeg logs through this mpv (see the host).
expect(errors.single, anyOf(contains('403'), contains('could not be opened')));
expect(logs, lessThan(30), reason: 'warnings and errors only');
} finally {
await p.dispose();
await m.dispose();
}
},
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
);
// D11: yt-dlp finds no video; the error names the cookies it used.
test('a page yt-dlp finds no video on: the error says which cookies were used', () async {
final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: true, fail: true));
final got = <(String, String?)>[];
m.onError = (raw, {cookies}) => got.add((raw, cookies));
try {
await m.start();
expect(await m.open(cdn.page('BV3fake')), isFalse);
expect(got.single.$1, contains('No video formats found'));
expect(got.single.$2, 'no cookies were used');
expect(withCookieNote(playErrorText(got.single.$1), got.single.$2),
'No video found: it may need signing in (no cookies were used)');
} finally {
await m.dispose();
}
}, skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script');
}
Future<void> _until(bool Function() cond, String what) async {
final end = DateTime.now().add(const Duration(seconds: 10));
while (!cond()) {
if (DateTime.now().isAfter(end)) fail('timed out waiting for $what');
await Future.delayed(const Duration(milliseconds: 100));
}
}