From e8df18e7b212e1e21afe6487d6bbb8a6b0ed87e6 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:29:34 +0200 Subject: [PATCH] fix(jellyfin): order continue watching + up next by recency --- lib/media/media_item.dart | 5 + lib/services/data_aggregation_service.dart | 10 +- .../jellyfin_client/parts/browse.dart | 99 +++++++++++-- test/services/jellyfin_client_urls_test.dart | 137 ++++++++++++++++++ 4 files changed, 231 insertions(+), 20 deletions(-) diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index dcf8b04d..08c0cbb7 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -364,6 +364,11 @@ sealed class MediaItem with _$MediaItem { /// `[seasonId, showId]`. For a season: `[showId]`. For a movie: `[]`. List get parentChain => [?parentId, ?grandparentId]; + /// Recency used to order the Continue Watching / On Deck shelf: when the item + /// was last watched, falling back to when it was added for never-watched rows. + /// Shared by the per-client merge and the cross-server sort so they agree. + int get recencySortKey => lastViewedAt ?? addedAt ?? 0; + /// Whether this item has started but not finished playback. bool get hasActiveProgress { if (durationMs == null || viewOffsetMs == null) return false; diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 564fb2be..4a26655d 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -84,12 +84,10 @@ class DataAggregationService { }).toList(); } - // Sort by most recently viewed, falling back to addedAt for unwatched items - filteredOnDeck.sort((a, b) { - final aTime = a.lastViewedAt ?? a.addedAt ?? 0; - final bTime = b.lastViewedAt ?? b.addedAt ?? 0; - return bTime.compareTo(aTime); // Descending (most recent first) - }); + // Sort by most recently viewed, falling back to addedAt for unwatched items. + // Same key as JellyfinClient's continue-watching merge (MediaItem.recencySortKey) + // so per-server and cross-server ordering can't drift apart. + filteredOnDeck.sort((a, b) => b.recencySortKey.compareTo(a.recencySortKey)); filteredOnDeck = await _deduplicateContinueWatching(filteredOnDeck); diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index a447682f..5af337db 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -45,6 +45,12 @@ const _queueFields = 'UserData'; /// bounded while still returning the full series queue. const _episodeQueuePageSize = 200; +/// How many recently played episodes to scan when stamping `/Shows/NextUp` +/// rows with their series' last-watched date (see [_attachSeriesLastPlayed]). +/// Mirrors [_episodeQueuePageSize]; covers far more distinct series than the +/// Next Up list ever returns, while keeping the response bounded. +const _continueWatchingSeriesLookback = 200; + const _childrenPageSize = 500; const _pagedListPageSize = 200; const _playableDescendantTypes = 'Movie,Episode'; @@ -887,7 +893,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { return _mergeContinueWatchingAndNextUp( resume: _mapItems(results.first), - nextUp: _mapItems(results[1]), + nextUp: await _attachSeriesLastPlayed(_mapItems(results[1])), limit: count, ); } @@ -1252,6 +1258,67 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { return extras; } + /// Jellyfin's `/Shows/NextUp` returns the *next* (unwatched) episode for each + /// series, so those rows have no `LastPlayedDate` of their own and a Series DTO + /// doesn't expose an aggregated one. To let the Continue Watching shelf + /// interleave Next Up with resume items by recency, stamp each Next Up episode + /// with its series' last-watched date, read from the most recently played + /// episode of that series. + Future> _attachSeriesLastPlayed(List nextUp) async { + final pendingSeriesIds = { + for (final item in nextUp) + if (item.kind == MediaKind.episode && item.lastViewedAt == null && item.grandparentId != null) + item.grandparentId!, + }; + if (pendingSeriesIds.isEmpty) return nextUp; + + // One lightweight pass over the most recently played episodes server-wide, + // ordered DatePlayed-descending so the first time we see a series is its + // newest play. We deliberately do NOT filter on the Played flag: Jellyfin's + // own NextUp ranks series by MAX(LastPlayedDate) across every episode, and an + // episode can carry a LastPlayedDate while Played==false (started but not + // finished, or later marked unwatched). Filtering to IsPlayed would miss + // those and leave such series un-dated. Null dates sort last, so the limit + // still captures the genuinely-recent episodes; a series whose last play + // falls beyond the window keeps a null date and degrades to its addedAt in + // the sort — it would rank near the bottom anyway, being least-recent. + final rawPlayed = await _safeFetchItemsArray('/Items', { + 'userId': connection.userId, + 'IncludeItemTypes': 'Episode', + 'Recursive': 'true', + 'SortBy': 'DatePlayed', + 'SortOrder': 'Descending', + 'Fields': _queueFields, + 'Limit': _continueWatchingSeriesLookback.toString(), + 'EnableImages': 'false', + 'EnableTotalRecordCount': 'false', + }); + + final lastPlayedBySeries = {}; + for (final episode in _mapItems(rawPlayed)) { + final seriesId = episode.grandparentId; + final playedAt = episode.lastViewedAt; + if (seriesId == null || playedAt == null) continue; + if (!pendingSeriesIds.contains(seriesId)) continue; + lastPlayedBySeries.putIfAbsent(seriesId, () => playedAt); + } + if (lastPlayedBySeries.isEmpty) return nextUp; + + return [ + for (final item in nextUp) + if (item.lastViewedAt == null && lastPlayedBySeries[item.grandparentId] != null) + item.copyWith(lastViewedAt: lastPlayedBySeries[item.grandparentId]) + else + item, + ]; + } + + /// Merge Jellyfin's two continue-watching sources into one recency-ordered + /// shelf. Resume items are deduped first so an in-progress episode wins over + /// the same series' Next Up entry, then the combined list is ordered by + /// [MediaItem.recencySortKey] (matching `DataAggregationService`) before the + /// limit is applied — so a recent Next Up episode is never starved by a long + /// run of older resume items. List _mergeContinueWatchingAndNextUp({ required List resume, required List nextUp, @@ -1259,25 +1326,29 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { }) { if (limit != null && limit <= 0) return const []; - final result = []; + final merged = []; final seenIds = {}; final seenSeriesIds = {}; - void add(MediaItem item) { - if (!seenIds.add(item.id)) return; + // Resume first: first-wins dedup makes an in-progress episode beat the same + // series' Next Up entry. + for (final item in [...resume, ...nextUp]) { + if (!seenIds.add(item.id)) continue; final seriesId = item.kind == MediaKind.episode ? item.grandparentId : null; - if (seriesId != null && !seenSeriesIds.add(seriesId)) return; - result.add(item); + if (seriesId != null && !seenSeriesIds.add(seriesId)) continue; + merged.add(item); } - for (final item in resume) { - add(item); - if (limit != null && result.length >= limit) return result; - } - for (final item in nextUp) { - add(item); - if (limit != null && result.length >= limit) return result; - } + // Stable sort by recency: Dart's List.sort isn't stable, so break ties on the + // insertion index to keep ordering deterministic across refreshes. + final ordered = [for (var i = 0; i < merged.length; i++) (item: merged[i], index: i)]; + ordered.sort((a, b) { + final byRecency = b.item.recencySortKey.compareTo(a.item.recencySortKey); + return byRecency != 0 ? byRecency : a.index.compareTo(b.index); + }); + final result = [for (final entry in ordered) entry.item]; + + if (limit != null && result.length > limit) return result.sublist(0, limit); return result; } diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index afb170f3..9148e76a 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -1939,6 +1939,143 @@ void main() { expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse); }); + test('fetchContinueWatching orders a recently watched series Next Up above an older resume item', () async { + final requests = []; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((req) async { + requests.add(req.url); + if (req.url.path == '/UserItems/Resume') { + return http.Response( + jsonEncode({ + 'Items': [ + { + 'Id': 'resume-old', + 'Type': 'Movie', + 'Name': 'Old Movie', + 'UserData': {'LastPlayedDate': '2020-01-01T00:00:00.0000000Z'}, + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (req.url.path == '/Shows/NextUp') { + return http.Response( + jsonEncode({ + 'Items': [ + {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (req.url.path == '/Items') { + return http.Response( + jsonEncode({ + 'Items': [ + { + 'Id': 'ep-played', + 'Type': 'Episode', + 'SeriesId': 'show-recent', + 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }), + ); + addTearDown(scoped.close); + + final items = await scoped.fetchContinueWatching(count: 10); + + // The Next Up episode inherits its series' recent last-played date, so it + // sorts above the older resume item (issue #1266). + expect(items.map((item) => item.id), ['next-recent', 'resume-old']); + + final lookup = requests.singleWhere((uri) => uri.path == '/Items'); + expect(lookup.queryParameters['userId'], 'user-1'); + expect(lookup.queryParameters['IncludeItemTypes'], 'Episode'); + expect(lookup.queryParameters['Recursive'], 'true'); + expect(lookup.queryParameters['SortBy'], 'DatePlayed'); + expect(lookup.queryParameters['SortOrder'], 'Descending'); + expect(lookup.queryParameters['Limit'], '200'); + // No Filters=IsPlayed: a series' newest engagement can sit on an episode + // with a LastPlayedDate but Played==false (see _attachSeriesLastPlayed). + expect(lookup.queryParameters.containsKey('Filters'), isFalse); + }); + + test('fetchContinueWatching does not let resume items starve Next Up under the limit', () async { + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((req) async { + if (req.url.path == '/UserItems/Resume') { + return http.Response( + jsonEncode({ + 'Items': [ + { + 'Id': 'resume-old-1', + 'Type': 'Movie', + 'Name': 'Old Movie 1', + 'UserData': {'LastPlayedDate': '2021-01-01T00:00:00.0000000Z'}, + }, + { + 'Id': 'resume-old-2', + 'Type': 'Movie', + 'Name': 'Old Movie 2', + 'UserData': {'LastPlayedDate': '2022-01-01T00:00:00.0000000Z'}, + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (req.url.path == '/Shows/NextUp') { + return http.Response( + jsonEncode({ + 'Items': [ + {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (req.url.path == '/Items') { + return http.Response( + jsonEncode({ + 'Items': [ + { + 'Id': 'ep-played', + 'Type': 'Episode', + 'SeriesId': 'show-recent', + 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }), + ); + addTearDown(scoped.close); + + // count equals the number of resume items: the old resume-first merge would + // have filled the limit and dropped Next Up entirely. + final items = await scoped.fetchContinueWatching(count: 2); + + expect(items.map((item) => item.id), ['next-recent', 'resume-old-2']); + }); + test('fetchContinueWatching keeps resume items when Next Up fails', () async { final scoped = JellyfinClient.forTesting( connection: _conn(),