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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user