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:
1 parent
8970f2694b
commit
25b19f2d6a
47 files changed
+4083
-1728
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user