fix(continue-watching): clear the resume position when an item is marked watched
Marking a movie or episode watched left it sitting in Continue Watching with a checkmark, and the only way to shift it was to play it and skip to the end. Continue Watching membership on a MediaBrowser server is derived from UserData.PlaybackPositionTicks alone; Played is never consulted. Marking played normally zeroes that position as a side effect, so the row usually disappears and nothing ever checked that it had. When something writes a position back afterwards the item is left played *and* resumable, which the resume route happily keeps returning forever. markWatched now reads the UserItemDataDto the mark already returns and clears the bookmark itself when the server left one behind, so the postcondition holds however the item got into that state. The follow-up write costs a request only when the invariant is actually broken. The writer putting items there is our own offline queue. insertWatchAction already drops queued progress for an item when the mark is itself queued, but the online mark writes straight to the server and queues nothing, so a progress row recorded earlier survived and replayed afterwards — pending actions go out oldest first — restoring the very position the mark had cleared. The sync service now listens for watch-state events and discards queued progress for that item as the mark lands. Progress recorded after a mark is a rewatch and is queued later, so it is untouched. Plex never showed this because it forwards the recorded-at timestamp and lets the server discard a stale replay; the MediaBrowser stop report has nowhere to put one. Continue Watching also drops the row locally now instead of waiting a round trip for the refetch to confirm it, matching what removal events already did, and marking a season or show takes its on-deck episode with it. Watched items are deliberately still not filtered out of the shelf: Jellyfin keeps Played set when new progress arrives, so a rewatch in progress is indistinguishable from a stuck row, and filtering would hide it. close #1812
This commit is contained in:
@@ -415,6 +415,66 @@ void main() {
|
||||
expect(aggregation.hubCalls, hubCallsBefore);
|
||||
});
|
||||
|
||||
test('watched event drops the row immediately, before the refetch answers', () async {
|
||||
aggregation.onDeckResult = () => [_item('ep-1'), _item('ep-2')];
|
||||
await provider.load();
|
||||
|
||||
// A finished item has no business on the shelf, so the row must go now
|
||||
// rather than a round trip later — and it must not come back if the
|
||||
// backend is still returning it (#1812).
|
||||
var sawImmediateRemoval = false;
|
||||
provider.addListener(() {
|
||||
if (provider.onDeck.length == 1 && provider.onDeck.single.id == 'ep-2') {
|
||||
sawImmediateRemoval = true;
|
||||
}
|
||||
});
|
||||
aggregation.onDeckResult = () => [_item('ep-2')];
|
||||
|
||||
WatchStateNotifier().notifyWatched(item: _item('ep-1'));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(sawImmediateRemoval, isTrue);
|
||||
expect(provider.onDeck.map((i) => i.id), ['ep-2']);
|
||||
});
|
||||
|
||||
test('marking a show watched drops its on-deck episode', () async {
|
||||
aggregation.onDeckResult = () => [_item('ep-1', parentId: 'season-1', grandparentId: 'show-1'), _item('ep-2')];
|
||||
await provider.load();
|
||||
aggregation.onDeckResult = () => [_item('ep-2')];
|
||||
|
||||
WatchStateNotifier().notifyWatched(item: _item('show-1', kind: MediaKind.show));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(provider.onDeck.map((i) => i.id), ['ep-2']);
|
||||
});
|
||||
|
||||
test('threshold-crossing progress drops the row too', () async {
|
||||
aggregation.onDeckResult = () => [_item('ep-1'), _item('ep-2')];
|
||||
await provider.load();
|
||||
aggregation.onDeckResult = () => [_item('ep-2')];
|
||||
|
||||
WatchStateNotifier().notifyProgress(item: _item('ep-1'), viewOffset: 95000, duration: 100000);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(provider.onDeck.map((i) => i.id), ['ep-2']);
|
||||
});
|
||||
|
||||
test('unwatched event evicts nothing', () async {
|
||||
aggregation.onDeckResult = () => [_item('ep-1'), _item('ep-2')];
|
||||
await provider.load();
|
||||
|
||||
var sawShorterList = false;
|
||||
provider.addListener(() {
|
||||
if (provider.onDeck.length < 2) sawShorterList = true;
|
||||
});
|
||||
|
||||
WatchStateNotifier().notifyWatched(item: _item('ep-1'), isNowWatched: false);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(sawShorterList, isFalse);
|
||||
expect(provider.onDeck.map((i) => i.id), ['ep-1', 'ep-2']);
|
||||
});
|
||||
|
||||
test('deletion drops the item from on-deck and hubs, then refreshes continue watching only', () async {
|
||||
aggregation.onDeckResult = () => [_item('ep-1'), _item('ep-2')];
|
||||
aggregation.hubsResult = () => [
|
||||
|
||||
@@ -1356,6 +1356,82 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaBrowser mark-watched clears a surviving resume position', () {
|
||||
// Continue Watching membership on this API is `PlaybackPositionTicks > 0`
|
||||
// alone, so a played item that keeps a position is pinned to the shelf
|
||||
// forever. markWatched must assert that postcondition, not assume it
|
||||
// (#1812).
|
||||
http.Response Function(http.Request) respondWithPosition(int ticks) {
|
||||
return (request) {
|
||||
if (request.url.path.endsWith('/UserData')) return http.Response('', 204);
|
||||
return jsonResponse({'ItemId': 'item-1', 'Played': true, 'PlaybackPositionTicks': ticks});
|
||||
};
|
||||
}
|
||||
|
||||
test('Jellyfin clears it through the unprefixed user-data route', () async {
|
||||
final requests = _RequestCapture(respondWithPosition(12000000000));
|
||||
final client = testJellyfinClient(handler: requests.handle);
|
||||
addTearDown(client.close);
|
||||
|
||||
await client.markWatched(_item(MediaBackend.jellyfin));
|
||||
|
||||
expect(requests.log, [
|
||||
'POST /UserPlayedItems/item-1?userId=user-1',
|
||||
'POST /UserItems/item-1/UserData?userId=user-1',
|
||||
]);
|
||||
expect(jsonDecode(requests.requests.last.body), {'PlaybackPositionTicks': 0});
|
||||
});
|
||||
|
||||
test('Emby clears it through the user-scoped user-data route', () async {
|
||||
final requests = _RequestCapture(respondWithPosition(12000000000));
|
||||
final client = testEmbyClient(handler: requests.handle);
|
||||
addTearDown(client.close);
|
||||
|
||||
await client.markWatched(_item(MediaBackend.emby));
|
||||
|
||||
expect(requests.log, [
|
||||
'POST /Users/user-1/PlayedItems/item-1?userId=user-1',
|
||||
'POST /Users/user-1/Items/item-1/UserData?userId=user-1',
|
||||
]);
|
||||
expect(jsonDecode(requests.requests.last.body), {'PlaybackPositionTicks': 0});
|
||||
});
|
||||
|
||||
test('a mark that already zeroed the position issues no follow-up write', () async {
|
||||
// The normal case. A second request on every mark would double the cost
|
||||
// of the app's most common watch-state write.
|
||||
final requests = _RequestCapture(respondWithPosition(0));
|
||||
final client = testJellyfinClient(handler: requests.handle);
|
||||
addTearDown(client.close);
|
||||
|
||||
await client.markWatched(_item(MediaBackend.jellyfin));
|
||||
|
||||
expect(requests.log, ['POST /UserPlayedItems/item-1?userId=user-1']);
|
||||
});
|
||||
|
||||
test('a body without UserData is left alone', () async {
|
||||
// Older servers answer the played toggle with 204 and no DTO; there is
|
||||
// nothing to assert against, so do not guess.
|
||||
final requests = _RequestCapture((_) => http.Response('', 204));
|
||||
final client = testJellyfinClient(handler: requests.handle);
|
||||
addTearDown(client.close);
|
||||
|
||||
await client.markWatched(_item(MediaBackend.jellyfin));
|
||||
|
||||
expect(requests.log, ['POST /UserPlayedItems/item-1?userId=user-1']);
|
||||
});
|
||||
|
||||
test('a failed clear surfaces as MediaServerHttpException', () async {
|
||||
final requests = _RequestCapture((request) {
|
||||
if (request.url.path.endsWith('/UserData')) return http.Response('nope', 500);
|
||||
return jsonResponse({'ItemId': 'item-1', 'Played': true, 'PlaybackPositionTicks': 12000000000});
|
||||
});
|
||||
final client = testJellyfinClient(handler: requests.handle);
|
||||
addTearDown(client.close);
|
||||
|
||||
await expectLater(client.markWatched(_item(MediaBackend.jellyfin)), throwsA(isA<MediaServerHttpException>()));
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaBrowser name-pair item fields', () {
|
||||
test('Emby item tags and genres survive the browse path via the name-pair arrays', () async {
|
||||
// Real Emby DTO shape: the plain arrays are absent, the name-pair
|
||||
|
||||
@@ -604,6 +604,111 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Superseded queued progress (#1812)
|
||||
// ============================================================
|
||||
|
||||
group('queued progress superseded by a watch-state write', () {
|
||||
MediaItem itemFor(String id) =>
|
||||
testMediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv');
|
||||
|
||||
// The online mark writes straight to the server and queues nothing, so
|
||||
// nothing purges the queue the way insertWatchAction does for the offline
|
||||
// mark. Replaying the stale row afterwards rewrites the resume position the
|
||||
// mark cleared, which pins the item to Continue Watching on MediaBrowser.
|
||||
test('a watched event drops the queued progress row for that item', () async {
|
||||
final (svc: svc, db: db, mgr: mgr) = _makeService();
|
||||
addTearDown(() async {
|
||||
svc.dispose();
|
||||
mgr.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 50000, duration: 100000);
|
||||
expect(await svc.getPendingSyncCount(), 1);
|
||||
|
||||
WatchStateNotifier().notifyWatched(item: itemFor('42'));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(await svc.getPendingSyncCount(), 0);
|
||||
expect(await db.getLatestWatchAction('srv:42'), isNull);
|
||||
});
|
||||
|
||||
test('an unwatched event drops it too', () async {
|
||||
final (svc: svc, db: db, mgr: mgr) = _makeService();
|
||||
addTearDown(() async {
|
||||
svc.dispose();
|
||||
mgr.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 50000, duration: 100000);
|
||||
|
||||
WatchStateNotifier().notifyWatched(item: itemFor('42'), isNowWatched: false);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(await svc.getPendingSyncCount(), 0);
|
||||
});
|
||||
|
||||
test('other items and queued manual marks are left alone', () async {
|
||||
final (svc: svc, db: db, mgr: mgr) = _makeService();
|
||||
addTearDown(() async {
|
||||
svc.dispose();
|
||||
mgr.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 50000, duration: 100000);
|
||||
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '43', viewOffset: 50000, duration: 100000);
|
||||
await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '44');
|
||||
|
||||
WatchStateNotifier().notifyWatched(item: itemFor('42'));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(await db.getLatestWatchAction('srv:42'), isNull);
|
||||
expect((await db.getLatestWatchAction('srv:43'))?.actionType, 'progress');
|
||||
expect((await db.getLatestWatchAction('srv:44'))?.actionType, 'watched');
|
||||
});
|
||||
|
||||
test('progress recorded after the mark survives — that is a rewatch', () async {
|
||||
final (svc: svc, db: db, mgr: mgr) = _makeService();
|
||||
addTearDown(() async {
|
||||
svc.dispose();
|
||||
mgr.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
WatchStateNotifier().notifyWatched(item: itemFor('42'));
|
||||
await pumpEventQueue();
|
||||
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 5000, duration: 100000);
|
||||
|
||||
expect((await db.getLatestWatchAction('srv:42'))?.viewOffset, 5000);
|
||||
});
|
||||
|
||||
test('a superseded row never reaches the server on the next sync', () async {
|
||||
final (svc: svc, db: db, mgr: mgr) = _makeService();
|
||||
addTearDown(() async {
|
||||
svc.dispose();
|
||||
mgr.dispose();
|
||||
await db.close();
|
||||
});
|
||||
svc.setActiveProfileId('p1');
|
||||
|
||||
final client = _RecordingMediaClient(serverId: ServerId('srv'), backend: MediaBackend.jellyfin);
|
||||
mgr.debugRegisterClientForTesting(client);
|
||||
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 50000, duration: 100000);
|
||||
|
||||
WatchStateNotifier().notifyWatched(item: itemFor('42'));
|
||||
await pumpEventQueue();
|
||||
await svc.syncPendingItems();
|
||||
|
||||
// The whole point: no position write, so the resume bookmark the mark
|
||||
// cleared stays cleared.
|
||||
expect(client.stopped, isEmpty);
|
||||
expect(client.started, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// getLocalWatchStatus
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user