fix(player): settle the watched patch the backend recorded itself
Watching an episode to the end left it stuck as watched for the rest of the session. Unmarking it on another device and refreshing did nothing; only a restart cleared it. Unlike #1829 this needs no second device to cause -- a normal watch-through is enough, and the second device only makes it visible. A threshold crossing writes an unacknowledged overlay patch, deliberately: reporting success proves the backend received the report, not that it classified the item as played, so the patch stays owed until something settles it. _settleServerMark has three settled outcomes and only one of them did. The explicit-mark branch promoted; the two branches that skip the mark because the backend already recorded the watch itself -- Jellyfin from /Sessions/Playing/Stopped, Plex from a timeline crossing past LibraryVideoPlayedThreshold -- returned without promoting. Those are the common paths, so nearly every completed playback stranded a patch that the store then refused to suppress, because an unacknowledged entry is never retired by an authoritative read. Both now promote, through one idempotent helper that clears the id so the delivery callback and the settle paths cannot promote twice. Promotion has to follow delivery rather than the settle decision. A marks-on-stop backend settles when the crossing latches, which happens before the stop is sent, and until that stop lands the watch really is still owed -- promoting there would let a refresh retire a patch the server had never heard about. MediaBrowser also drops a stop for a session it never opened, in which case the watch it would have recorded never happens at all. So the stop path promotes only once the report reached a session able to act on it, which is the same condition that already governs whether the stop persists its position; that condition is now named rather than recomputed, and reset with its siblings when a session re-arms. The crossing branch needs no such gate: it is assembled from two delivered reports, so delivery is already proven. Verified against a live Jellyfin server driving the real client and tracker: before, the server reported the item unwatched after a second device cleared it while the overlay still rendered watched; after, the overlay follows the server. The optimistic mark still appears immediately during playback -- it now yields to a later authoritative read instead of outliving one. The #1287 and #1740 contracts are unchanged: neither branch issues an explicit mark, and the tests assert that alongside the promotion.
This commit is contained in:
@@ -1855,6 +1855,178 @@ void main() {
|
||||
expect(tracker.dispose, returnsNormally);
|
||||
});
|
||||
});
|
||||
|
||||
// A report-derived crossing writes an unacknowledged overlay patch, which the
|
||||
// store never suppresses. Unless the settled server-side watch promotes it,
|
||||
// it pins `watched` for the rest of the session and no refresh can clear it.
|
||||
group('watched-patch promotion', () {
|
||||
List<WatchPatchId> listenForPromotions() {
|
||||
final seen = <WatchPatchId>[];
|
||||
final sub = WatchPatchPromotionNotifier().stream.listen((p) => seen.add(p.patchId));
|
||||
addTearDown(sub.cancel);
|
||||
return seen;
|
||||
}
|
||||
|
||||
/// Watched events prove the crossing actually latched and created a patch,
|
||||
/// so a "did not promote" assertion cannot pass vacuously.
|
||||
List<WatchStateEvent> listenForWatchedEvents() {
|
||||
final seen = <WatchStateEvent>[];
|
||||
final sub = WatchStateNotifier().stream.listen((e) {
|
||||
if (e.changeType == WatchStateChangeType.watched) seen.add(e);
|
||||
});
|
||||
addTearDown(sub.cancel);
|
||||
return seen;
|
||||
}
|
||||
|
||||
test('a delivered stop promotes the crossing on a backend that marks on stop', () async {
|
||||
final promotions = listenForPromotions();
|
||||
final client = _StopMarksWatchedClient();
|
||||
final player = _FakePlayer(position: const Duration(seconds: 95), duration: const Duration(seconds: 100));
|
||||
final tracker = PlaybackProgressTracker(client: client, metadata: _meta(), player: player, isOffline: false);
|
||||
addTearDown(tracker.dispose);
|
||||
|
||||
await tracker.sendProgress('stopped');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(promotions, hasLength(1));
|
||||
// The #1287 contract is unchanged: still no explicit mark.
|
||||
expect(client.markWatchedCalls, isEmpty);
|
||||
});
|
||||
|
||||
test('a MediaBrowser stop for a session that never opened does not promote', () async {
|
||||
// Jellyfin drops a stop for a session it never opened, so the watch it
|
||||
// would have recorded never happened and the patch is still owed.
|
||||
final promotions = listenForPromotions();
|
||||
final watched = listenForWatchedEvents();
|
||||
final client = _StopMarksWatchedClient();
|
||||
final player = _FakePlayer(position: const Duration(seconds: 95), duration: const Duration(seconds: 100));
|
||||
final tracker = PlaybackProgressTracker(
|
||||
client: client,
|
||||
metadata: testMediaItem(
|
||||
id: '42',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.movie,
|
||||
serverId: ServerId('srv'),
|
||||
),
|
||||
player: player,
|
||||
isOffline: false,
|
||||
);
|
||||
addTearDown(tracker.dispose);
|
||||
|
||||
await tracker.sendProgress('stopped');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
// The crossing latched locally, so there is a patch to leave owed.
|
||||
expect(watched, hasLength(1));
|
||||
expect(promotions, isEmpty);
|
||||
});
|
||||
|
||||
test('a MediaBrowser stop into an opened session promotes', () async {
|
||||
final promotions = listenForPromotions();
|
||||
final client = _StopMarksWatchedClient();
|
||||
final player = _FakePlayer(position: const Duration(seconds: 10), duration: const Duration(seconds: 100));
|
||||
final tracker = PlaybackProgressTracker(
|
||||
client: client,
|
||||
metadata: testMediaItem(
|
||||
id: '42',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.movie,
|
||||
serverId: ServerId('srv'),
|
||||
),
|
||||
player: player,
|
||||
isOffline: false,
|
||||
);
|
||||
addTearDown(tracker.dispose);
|
||||
|
||||
await tracker.sendProgress('playing');
|
||||
player.position = const Duration(seconds: 95);
|
||||
await tracker.sendProgress('stopped');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(promotions, hasLength(1));
|
||||
});
|
||||
|
||||
test('a server-observed crossing promotes without an explicit mark (#1740)', () async {
|
||||
final promotions = listenForPromotions();
|
||||
final client = _FakePlexClient(thresholdPercent: 90);
|
||||
final player = _FakePlayer(position: const Duration(seconds: 10), duration: const Duration(seconds: 100));
|
||||
final tracker = PlaybackProgressTracker(client: client, metadata: _meta(), player: player, isOffline: false);
|
||||
addTearDown(tracker.dispose);
|
||||
|
||||
// Below then above, both delivered: the backend saw the crossing itself.
|
||||
// The playing report is dispatched fire-and-forget, so let it land before
|
||||
// the stop that completes the crossing.
|
||||
await tracker.sendProgress('playing');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
player.position = const Duration(seconds: 95);
|
||||
await tracker.sendProgress('stopped');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(promotions, hasLength(1));
|
||||
expect(client.markWatchedCalls, isEmpty);
|
||||
});
|
||||
|
||||
test('the explicit-mark path promotes exactly once', () async {
|
||||
final promotions = listenForPromotions();
|
||||
final client = _FakePlexClient(thresholdPercent: 90);
|
||||
final player = _FakePlayer(position: const Duration(seconds: 95), duration: const Duration(seconds: 100));
|
||||
final tracker = PlaybackProgressTracker(client: client, metadata: _meta(), player: player, isOffline: false);
|
||||
addTearDown(tracker.dispose);
|
||||
|
||||
// No observable crossing, so this session takes the explicit mark.
|
||||
await tracker.sendProgress('stopped');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(client.markWatchedCalls, ['42']);
|
||||
expect(promotions, hasLength(1));
|
||||
});
|
||||
|
||||
test('a failed explicit mark leaves the patch owed', () async {
|
||||
final promotions = listenForPromotions();
|
||||
final watched = listenForWatchedEvents();
|
||||
final client = _ScrobblePreciseClient(thresholdPercent: 90, failScrobbleFirstTime: true);
|
||||
final player = _FakePlayer(position: const Duration(seconds: 95), duration: const Duration(seconds: 100));
|
||||
final tracker = PlaybackProgressTracker(client: client, metadata: _meta(), player: player, isOffline: false);
|
||||
addTearDown(tracker.dispose);
|
||||
|
||||
await tracker.sendProgress('stopped');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
// The crossing latched locally, so there is a patch to leave owed.
|
||||
expect(watched, hasLength(1));
|
||||
// The write never landed, so nothing may retire the local patch.
|
||||
expect(promotions, isEmpty);
|
||||
});
|
||||
|
||||
test('a re-armed session does not promote off the previous stop', () async {
|
||||
final promotions = listenForPromotions();
|
||||
final client = _StopMarksWatchedClient();
|
||||
final player = _FakePlayer(position: const Duration(seconds: 10), duration: const Duration(seconds: 100));
|
||||
final tracker = PlaybackProgressTracker(
|
||||
client: client,
|
||||
metadata: testMediaItem(
|
||||
id: '42',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.movie,
|
||||
serverId: ServerId('srv'),
|
||||
),
|
||||
player: player,
|
||||
isOffline: false,
|
||||
);
|
||||
addTearDown(tracker.dispose);
|
||||
|
||||
await tracker.sendProgress('playing');
|
||||
player.position = const Duration(seconds: 95);
|
||||
await tracker.sendProgress('stopped');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(promotions, hasLength(1));
|
||||
|
||||
// A new server-side session must earn its own delivered stop.
|
||||
tracker.resumeAfterStoppedReport();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(promotions, hasLength(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A more precise fake than [_FakePlexClient]: lets the test independently
|
||||
|
||||
Reference in New Issue
Block a user