Restructure step 6: what's next and watch tracking
ci / core (push) Successful in 29s
ci / server (push) Successful in 53s
ci / updater-windows (push) Failing after 7m45s

- What's next is the server's: state.advance counts down (10 s) when the
  media finishes with an Up next item first, holds while its performer or
  warm source is gone, and sets queuePlay at its end; cancel is shared, a
  seek or swap ends it; the server skips items nobody can put up. Shared
  rules in core whats_next.dart; the app's janitor, per-screen countdown
  and end-of-episode card are gone.
- The continuation (next Jellyfin episode, next file of a local show
  with its gaps) is an auto item under "Continues with", prepared like
  Up next's first item.
- Watch tracking by time In (D3): only time played while this viewer is
  in counts; away, paused, held, jumps and stalls never do (P7, P9).
  WatchTracker + device WatchHistory (files, links); Jellyfin progress
  reads the same watch; records are the AniList seam.
- A server restart no longer loses what played: apps hold their media
  and one offers it back (swap.restore, first offer wins), Up next is
  re-added, the source re-feeds, and the watch carries on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-16 02:06:04 +03:00
1 parent a87ee98faa
commit 741b258ac1
55 files changed
+2724 -964

No files matched your search

@@ -362,6 +362,87 @@ void main() {
}
});
test('a restarted server comes back empty: what played is offered back where it was, nothing reopens, '
'the watch goes on, Up next comes back', timeout: const Timeout(Duration(minutes: 2)), () async {
final first = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'restart'));
await first.start();
final port = first.boundPort;
final origin = await _origin(await _media());
SyncServer? second;
try {
final alice = await member(port, 'alice');
final bob = await member(port, 'bob');
await playTogether(alice, [alice, bob], 'http://127.0.0.1:${origin.port}/ep.mkv');
expect(await bob.queue.add(LinkMedia('http://127.0.0.1:${origin.port}/next.mkv', anyway: true)), isNull);
await _poll(() => alice.queue.items.peek().length == 1, 'Up next has an item');
await Future.delayed(const Duration(seconds: 4));
final opens = [alice.opens, bob.opens];
final watch = alice.watch.session;
final watched = watch!.timeIn;
expect(watched, greaterThan(2));
await first.stop();
await _poll(() => alice.reconnecting.peek() && bob.reconnecting.peek(), 'both reconnecting');
second = SyncServer(AppConfig(port: port, secret: 'pw', name: 'restart'));
await second.start();
final engine = second.hub.engine;
await _poll(() => engine.media?.name == 'ep.mkv', 'the media offered back', timeout: const Duration(seconds: 30));
expect(engine.mediaId, 2, reason: 'one offer taken: the server started at 1');
expect(engine.positionAt(), greaterThan(4), reason: 'where it was, not the start');
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing && x.serverState.peek()?.transport == 'playing'),
'playing again', timeout: const Duration(seconds: 20));
expect([alice.opens, bob.opens], opens, reason: 'the players kept it: nothing reopened');
await _poll(() => engine.queue.items.length == 1, 'Up next back');
expect(engine.queue.items.single.media.url, endsWith('/next.mkv'));
// The watch went on through the restart (one watch, still counting).
await Future.delayed(const Duration(seconds: 2));
expect(identical(alice.watch.session, watch), isTrue);
expect(alice.watch.mediaId, engine.mediaId);
expect(watch.timeIn, greaterThan(watched + 1));
} finally {
await second?.stop();
await origin.close(force: true);
}
});
test('a restarted server comes back empty: the member passing its file on puts it back and feeds it again',
timeout: const Timeout(Duration(minutes: 2)), () async {
final first = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'restart'));
await first.start();
final port = first.boundPort;
final file = await _media();
SyncServer? second;
try {
final alice = await member(port, 'alice');
final bob = await member(port, 'bob');
expect(await alice.swapPicked(FileMedia(file)), isNull);
await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 100), 'both opened it',
timeout: const Duration(seconds: 30));
for (final x in [alice, bob]) {
x.toggleReady();
}
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing), 'both playing');
await Future.delayed(const Duration(seconds: 3));
final aliceOpens = alice.opens;
await first.stop();
await _poll(() => alice.reconnecting.peek() && bob.reconnecting.peek(), 'both reconnecting');
second = SyncServer(AppConfig(port: port, secret: 'pw', name: 'restart'));
await second.start();
final hub = second.hub;
await _poll(() => hub.engine.media?.name == 'coord_presence_120s.mkv', 'the file offered back',
timeout: const Duration(seconds: 30));
expect(hub.engine.positionAt(), greaterThan(3));
await _poll(() => hub.relay?.sourceToken == alice.connection.token && alice.relaying.peek(), 'alice feeds it again');
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing && x.serverState.peek()?.transport == 'playing'),
'playing again', timeout: const Duration(seconds: 30));
expect(alice.opens, aliceOpens, reason: 'her own file stayed open');
expect(hub.relay!.buffers.single.bytesToViewers, greaterThan(0), reason: 'bob reads the new relay');
} finally {
await second?.stop();
}
});
test('cancel during connect setup aborts without joining', timeout: const Timeout(Duration(minutes: 1)), () async {
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel'));
await server.start();
+42 -23
View File
@@ -1,5 +1,3 @@
// restructure: rewrite in step 6 (Watch tracking): the progress-reporting
// checks; the guest-through-the-relay flow stays.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -14,6 +12,7 @@ import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/playback/media_marks.dart';
import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/state/settings.dart';
// Two account holders and a guest watch Jellyfin episodes together over a
// real sync server, real mpv and a local fake Jellyfin (never a real one):
@@ -220,7 +219,8 @@ String _kind(_Call c) => c.path.split('/').last;
void main() {
test(
'account holders direct-play and report, the guest plays through the relay, all in sync',
'account holders direct-play and report what they watched, the guest plays through the relay, '
'the next episode continues it for everyone',
timeout: const Timeout(Duration(minutes: 4)),
() async {
MediaKit.ensureInitialized();
@@ -296,6 +296,11 @@ void main() {
expect((p - pos.first).abs(), lessThan(1.0), reason: 'in sync: $pos');
}
// Carol sits out for the rest of the episode: she is not tracked as
// having watched it (P7).
c.sitOut();
await _poll(() => c.selfAway.peek(), 'carol away');
// Pause (A unreadies): an immediate Progress with IsPaused.
a.toggleReady();
await _poll(() => !a.playback.player.state.playing, 'held');
@@ -304,7 +309,7 @@ void main() {
_kind(s) == 'Progress' && s.body?['IsPaused'] == true),
'paused progress');
a.toggleReady();
await _poll(() => all.every((x) => x.playback.player.state.playing), 'playing again');
await _poll(() => [a, b].every((x) => x.playback.player.state.playing), 'playing again');
// Account holders see the "Next episode" prompt (this item is
// shorter than its 2 min lead); the guest cannot put it up.
@@ -319,23 +324,28 @@ void main() {
_kind(s) == 'Progress' && s.body?['AudioStreamIndex'] == 2),
'track change reported');
// Near the end: finished -> Stopped, then one auto-advance swap.
a.seekTo(16);
// Alice put the episode up and has the next one: it continues it,
// in Up next for everyone.
await _poll(() => b.queue.continues.peek()?.media.jellyfin?.itemId == _ep2, 'the continuation');
final auto = b.queue.continues.peek()!;
expect(auto.by, a.connection.memberId);
// Played to the end: finished -> Stopped, the server's countdown
// on every screen, then one swap.
await _poll(() => a.serverState.peek()?.transport == 'finished', 'finished',
timeout: const Duration(seconds: 20));
timeout: const Duration(seconds: 40));
final finishedId = a.serverState.peek()!.mediaId;
await _poll(() => a.autoAdvance.peek() != null && c.autoAdvance.peek() != null,
'both account holders count down');
expect(b.autoAdvance.peek(), isNull);
final swappers = [a.autoAdvance.peek()!.swapper, c.autoAdvance.peek()!.swapper];
expect(swappers.where((s) => s), hasLength(1),
reason: 'exactly one designated swapper: the lowest member id (alice, m1)');
expect(a.autoAdvance.peek()!.swapper, isTrue);
await _poll(() => [a, b].every((x) => (x.queue.countdown.peek()?.secondsLeft ?? 0) > 0),
'everyone sees the countdown');
expect(a.serverState.peek()!.advance!.id, auto.id);
expect(a.queue.countdown.peek()!.item.id, auto.id);
await _poll(() => (a.serverState.peek()?.mediaId ?? 0) > finishedId, 'advanced',
timeout: const Duration(seconds: 20));
timeout: const Duration(seconds: 25));
await Future.delayed(const Duration(seconds: 3));
expect(a.serverState.peek()!.mediaId, finishedId + 1, reason: 'no double swap');
expect(refOfStateMedia(a.serverState.peek()!.media)!.itemId, _ep2);
expect(a.serverState.peek()!.queue, isEmpty, reason: 'the swap took the continuation');
c.joinBack();
await _poll(() => all.every((x) => x.playback.durationSecs > 19 && x.jellyfinPlan.peek() != null),
'all opened the next episode');
expect(b.jellyfinPlan.peek(), isA<RelayPlay>());
@@ -383,13 +393,17 @@ void main() {
expect(ep1.last, 'Stopped');
expect(ep1.where((k) => k == 'Stopped'), hasLength(1));
final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1);
// Part of it watched, then a seek to the end: not watched (see
// WatchSession). A resume point capped at 85 % of the 20 s, so
// Jellyfin cannot mark it played on its own.
expect(stopped1.body!['PositionTicks'], lessThan(18 * 10000000),
reason: 'part way: resume point under 90 %, so not marked played');
expect(jelly.calls.any((c) => c.method == 'POST' && c.path == '/UserPlayedItems/$_ep1'), isFalse,
reason: 'seeking to the end is not watching');
// Alice watched it through (time In, see WatchSession): Stopped
// without a position, then marked played.
expect(stopped1.body!.containsKey('PositionTicks'), isFalse, reason: 'watched: Jellyfin marks it played');
bool markedPlayed(String token) => jelly.calls.any((c) =>
c.method == 'POST' &&
c.path == '/UserPlayedItems/$_ep1' &&
_FakeJellyfin.authFields(c.auth)['Token'] == token);
expect(markedPlayed('ATOKEN'), isTrue);
expect(markedPlayed('CTOKEN'), isFalse, reason: 'carol sat out most of it (P7)');
final carol1 = jelly.sessions(token: 'CTOKEN').lastWhere((s) => s.body?['ItemId'] == _ep1);
expect(_kind(carol1), 'Stopped', reason: 'sitting out stops her reports');
expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1');
expect(ep2.first, 'Playing');
expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped');
@@ -442,7 +456,12 @@ void main() {
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
await accB.ensureLoaded(validate: false);
final a = SessionCoordinator(jellyfin: accA, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
// No continuation: its warm relay would be read while the test stops
// (a cut relay response fails a test, see DEVELOPMENT "Traps").
final a = SessionCoordinator(
jellyfin: accA,
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
settings: AppSettings()..autoAdvance.value = false);
final b = SessionCoordinator(jellyfin: accB, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
try {
await a.connect(ConnectParams(
+62 -42
View File
@@ -1,5 +1,3 @@
// restructure: rewrite in step 6 (What's next + Watch tracking): the
// "progress" and "episodes" groups (reporting, auto-advance swapper).
import 'dart:convert';
import 'package:flutter/gestures.dart';
@@ -14,8 +12,9 @@ import 'package:starsync/src/jellyfin/session_media.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/screens/home/media_picker.dart';
import 'package:starsync/src/screens/session/jellyfin_widgets.dart';
import 'package:starsync/src/queue/queue_controller.dart' show UpNextCountdown;
import 'package:starsync/src/queue/queue_ui.dart' show UpNextCard;
import 'package:starsync/src/session/delivery.dart';
import 'package:starsync/src/session/session_coordinator.dart' show AutoAdvance;
import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia;
import 'jellyfin_accounts_test.dart';
@@ -212,9 +211,12 @@ void main() {
});
});
// The reporter reads the watch the session's tracker feeds (time In,
// D3); here the test feeds it.
group('progress', () {
late JellyfinAccounts accounts;
late DateTime now;
late WatchSession watch;
late JellyfinProgressReporter reporter;
final errors = <Object>[];
var unauthorized = 0;
@@ -224,27 +226,35 @@ void main() {
now = DateTime(2026, 1, 1, 20);
errors.clear();
unauthorized = 0;
watch = WatchSession();
reporter = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!,
itemId: episodeId,
source: fixtureSource(),
playSessionId: playSessionId,
watch: watch,
clock: () => now,
onError: errors.add,
onUnauthorized: () => unauthorized++,
);
});
/// One second of the session at wall second [secs]: the watch gets its
/// sample (In unless [away]), the reporter its snapshot.
void tick(int secs, double pos, {bool paused = false, bool away = false, int? aid, int? sid}) {
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs));
watch.sample(pos, playing: !paused, present: !away, at: now);
reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid));
}
Future<void> idle() => reporter.stop(const PlayerSnapshot(position: 0, paused: true))
.then((_) {});
test('Playing, Progress every 10 s and on pause / seek / track change, Stopped', () async {
// English audio (mpv aid 2 = Jellyfin index 2), English full subs (sid 1 = 3).
reporter.start(const PlayerSnapshot(position: 0, paused: true, aid: 2, sid: 1));
void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) {
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs));
reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid));
}
void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) =>
tick(secs, pos, paused: paused, aid: aid, sid: sid);
at(1, 0); // resume -> Progress
at(2, 1);
@@ -282,15 +292,14 @@ void main() {
'MediaSourceId': episodeId,
'PlaySessionId': playSessionId,
'PositionTicks': 0,
}, reason: '11 s watched is a peek: the resume point from before (none) stays');
}, reason: '11 s In is a peek: the resume point from before (none) stays');
expect(errors, isEmpty);
});
test('watched to the end: Stopped without a position, then marked played', () async {
reporter.start(const PlayerSnapshot(position: 0, paused: false));
for (var i = 1; i <= 1420; i++) {
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: i));
reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false));
for (var i = 0; i <= 1420; i++) {
tick(i, i.toDouble());
}
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 3),
@@ -302,6 +311,19 @@ void main() {
expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide');
});
test('sat out for most of it: a resume point, never played (P7)', () async {
reporter.start(const PlayerSnapshot(position: 0, paused: false));
for (var i = 0; i <= 1420; i++) {
// In for five minutes, then away while the others watch on.
tick(i, i.toDouble(), away: i > 300);
}
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
expect(reports.map((r) => r.path.split('/').last), isNot(contains('UserPlayedItems')));
final stopped = reports.lastWhere((r) => r.path.endsWith('Stopped')).body;
expect(stopped['PositionTicks'], 300 * jellyfinTicksPerSecond,
reason: 'it resumes where this viewer stopped watching');
});
test('played before: a peek never unmarks it', () async {
final r = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!,
@@ -376,21 +398,6 @@ void main() {
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
});
test('one auto-advance swapper: the lowest id among members with their own account', () {
final members = [
member('m10', 'Ann', jf: {fixtureServerId: true}),
member('m2', 'Bob', jf: {fixtureServerId: true}),
member('m1', 'Guest', jf: {fixtureServerId: false}),
member('m0', 'Old', jf: {fixtureServerId: true}, gone: true),
member('m3', 'Fresh'),
];
expect(autoAdvanceSwapper(members, fixtureServerId), 'm2');
expect(autoAdvanceSwapper(members.reversed.toList(), fixtureServerId), 'm2',
reason: 'order does not matter: every client agrees');
expect(autoAdvanceSwapper(members, 'other-server'), isNull);
expect(compareMemberIds('m9', 'm10'), lessThan(0));
});
test('member route: via server only for relay viewers of the current item', () {
DeliveryRoute? route(MemberView m, {bool relay = true}) =>
routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay);
@@ -508,23 +515,36 @@ void main() {
const JellyfinMedia(
serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes'));
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async {
var play = 0, cancel = 0;
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard(
next: next, countdown: null, onPlayNow: () => play++, onCancel: () => cancel++))));
expect(find.text('Next: S1E7 Battle with the Heroes'), findsOneWidget);
expect(find.byKey(const ValueKey('auto-advance-cancel')), findsNothing);
await tester.tap(find.byKey(const ValueKey('play-next')));
expect(play, 1);
testWidgets("end card: the server's countdown with Cancel, Play now; waiting for who puts it up",
(tester) async {
final queue = _graph.coordinator.queue;
const item = QueueItem(
id: 'q4',
media: HelloMedia(name: 'ep7', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: fixtureServerId, itemId: nextEpisodeId)),
how: QueueHow.jellyfin,
title: 'Battle with the Heroes',
sub: 'S1E7',
by: 'm1',
byName: 'Ann',
auto: true,
forMedia: 3);
await tester.pumpWidget(scoped(_graph, app(UpNextCard(
queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item, secondsLeft: 7)))));
expect(find.text('Next in 7 s'), findsOneWidget);
expect(find.text('Battle with the Heroes'), findsOneWidget);
expect(find.byKey(const ValueKey('up-next-cancel')), findsOneWidget);
expect(find.byKey(const ValueKey('up-next-play-now')), findsOneWidget);
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard(
next: next,
countdown: AutoAdvance(mediaId: 3, secondsLeft: 7, next: next, swapper: false),
onPlayNow: () => play++,
onCancel: () => cancel++))));
expect(find.text('Next episode in 7 s'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('auto-advance-cancel')));
expect(cancel, 1);
// Cancelled (for everyone): Play now only.
await tester.pumpWidget(scoped(_graph, app(UpNextCard(
queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item)))));
expect(find.text('Next'), findsOneWidget);
expect(find.byKey(const ValueKey('up-next-cancel')), findsNothing);
// Ran out while the one who puts it up is gone: the card says so.
await tester.pumpWidget(scoped(_graph, app(UpNextCard(
queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item, secondsLeft: 0, waitingFor: 'Ann')))));
expect(find.text('Waiting for Ann'), findsOneWidget);
});
testWidgets('next-episode pill: faint at rest, lit on hover, click puts it up', (tester) async {
+56 -52
View File
@@ -1,25 +1,20 @@
// restructure: rewrite in step 6 (What's next): who performs an item and
// the janitor become server state.
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/files/scanner.dart' show FolderScan;
import 'package:starsync/src/library/library_store.dart';
import 'package:starsync/src/library/local_next.dart';
import 'package:starsync/src/library/show_matcher.dart';
import 'package:starsync/src/queue/queue_logic.dart';
MemberView member(String id,
{bool gone = false, bool away = false, Map<String, bool> jf = const {}, String name = ''}) =>
MemberView(
id: id,
name: name.isEmpty ? id : name,
ready: false,
inSet: true,
buffered: 0,
presence: gone ? Presence.gone : away ? Presence.away : Presence.watching,
jf: jf);
import 'show_matcher_test.dart' show FakeAniList;
// What's next on the app: an item's words, and what continues a file of
// a local show (who puts an item up and the countdown are core's and the
// server's: core `whats_next_test`, `queue_test`).
QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem(
id: 'q1',
media: how == QueueHow.jellyfin
? const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i'))
: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
media: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
how: how,
title: 'x',
by: by,
@@ -27,49 +22,58 @@ QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => Queue
);
void main() {
group('queuePerformer', () {
test('a link passed on or a file: only its adder; gone waits for it, left means skipped', () {
for (final how in [QueueHow.through, QueueHow.file]) {
expect(queuePerformer(item(how), [member('m1'), member('m2', away: true)]).id, 'm2');
final waits = queuePerformer(item(how), [member('m1'), member('m2', gone: true)]);
expect((waits.id, waits.why), (null, null), reason: 'a gone adder may come back within its grace');
final left = queuePerformer(item(how), [member('m1'), member('m3')]);
expect(left.id, isNull);
expect(left.why, 'Bob left');
}
});
test('Jellyfin: the adder with access, else the lowest member with access, else skipped', () {
final access = {'S': true};
expect(queuePerformer(item(QueueHow.jellyfin), [member('m1', jf: access), member('m2', jf: access)]).id, 'm2');
expect(
queuePerformer(item(QueueHow.jellyfin), [member('m10', jf: access), member('m3', jf: access), member('m2')])
.id,
'm3');
expect(queuePerformer(item(QueueHow.jellyfin), [member('m3', jf: access, away: true), member('m2')]).why,
isNotNull);
});
test('a link everyone opens or the server fetches: the adder, else the lowest active member', () {
for (final how in [QueueHow.direct, QueueHow.server]) {
expect(queuePerformer(item(how), [member('m1'), member('m2')]).id, 'm2');
expect(queuePerformer(item(how), [member('m4'), member('m2', gone: true), member('m3')]).id, 'm3');
final nobody = queuePerformer(item(how), [member('m2', away: true)]);
expect((nobody.id, nobody.why), (null, null), reason: 'nobody around is not a skip');
}
});
});
test('the janitor is the lowest member that is not gone', () {
expect(queueJanitor([member('m9'), member('m1', gone: true), member('m2')]), 'm2');
});
test('labels', () {
expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you');
expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob');
expect(queueHowLabel(item(QueueHow.file), selfId: 'm1'), "Bob's file");
expect(queueHowLabel(item(QueueHow.file, byName: ''), selfId: 'm1'), "who added it's file");
expect(queueClock(3725), '1:02:05');
expect(queueClock(65), '1:05');
expect(queueClock(0), '');
});
group('what continues a file of a local show', () {
const dir = '/lib/Bleach';
String ep(int n) => '$dir/[Judas] Bleach - ${n.toString().padLeft(3, '0')}.mkv';
// 064 and 065 are there, 066..107 are not.
final files = [for (final n in [63, 64, 65, 108, 109]) LocalFile(ep(n), 1 << 30, DateTime(2026))];
late List<String> scanned;
late LocalNextFinder finder;
late ShowMatcher matcher;
setUp(() {
scanned = [];
matcher = ShowMatcher(store: MemoryLibraryStore(), aniList: FakeAniList(), delay: (_) async {});
finder = LocalNextFinder(
matcher: matcher,
scan: (d) async {
scanned.add(d);
return FolderScan(videos: files, hiddenCount: 0, hiddenExamples: const [], subtitles: const {}, truncated: false);
});
});
test('from its own folder when the library has not seen it this run', () async {
final next = await finder.nextAfter(ep(64));
expect(next!.episode.path, ep(65));
expect(next.skipped, isNull);
expect(scanned, [dir]);
});
test('numbers skipped on the way are said', () async {
final next = await finder.nextAfter(ep(65));
expect(next!.episode.path, ep(108));
expect(next.skipped!.label, '66–107');
});
test('the last one, or not an episode: nothing continues it', () async {
expect(await finder.nextAfter(ep(109)), isNull);
expect(await finder.nextAfter('$dir/notes.mkv'), isNull);
});
test('the library the Files page saw is used without a scan', () async {
matcher.remember(groupLocal(files, roots: const ['/lib']));
expect((await finder.nextAfter(ep(63)))!.episode.path, ep(64));
expect(scanned, isEmpty);
});
});
}
+8 -1
View File
@@ -14,6 +14,7 @@ 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';
import 'package:starsync/src/state/settings.dart';
// 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
@@ -244,8 +245,14 @@ void main() {
final hosts = <SessionCoordinator, MediaKitHost>{};
SessionCoordinator coord(JellyfinAccounts acc) {
final host = MediaKitHost()..init();
// Up next alone: no continuation of the episodes (it would be
// queued after the items this test adds and times).
final c = SessionCoordinator(
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp, players: host)
jellyfin: acc,
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
ytDlp: ytDlp,
players: host,
settings: AppSettings()..autoAdvance.value = false)
..media.preloadEnabled = preload;
hosts[c] = host;
return c;
+1 -1
View File
@@ -7,7 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
/// (`AppGraph`, widgets through `AppScope`); this list may only shrink.
/// Lower a number when a use goes; never raise one or add a class.
const baseline = {
'AppSettings': 55,
'AppSettings': 49,
'JellyfinAccounts': 10,
'UpdateController': 8,
'Profile': 8,
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/watch/watch_history.dart';
import 'package:starsync/src/watch/watch_tracker.dart';
// Watch tracking (D3): one watch per media of the server, time In from the
// presence the server reports, the device history for files and links,
// every closed watch out on `records` (the seam for AniList later).
final _t0 = DateTime.utc(2026, 1, 1);
void main() {
late WatchTracker tracker;
late List<WatchRecord> records;
setUp(() {
tracker = WatchTracker(history: WatchHistory(persist: false));
records = [];
tracker.records.listen(records.add);
});
tearDown(() => tracker.dispose());
const episode = {'name': 'Bleach - 064.mkv', 'size': 100, 'duration': 1440, 'ref': {'kind': 'episode', 'title': 'Bleach', 'absolute': 64}};
/// [secs] seconds of media [mediaId] from wall second [t], playing,
/// with self [present] or not.
int run(int mediaId, Map<String, Object?> media, int t, int secs,
{bool present = true, bool playing = true, String? ownFile, double from = 0}) {
for (var i = 0; i <= secs; i++) {
tracker.sample(WatchSample(
mediaId: mediaId,
media: media,
ownFile: ownFile,
position: from + i,
playing: playing,
present: present,
duration: 1440,
at: _t0.add(Duration(seconds: t + i)),
));
}
return t + secs + 1;
}
test('a file watched through goes into the history as watched; the next media closes the watch', () async {
const path = '/lib/Bleach/Bleach - 064.mkv';
final t = run(2, episode, 0, 1200, ownFile: path);
run(3, const {'name': 'next', 'size': 1}, t, 1);
await Future<void>.delayed(Duration.zero);
expect(records.first.key, WatchKey.file(path));
expect(records.first.verdict.outcome, WatchOutcome.played);
expect(records.first.ref?.absolute, 64);
final e = tracker.history.entry(WatchKey.file(path))!;
expect((e.played, e.resumeAt, e.title), (true, 0.0, 'Bleach - 064.mkv'));
expect(tracker.mediaId, 3);
});
test('sat out for most of it: not watched (P7), a resume point instead', () async {
const url = 'https://example.com/v.mp4';
final media = {'name': 'v', 'size': 0, 'duration': 1440, 'url': url};
var t = run(2, media, 0, 300);
t = run(2, media, t, 1100, present: false, from: 300);
tracker.close(at: _t0.add(Duration(seconds: t)));
await Future<void>.delayed(Duration.zero);
expect(records.single.verdict.outcome, WatchOutcome.partial);
expect(records.single.timeIn, closeTo(300, 2));
final e = tracker.history.entry(WatchKey.link(url))!;
expect(e.played, isFalse);
expect(e.resumeAt, closeTo(300, 1), reason: 'where this viewer was when it sat out');
});
test('a peek changes nothing; a played item stays played', () async {
const path = '/lib/x.mkv';
run(2, episode, 0, 1200, ownFile: path);
tracker.close();
expect(tracker.history.entry(WatchKey.file(path))!.played, isTrue);
run(4, episode, 0, 20, ownFile: path);
expect(tracker.session!.playedBefore, isTrue, reason: 'the history says it was watched');
tracker.close();
expect(tracker.history.entry(WatchKey.file(path))!.played, isTrue);
final other = WatchKey.of(const {'name': 'peeked', 'size': 5})!;
run(5, const {'name': 'peeked', 'size': 5, 'duration': 1440}, 0, 20);
tracker.close();
expect(tracker.history.entry(other), isNull);
});
test('Jellyfin: the watch is shared with its reports, not kept in the device history', () async {
final media = {
'name': 'ep',
'size': 0,
'duration': 1440,
'jellyfin': {'serverId': 'S', 'itemId': 'i'},
};
run(2, media, 0, 10);
tracker.before(played: true, resumeAt: 0);
expect(tracker.verdict!.outcome, WatchOutcome.keepPlayed);
tracker.close();
await Future<void>.delayed(Duration.zero);
expect(records.single.key.isJellyfin, isTrue);
expect(tracker.history.entries, isEmpty);
});
test('keys: own file, Jellyfin, own or shared link, else name and size', () {
expect(WatchKey.of(const {'name': 'a', 'size': 1}, ownFile: '/x.mkv'), WatchKey.file('/x.mkv'));
expect(
WatchKey.of(const {
'name': 'a',
'jellyfin': {'serverId': 'S', 'itemId': 'i'}
}),
WatchKey.jellyfin(const JellyfinRef(serverId: 'S', itemId: 'i')));
expect(WatchKey.of(const {'name': 'a', 'relay': {}}, ownLink: 'https://x/y'), WatchKey.link('https://x/y'));
expect(WatchKey.of(const {'name': 'a', 'url': 'https://x/z'}), WatchKey.link('https://x/z'));
expect(WatchKey.of(const {'name': 'a', 'size': 3}), WatchKey.media('a', 3));
expect(WatchKey.of(null), isNull);
});
}
+146
View File
@@ -0,0 +1,146 @@
import 'dart:async';
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/files/scanner.dart' show scanFolder;
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/library/library_store.dart';
import 'package:starsync/src/library/local_next.dart';
import 'package:starsync/src/library/show_matcher.dart';
import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/watch/watch_history.dart';
import 'package:starsync/src/watch/watch_tracker.dart';
import 'show_matcher_test.dart' show FakeAniList;
// What's next with real coordinators, real mpv and a dedicated server: a
// file of a local show put up by a member is continued by the show's next
// episode (numbers skipped on the way said), the server's countdown runs
// on every screen, the member who has the file puts it up, a cancel is
// shared, and what was watched goes into the device's watch history.
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
final _show = '$_tmp/whats_next_e2e/Show';
/// `Show - NN.mp4`, 6 s each, with audio (headless mpv needs it) and the
/// index first.
Future<String> _episode(int n) async {
final path = '$_show/Show - ${n.toString().padLeft(2, '0')}.mp4';
if (await File(path).exists()) return path;
await Directory(_show).create(recursive: true);
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24',
'-f', 'lavfi', '-i', 'sine=frequency=${300 + 100 * n}:sample_rate=48000',
'-t', '6', '-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)}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(const Duration(milliseconds: 200));
}
fail('timed out waiting for $what');
}
void main() {
late SyncServer server;
late List<SessionCoordinator> all;
late List<Object> serverErrors;
Future<SessionCoordinator> member(String name, {Future<LocalNext?> Function(String)? nextFile}) async {
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
await acc.ensureLoaded(validate: false);
final c = SessionCoordinator(
jellyfin: acc,
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
watch: WatchTracker(history: WatchHistory(persist: false)))
..nextFile = nextFile;
all.add(c);
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name));
await c.playback.player.setVolume(0);
return c;
}
setUp(() async {
MediaKit.ensureInitialized();
all = [];
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'vps'));
// A relay read cut short at a swap or the stop is reported to the
// zone the server started in (DEVELOPMENT "Traps").
serverErrors = [];
await runZonedGuarded(server.start, (e, _) {
if ('$e'.contains('Content size below specified contentLength') || '$e'.contains('did not arrive')) return;
serverErrors.add(e);
});
});
tearDown(() async {
for (final x in all) {
await x.dispose();
}
await server.stop();
expect(serverErrors, isEmpty, reason: 'server errors');
});
test('a file of a show continues with its next episode; the countdown and a cancel are shared',
timeout: const Timeout(Duration(minutes: 3)), () async {
final ep1 = await _episode(1);
for (final n in [3, 4]) {
await _episode(n);
}
final matcher = ShowMatcher(store: MemoryLibraryStore(), aniList: FakeAniList(), delay: (_) async {});
final alice = await member('alice', nextFile: LocalNextFinder(matcher: matcher, scan: scanFolder).nextAfter);
final bob = await member('bob');
expect(await alice.swapPicked(FileMedia(ep1)), isNull);
await _poll(() => bob.queue.continues.peek() != null, 'the continuation, for everyone');
final next = bob.queue.continues.peek()!;
expect((next.how, next.by, next.auto), (QueueHow.file, alice.connection.memberId, true));
expect(next.media.name, 'Show - 03.mp4');
expect(next.sub, contains('skips 2'));
expect(bob.queue.items.peek(), isEmpty, reason: 'it is not an Up next item of its own');
// Both ready: it plays to its end; the countdown is on both screens.
await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 5), 'both opened episode 1');
final first = alice.serverState.peek()!.mediaId;
for (final x in [alice, bob]) {
x.toggleReady();
}
await _poll(() => alice.serverState.peek()?.transport == 'finished', 'episode 1 ended');
await _poll(() => [alice, bob].every((x) => (x.queue.countdown.peek()?.secondsLeft ?? 0) > 0), 'the countdown');
expect(bob.queue.countdown.peek()!.item.id, next.id);
await _poll(() => (bob.serverState.peek()?.mediaId ?? 0) > first, 'episode 3 up', timeout: const Duration(seconds: 20));
expect(bob.serverState.peek()!.media!['name'], 'Show - 03.mp4');
expect(bob.serverState.peek()!.media!['swapBy'], alice.connection.memberId, reason: 'who has the file put it up');
// Watched through: in the device history of both (alice's by its path,
// bob's by its name and size), recorded as the next one came up.
await _poll(() => alice.watch.history.entry(WatchKey.file(ep1))?.played ?? false, "alice's history");
await _poll(() => bob.watch.history.entries.any((e) => e.$2.played), "bob's history");
// Episode 3 continues with 4; at its end bob cancels: for everyone.
await _poll(() => alice.queue.continues.peek()?.media.name == 'Show - 04.mp4', 'episode 4 next');
await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 5 && x.playback.player.state.playing),
'both play episode 3', timeout: const Duration(seconds: 20));
final third = alice.serverState.peek()!.mediaId;
await _poll(() => (alice.queue.countdown.peek()?.secondsLeft ?? 0) > 0, 'the countdown again');
bob.queue.cancelCountdown();
await _poll(() => alice.queue.countdown.peek()?.secondsLeft == null && alice.queue.continues.peek() == null,
'cancelled for alice too');
await Future.delayed(const Duration(seconds: 12));
expect(alice.serverState.peek()!.mediaId, third, reason: 'nothing came up');
expect(alice.serverState.peek()!.transport, 'finished');
});
}