Restructure step 6: what's next and watch tracking

- 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

+1 -1
View File
@@ -316,7 +316,7 @@ void main() {
expect(r.copyWith(sourceGone: false).toJson().containsKey('sourceGoneSince'), isFalse);
});
test('state carries hold and the reserved advance', () {
test('state carries hold and advance', () {
final at = DateTime.utc(2026);
final s = StateBroadcast(
eventId: 1,
+129
View File
@@ -70,6 +70,7 @@ void main() {
});
test('skip asks for the first item; a play request times out', () {
engine.applySwap('a', const HelloMedia(name: 'up', size: 0, duration: 0, url: 'https://x/up'));
expect(engine.applyQueuePlay('a', ''), isFalse, reason: 'nothing queued');
engine.applyQueueAdd('a', link('one'));
expect(engine.applyQueuePlay('a', ''), isTrue);
@@ -171,4 +172,132 @@ void main() {
expect(engine.media!.name, 'Ep02');
});
});
group("what's next (the server's countdown)", () {
QueueItem file(String title, {String key = 'abcdefgh12'}) => QueueItem(
media: HelloMedia(name: title, size: 3, duration: 0), how: QueueHow.file, title: title, key: key);
// Both keep talking (pings), so nobody goes silent.
void after(int secs) {
t = t.add(Duration(seconds: secs));
engine
..touch('a')
..touch('b')
..tick();
}
/// Ep01 (20 s) up by alice, both ready and buffered, played to its end.
void finish() {
engine.applySwap('a', const HelloMedia(name: 'Ep01', size: 0, duration: 20, url: 'https://example.com/1'));
for (final tok in ['a', 'b']) {
engine.applyReady(tok, true);
engine.applyReport(tok, 0, 100, engine.eventId, mediaId: engine.mediaId);
}
expect(engine.transport, Transport.playing);
after(21);
expect(engine.transport, Transport.finished);
}
test('nothing up: the first item is asked for at once', () {
engine.applyQueueAdd('b', link('one'));
expect(engine.queue.play, idOf('one'));
expect(engine.advance, isNull);
});
test('at the end with an item first: 10 s for everyone, then queuePlay', () {
finish();
expect(engine.advance, isNull, reason: 'nothing queued, nothing counts down');
engine.applyQueueAdd('b', link('two'));
final a = engine.advance!;
expect([a.id, a.secs], [idOf('two'), RelayConstants.advanceSecs]);
expect(a.at, t);
expect(engine.broadcast().advance!.id, a.id, reason: 'in every state');
expect(advanceSecondsLeft(a, t.add(const Duration(milliseconds: 2500))), 8);
after(5);
expect(engine.queue.play, isNull);
// A new first item takes the countdown over, its end unchanged.
engine.applyQueueAdd('a', link('first'), next: true);
expect([engine.advance!.id, engine.advance!.at], [idOf('first'), a.at]);
after(5);
expect(engine.queue.play, idOf('first'));
expect(engine.advance, isNull, reason: 'over once it asked');
after(1);
expect(engine.advance, isNull, reason: 'not counted down again while the request waits');
});
test('a seek stops it; cancel stops it for this media and drops the continuation', () {
engine.applyQueueAdd('b', link('two'));
finish();
expect(engine.advance, isNotNull);
engine.applySeek('b', 5);
expect(engine.advance, isNull);
expect(engine.transport, isNot(Transport.finished));
// Played to the end again: a fresh countdown.
after(16);
expect(engine.transport, Transport.finished);
expect(engine.advance, isNotNull);
engine.applyQueueAdd('a', link('Ep02').copyWith(auto: true, forMedia: engine.mediaId));
engine.feed.drain();
expect(engine.applyQueueCancel('b'), isTrue);
expect(engine.advance, isNull);
expect(titles(), ['two']);
expect(engine.feed.drain().single.action, 'advance-cancel');
after(30);
expect((engine.advance, engine.queue.play), (null, null), reason: 'cancelled for this media');
});
test("holds while its performer is gone, plays once they're back", () {
engine.applyQueueAdd('b', file('bobs'));
finish();
engine.applyGone('b');
after(12);
expect(engine.queue.play, isNull, reason: 'held: only bob has the file');
expect(engine.advance, isNotNull);
engine.applyTakeover('b', seq: 1);
after(1);
expect(engine.queue.play, idOf('bobs'));
});
test("holds while the item's relay source is gone", () {
var sourceGone = true;
engine.itemSourceGone = (_) => sourceGone;
engine.applyQueueAdd('b', link('two'));
finish();
after(12);
expect(engine.queue.play, isNull);
sourceGone = false;
after(1);
expect(engine.queue.play, idOf('two'));
});
test('what nobody here can put up is skipped by the server', () {
engine.applyQueueAdd('b', file('bobs'));
engine.applyQueueAdd('a', link('next'));
finish();
engine.applyLeave('b', reason: 'bye');
engine.feed.drain();
after(11);
expect(titles(), ['next']);
final skip = engine.feed.drain().firstWhere((f) => f.action == 'queue-skip');
expect([skip.actorId, skip.target, skip.reason], ['server', 'bobs', 'bob left']);
expect(engine.advance!.id, idOf('next'), reason: 'the countdown goes on with the next one');
after(1);
expect(engine.queue.play, idOf('next'));
// A play request for a Jellyfin item nobody here can open: skipped too.
engine.applyQueueAdd('a', QueueItem(
media: const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i')),
how: QueueHow.jellyfin,
title: 'ep'));
engine.applyQueuePlay('a', idOf('ep'));
expect(titles().contains('ep'), isFalse);
});
test('the continuation adds no feed line', () {
engine.applySwap('a', const HelloMedia(name: 'Ep01', size: 0, duration: 0, url: 'https://example.com/1'));
engine.feed.drain();
engine.applyQueueAdd('a', link('Ep02').copyWith(auto: true, forMedia: engine.mediaId));
expect(engine.feed.drain().where((f) => f.action == 'queue-add'), isEmpty);
});
});
}
+16
View File
@@ -388,6 +388,22 @@ void main() {
group('swap', () {
const ep2 = HelloMedia(name: 'Ep02.mkv', size: 9, duration: 90.0);
test('a restore (after a restart) puts it back at its position, held; finished stays finished', () {
join('a', 'alice');
engine.applySwap('a', ep2, restore: const SwapRestore(position: 42.5, transport: 'playing'));
expect((engine.positionAt(), engine.transport), (42.5, Transport.held));
expect(engine.feed.drain().last.reason, 'restore');
engine.applyReady('a', true);
buffer('a', 4.0);
expect(engine.transport, Transport.playing, reason: 'the gate starts it again');
engine.applySwap('a', ep2, restore: const SwapRestore(position: 90, transport: 'finished'));
expect((engine.positionAt(), engine.transport), (90.0, Transport.finished));
final rt = SwapIntent.fromJson(
const SwapIntent(media: ep2, restore: SwapRestore(position: 3, transport: 'playing')).toJson());
expect((rt.restore!.position, rt.restore!.transport), (3.0, 'playing'));
expect(SwapIntent.fromJson(const SwapIntent(media: ep2).toJson()).restore, isNull);
});
test('swap bumps mediaId, holds, keeps ready, fresh members in set', () {
join('a', 'alice');
engine.applyReady('a', true);
+127 -78
View File
@@ -1,102 +1,151 @@
// restructure: rewrite in step 6 (D3: the watched rule moves from position coverage to time present)
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
/// Plays [from]..[to] at normal speed, one sample a second, starting at
/// wall second [t]; returns the wall second after.
int play(WatchSession w, int t, double from, double to) {
var pos = from;
w.sample(pos, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
while (pos < to) {
t++;
pos = pos + 1 > to ? to : pos + 1;
w.sample(pos, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
final _t0 = DateTime(2026, 1, 1);
/// One sample a second for [secs] seconds from wall second [t], the
/// position moving with the wall clock from [from] while [playing];
/// returns the wall second after.
int run(WatchSession w, int t, double from, int secs, {bool playing = true, bool present = true}) {
for (var i = 0; i <= secs; i++) {
w.sample(from + (playing ? i : 0), playing: playing, present: present, at: _t0.add(Duration(seconds: t + i)));
}
return t + 1;
return t + secs + 1;
}
void main() {
const ep = 1440.0; // 24 min
const ed = 1350.0; // the ending starts 1.5 min before the end
test('normal playback counts; seeks, skips and pauses do not', () {
final w = WatchSession();
var t = play(w, 0, 0, 60);
// A jump ahead between two samples: not watched.
w.sample(600, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
t = play(w, t + 1, 600, 630);
// Paused: the position does not move, nothing counts.
w.sample(630, playing: false, at: DateTime(2026, 1, 1).add(Duration(seconds: t + 30)));
expect(w.covered, closeTo(90, 1));
expect(w.spans, hasLength(2));
expect(w.firstPosition, 0);
expect(w.lastPosition, 630);
expect(w.furthest, 630);
group('time In', () {
test('counts the time it played while In; pauses, holds and jumps add nothing', () {
final w = WatchSession();
var t = run(w, 0, 0, 60);
// Held (the server waits for someone): the clock runs, nothing counts.
t = run(w, t, 60, 30, playing: false);
// A jump ahead: only the time actually played after it.
t = run(w, t, 600, 30);
expect(w.timeIn, closeTo(90, 2));
expect(w.furthest, 630);
expect(w.firstPosition, 0);
});
test('sat out (Away) or joining: not counted, though the media plays on (P7)', () {
final w = WatchSession();
var t = run(w, 0, 0, 120); // in for two minutes
t = run(w, t, 120, 1200, present: false); // sat out for the rest
expect(w.timeIn, closeTo(120, 2));
expect(w.verdict(runtime: ep).outcome, WatchOutcome.partial);
expect(w.furthest, closeTo(120, 1), reason: 'positions while away are not this viewer\'s');
});
test('a stall longer than the gap adds nothing', () {
final w = WatchSession();
w.sample(0, playing: true, present: true, at: _t0);
w.sample(30, playing: true, present: true, at: _t0.add(const Duration(seconds: 30)));
expect(w.timeIn, 0);
});
test('drift corrections and small seeks while watching do not cost watched time (P9)', () {
final w = WatchSession();
var pos = 0.0;
for (var i = 0; i <= 1200; i++) {
// Every 7 s the player is corrected half a second back or ahead.
if (i % 7 == 0) pos += i % 14 == 0 ? 0.5 : -0.5;
w.sample(pos, playing: true, present: true, at: _t0.add(Duration(seconds: i)));
pos += 1;
}
expect(w.timeIn, closeTo(1200, 1));
expect(w.verdict(runtime: ep).outcome, WatchOutcome.played);
});
});
test('rewatching a part counts once in coverage, twice in watched time', () {
final w = WatchSession();
var t = play(w, 0, 0, 300);
t = play(w, t, 100, 200); // went back for a moment
expect(w.covered, closeTo(300, 1));
expect(w.watched, closeTo(400, 1));
});
group('verdict (D3)', () {
test('played: 70 % of the runtime In, or the ending (or the last 15 %) reached after 40 %', () {
final full = WatchSession();
run(full, 0, 0, 1200); // 83 %
expect(full.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
test('played: 70 % covered, or the ending (or the last 15 %) reached after 40 %', () {
final full = WatchSession();
play(full, 0, 0, 1200); // 83 %
expect(full.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
// The opening and the ending skipped, stopped at the ED: Jellyfin's
// own rule (past 90 %) would count it too.
final skipped = WatchSession();
var t = run(skipped, 0, 0, 90);
t = run(skipped, t, 180, 1170); // to the ED start
expect(skipped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
expect(skipped.verdict(runtime: ep).outcome, WatchOutcome.played);
final skipped = WatchSession();
final t = play(skipped, 0, 0, 800); // 56 %, then skipped to the ED
play(skipped, t, ed, ed + 5);
expect(skipped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
// Without a known ending the last 15 % (at most 4 min) counts.
expect(skipped.verdict(runtime: ep).outcome, WatchOutcome.played);
final half = WatchSession();
t = run(half, 0, 0, 800); // 56 %, then a skip to the ED
run(half, t, ed, 5);
expect(half.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
});
// Watched with the opening skipped, stopped 3 min before the end.
final early = WatchSession();
final u = play(early, 0, 0, 90);
play(early, u, 180, ep - 180);
expect(early.verdict(runtime: ep).outcome, WatchOutcome.played);
test('skipping around or a screenshot visit never counts', () {
// Through the whole episode in 30 s jumps, 2 s at each stop.
final hops = WatchSession();
var t = 0;
for (var p = 0.0; p < ep; p += 30) {
t = run(hops, t, p, 2);
}
expect(hops.furthest, greaterThan(ed));
expect(hops.verdict(runtime: ep, endingStart: ed).watched, isFalse);
final jumped = WatchSession();
play(jumped, 0, 0, 400); // 28 %, then jumped to the end
jumped.sample(ep, playing: false, at: DateTime(2026, 1, 2));
expect(jumped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.partial);
});
final shot = WatchSession();
run(shot, 0, 700, 20);
expect(shot.verdict(runtime: ep).outcome, WatchOutcome.peek);
test('played before: stays played however little was watched', () {
final w = WatchSession(playedBefore: true);
play(w, 0, 700, 730); // checking a moment
expect(w.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.keepPlayed);
});
// A quarter watched, then a jump to the end: part way, not played.
final jumped = WatchSession();
t = run(jumped, 0, 0, 400);
run(jumped, t, ep - 5, 5);
expect(jumped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.partial);
});
test('a peek leaves nothing; part way resumes at the last position', () {
final peek = WatchSession(resumeBefore: 300);
play(peek, 0, 900, 990); // 90 s
expect(peek.verdict(runtime: ep).outcome, WatchOutcome.peek);
test('played before: stays played however little was watched', () {
final w = WatchSession(playedBefore: true);
run(w, 0, 700, 30);
expect(w.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.keepPlayed);
expect(w.verdict(runtime: ep).watched, isTrue);
});
final movie = WatchSession();
play(movie, 0, 0, 300); // 5 min of 2 h: under 5 %
expect(movie.verdict(runtime: 7200).outcome, WatchOutcome.peek);
test('a resume continues the earlier watch', () {
final resumed = WatchSession(resumeBefore: 600);
run(resumed, 0, 590, 800); // from the resume point to the end
expect(resumed.credited, closeTo(1400, 2));
expect(resumed.verdict(runtime: ep).outcome, WatchOutcome.played);
final part = WatchSession();
final t = play(part, 0, 0, 500);
part.sample(450, playing: false, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
final v = part.verdict(runtime: ep);
expect(v.outcome, WatchOutcome.partial);
expect(v.resumeAt, 450);
// Played from the start again: the earlier part is not counted twice.
final again = WatchSession(resumeBefore: 600);
run(again, 0, 0, 300);
expect(again.credited, closeTo(300, 2));
});
// A 3 min clip: half of it is not a peek.
final clip = WatchSession();
play(clip, 0, 0, 90);
expect(clip.verdict(runtime: 180).outcome, WatchOutcome.partial);
});
test('a peek leaves nothing; part way resumes at the last position', () {
final peek = WatchSession(resumeBefore: 300);
run(peek, 0, 900, 90);
expect(peek.verdict(runtime: ep).outcome, WatchOutcome.peek);
test('an unknown runtime never makes it played', () {
final w = WatchSession();
play(w, 0, 0, 1000);
expect(w.verdict(runtime: 0).outcome, WatchOutcome.partial);
final movie = WatchSession();
run(movie, 0, 0, 300); // 5 min of 2 h: under 5 %
expect(movie.verdict(runtime: 7200).outcome, WatchOutcome.peek);
final part = WatchSession();
final t = run(part, 0, 0, 500);
part.sample(450, playing: false, present: true, at: _t0.add(Duration(seconds: t)));
final v = part.verdict(runtime: ep);
expect(v.outcome, WatchOutcome.partial);
expect(v.resumeAt, 450);
// A 3 min clip: half of it is not a peek.
final clip = WatchSession();
run(clip, 0, 0, 90);
expect(clip.verdict(runtime: 180).outcome, WatchOutcome.partial);
});
test('an unknown runtime never makes it played', () {
final w = WatchSession();
run(w, 0, 0, 1000);
expect(w.verdict(runtime: 0).outcome, WatchOutcome.partial);
});
});
}
+82
View File
@@ -0,0 +1,82 @@
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
MemberView member(String id,
{Presence presence = Presence.watching, Map<String, bool> jf = const {}, String name = ''}) =>
MemberView(
id: id, name: name.isEmpty ? id : name, ready: false, inSet: true, buffered: 0, presence: presence, jf: jf);
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'),
how: how,
title: 'x',
by: by,
byName: byName,
);
void main() {
group('queuePerformer: who puts an item up (the server and every app read it the same)', () {
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', presence: Presence.away)]).id, 'm2',
reason: 'sitting out, it comes back to put its own item up');
final waits = queuePerformer(item(how), [member('m1'), member('m2', presence: Presence.gone)]);
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, left.why), (null, '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, presence: Presence.away), member('m2')])
.why,
'nobody here has that Jellyfin server');
});
test('a link everyone opens or the server fetches: the adder, else the lowest present 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', presence: Presence.gone), member('m3')]).id, 'm3');
final nobody = queuePerformer(item(how), [member('m2', presence: Presence.away)]);
expect((nobody.id, nobody.why), (null, null), reason: 'nobody around is not a skip');
}
});
test('member ids order by number: m2 before m10', () {
expect(compareMemberIds('m9', 'm10'), lessThan(0));
expect(lowestMemberId([member('m10'), member('m9')]), 'm9');
expect(lowestMemberId(const []), isNull);
});
});
test('the continuation: who put the media up, else the first member in', () {
final members = [member('m1', presence: Presence.away), member('m2'), member('m3')];
expect(mayAddContinuation('m1', 'm1', members), isTrue, reason: 'the swapper itself');
expect(mayAddContinuation('m2', 'm1', members), isTrue, reason: 'the swapper is away: the first in');
expect(mayAddContinuation('m3', 'm1', members), isFalse);
expect(mayAddContinuation('m2', 'm3', members), isFalse, reason: 'the swapper is in');
expect(mayAddContinuation('m2', null, members), isTrue, reason: 'the media the server started with');
expect(mayAddContinuation('', 'm1', members), isFalse);
});
test('seconds left of a countdown, on the server clock', () {
final at = DateTime.utc(2026, 1, 1);
final a = QueueAdvance(id: 'q1', at: at, secs: 10);
expect(advanceSecondsLeft(a, at), 10);
expect(advanceSecondsLeft(a, at.add(const Duration(milliseconds: 100))), 10);
expect(advanceSecondsLeft(a, at.add(const Duration(seconds: 9, milliseconds: 1))), 1);
expect(advanceSecondsLeft(a, at.add(const Duration(seconds: 12))), 0);
});
}