fix: merge Jellyfin next up into continue watching

This commit is contained in:
edde746
2026-05-02 11:19:04 +02:00
parent 810f1e5da5
commit 1781fc06ae
3 changed files with 229 additions and 9 deletions
+4 -2
View File
@@ -566,14 +566,16 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (!mounted) return; if (!mounted) return;
// Filter out Continue Watching / On Deck hubs (handled separately in hero section) // Filter out playback-progress hubs handled by the top Continue Watching row.
final filteredHubs = allHubs.where((hub) { final filteredHubs = allHubs.where((hub) {
final hubId = hub.identifier?.toLowerCase() ?? ''; final hubId = hub.identifier?.toLowerCase() ?? '';
final title = hub.title.toLowerCase(); final title = hub.title.toLowerCase();
return !hubId.contains('ondeck') && return !hubId.contains('ondeck') &&
!hubId.contains('continue') && !hubId.contains('continue') &&
!hubId.contains('nextup') &&
!title.contains('continue watching') && !title.contains('continue watching') &&
!title.contains('on deck'); !title.contains('on deck') &&
!title.contains('next up');
}).toList(); }).toList();
// Sort hubs by the user's library order // Sort hubs by the user's library order
+54 -7
View File
@@ -986,17 +986,23 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
@override @override
Future<List<MediaItem>> fetchContinueWatching({int count = 20}) async { Future<List<MediaItem>> fetchContinueWatching({int count = 20}) async {
final response = await _http.get( final results = await Future.wait([
'/UserItems/Resume', _fetchItemsArray('/UserItems/Resume', {
queryParameters: {
'userId': connection.userId, 'userId': connection.userId,
'Limit': count.toString(), 'Limit': count.toString(),
'Fields': _browseFields, 'Fields': _browseFields,
'MediaTypes': 'Video', 'MediaTypes': 'Video',
}, }),
); _safeFetchItemsArray('/Shows/NextUp', {
throwIfHttpError(response); 'userId': connection.userId,
return _mapItems(_itemsArray(response.data)); 'Limit': count.toString(),
'Fields': _browseFields,
'EnableResumable': 'false',
'EnableTotalRecordCount': 'false',
}),
]);
return _mergeContinueWatchingAndNextUp(resume: _mapItems(results[0]), nextUp: _mapItems(results[1]), limit: count);
} }
// ── Browse: hubs ───────────────────────────────────────────────── // ── Browse: hubs ─────────────────────────────────────────────────
@@ -1021,6 +1027,8 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
'userId': connection.userId, 'userId': connection.userId,
'Limit': limit.toString(), 'Limit': limit.toString(),
'Fields': _browseFields, 'Fields': _browseFields,
'EnableResumable': 'false',
'EnableTotalRecordCount': 'false',
}), }),
]); ]);
@@ -1082,6 +1090,8 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
'ParentId': libraryId, 'ParentId': libraryId,
'Limit': limit.toString(), 'Limit': limit.toString(),
'Fields': _browseFields, 'Fields': _browseFields,
'EnableResumable': 'false',
'EnableTotalRecordCount': 'false',
}), }),
]); ]);
@@ -1154,6 +1164,8 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
'Limit': effectiveLimit, 'Limit': effectiveLimit,
'Fields': _browseFields, 'Fields': _browseFields,
'ParentId': ?parentId, 'ParentId': ?parentId,
'EnableResumable': 'false',
'EnableTotalRecordCount': 'false',
}); });
break; break;
default: default:
@@ -1967,6 +1979,41 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
// ── Private helpers ────────────────────────────────────────────── // ── Private helpers ──────────────────────────────────────────────
List<MediaItem> _mergeContinueWatchingAndNextUp({
required List<MediaItem> resume,
required List<MediaItem> nextUp,
required int limit,
}) {
if (limit <= 0) return const [];
final result = <MediaItem>[];
final seenIds = <String>{};
final seenSeriesIds = <String>{};
void add(MediaItem item) {
if (!seenIds.add(item.id)) return;
final seriesId = item.kind == MediaKind.episode ? item.grandparentId : null;
if (seriesId != null && !seenSeriesIds.add(seriesId)) return;
result.add(item);
}
for (final item in resume) {
add(item);
if (result.length >= limit) return result;
}
for (final item in nextUp) {
add(item);
if (result.length >= limit) return result;
}
return result;
}
Future<List<Map<String, dynamic>>> _fetchItemsArray(String path, Map<String, dynamic> queryParameters) async {
final response = await _http.get(path, queryParameters: queryParameters);
throwIfHttpError(response);
return _itemsArray(response.data);
}
Future<List<Map<String, dynamic>>> _safeFetchItemsArray(String path, Map<String, dynamic> queryParameters) async { Future<List<Map<String, dynamic>>> _safeFetchItemsArray(String path, Map<String, dynamic> queryParameters) async {
try { try {
final response = await _http.get(path, queryParameters: queryParameters); final response = await _http.get(path, queryParameters: queryParameters);
@@ -683,6 +683,171 @@ void main() {
expect(result, hasLength(250)); expect(result, hasLength(250));
expect(starts, ['0', '200']); expect(starts, ['0', '200']);
}); });
test('fetchItemWithOnDeck keeps resumable NextUp semantics for show detail lookup', () async {
Uri? capturedNextUp;
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
if (req.url.path == '/Users/user-1/Items/show-1') {
return http.Response(
jsonEncode({'Id': 'show-1', 'Type': 'Series', 'Name': 'Show 1'}),
200,
headers: {'content-type': 'application/json'},
);
}
if (req.url.path == '/Shows/NextUp') {
capturedNextUp = req.url;
return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'});
}
return http.Response('not found', 404);
}),
);
addTearDown(scoped.close);
await scoped.fetchItemWithOnDeck('show-1');
expect(capturedNextUp, isNotNull);
expect(capturedNextUp!.queryParameters['seriesId'], 'show-1');
expect(capturedNextUp!.queryParameters['Limit'], '1');
expect(capturedNextUp!.queryParameters.containsKey('EnableResumable'), isFalse);
expect(capturedNextUp!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
test('fetchContinueWatching merges resume with non-resumable Next Up', () 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-show-1', 'Type': 'Episode', 'Name': 'Resume Show 1', 'SeriesId': 'show-1'},
{'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'},
],
}),
200,
headers: {'content-type': 'application/json'},
);
}
if (req.url.path == '/Shows/NextUp') {
return http.Response(
jsonEncode({
'Items': [
{'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'},
{'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'},
],
}),
200,
headers: {'content-type': 'application/json'},
);
}
return http.Response('not found', 404);
}),
);
addTearDown(scoped.close);
final items = await scoped.fetchContinueWatching(count: 3);
expect(items.map((item) => item.id), ['resume-show-1', 'resume-movie-1', 'next-show-2']);
final resume = requests.singleWhere((uri) => uri.path == '/UserItems/Resume');
expect(resume.queryParameters['userId'], 'user-1');
expect(resume.queryParameters['Limit'], '3');
expect(resume.queryParameters['MediaTypes'], 'Video');
final nextUp = requests.singleWhere((uri) => uri.path == '/Shows/NextUp');
expect(nextUp.queryParameters['userId'], 'user-1');
expect(nextUp.queryParameters['Limit'], '3');
expect(nextUp.queryParameters['EnableResumable'], 'false');
expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
test('fetchContinueWatching keeps resume items when Next Up fails', () async {
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
if (req.url.path == '/UserItems/Resume') {
return http.Response(
jsonEncode({
'Items': [
{'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'},
],
}),
200,
headers: {'content-type': 'application/json'},
);
}
if (req.url.path == '/Shows/NextUp') {
return http.Response('server error', 500);
}
return http.Response('not found', 404);
}),
);
addTearDown(scoped.close);
final items = await scoped.fetchContinueWatching();
expect(items.map((item) => item.id), ['resume-movie-1']);
});
});
group('JellyfinClient.fetchGlobalHubs URL builders', () {
late List<Uri> captured;
JellyfinClient buildClient() {
captured = [];
final mock = MockClient((req) async {
captured.add(req.url);
return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'});
});
return JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
}
Uri capturedNextUpRequest() => captured.singleWhere((uri) => uri.path == '/Shows/NextUp');
test('global Next Up excludes resumable episodes without date cutoff', () async {
final client = buildClient();
addTearDown(client.close);
await client.fetchGlobalHubs(limit: 12);
final nextUp = capturedNextUpRequest();
expect(nextUp.queryParameters['userId'], 'user-1');
expect(nextUp.queryParameters['Limit'], '12');
expect(nextUp.queryParameters['EnableResumable'], 'false');
expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
});
group('JellyfinClient.fetchLibraryHubs URL builders', () {
late List<Uri> captured;
JellyfinClient buildClient() {
captured = [];
final mock = MockClient((req) async {
captured.add(req.url);
return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'});
});
return JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
}
test('library Next Up excludes resumable episodes without date cutoff', () async {
final client = buildClient();
addTearDown(client.close);
await client.fetchLibraryHubs('lib-99', limit: 12);
final nextUp = captured.singleWhere((uri) => uri.path == '/Shows/NextUp');
expect(nextUp.queryParameters['ParentId'], 'lib-99');
expect(nextUp.queryParameters['userId'], 'user-1');
expect(nextUp.queryParameters['Limit'], '12');
expect(nextUp.queryParameters['EnableResumable'], 'false');
expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
}); });
group('JellyfinClient.fetchMoreHubItems URL builders', () { group('JellyfinClient.fetchMoreHubItems URL builders', () {
@@ -731,6 +896,9 @@ void main() {
expect(captured!.queryParameters['userId'], 'user-1'); expect(captured!.queryParameters['userId'], 'user-1');
expect(captured!.queryParameters['Limit'], '25'); expect(captured!.queryParameters['Limit'], '25');
expect(captured!.queryParameters.containsKey('ParentId'), isFalse); expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
expect(captured!.queryParameters['EnableResumable'], 'false');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'false');
expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
client.close(); client.close();
}); });
@@ -767,6 +935,9 @@ void main() {
expect(captured!.path, '/Shows/NextUp'); expect(captured!.path, '/Shows/NextUp');
expect(captured!.queryParameters['ParentId'], 'lib-99'); expect(captured!.queryParameters['ParentId'], 'lib-99');
expect(captured!.queryParameters['userId'], 'user-1'); expect(captured!.queryParameters['userId'], 'user-1');
expect(captured!.queryParameters['EnableResumable'], 'false');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'false');
expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
client.close(); client.close();
}); });