fix(jellyfin): order continue watching + up next by recency

This commit is contained in:
edde746
2026-06-07 16:34:18 +02:00
parent 54512b8f93
commit e8df18e7b2
4 changed files with 231 additions and 20 deletions
+5
View File
@@ -364,6 +364,11 @@ sealed class MediaItem with _$MediaItem {
/// `[seasonId, showId]`. For a season: `[showId]`. For a movie: `[]`.
List<String> 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;
+4 -6
View File
@@ -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);
+85 -14
View File
@@ -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<List<MediaItem>> _attachSeriesLastPlayed(List<MediaItem> nextUp) async {
final pendingSeriesIds = <String>{
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 = <String, int>{};
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<MediaItem> _mergeContinueWatchingAndNextUp({
required List<MediaItem> resume,
required List<MediaItem> nextUp,
@@ -1259,25 +1326,29 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
}) {
if (limit != null && limit <= 0) return const [];
final result = <MediaItem>[];
final merged = <MediaItem>[];
final seenIds = <String>{};
final seenSeriesIds = <String>{};
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;
}
@@ -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 = <Uri>[];
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(),