diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 8450e6d5..931df919 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -566,14 +566,16 @@ class _DiscoverScreenState extends State 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 hubId = hub.identifier?.toLowerCase() ?? ''; final title = hub.title.toLowerCase(); return !hubId.contains('ondeck') && !hubId.contains('continue') && + !hubId.contains('nextup') && !title.contains('continue watching') && - !title.contains('on deck'); + !title.contains('on deck') && + !title.contains('next up'); }).toList(); // Sort hubs by the user's library order diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 6b125d5b..6d7b02c0 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -986,17 +986,23 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc @override Future> fetchContinueWatching({int count = 20}) async { - final response = await _http.get( - '/UserItems/Resume', - queryParameters: { + final results = await Future.wait([ + _fetchItemsArray('/UserItems/Resume', { 'userId': connection.userId, 'Limit': count.toString(), 'Fields': _browseFields, 'MediaTypes': 'Video', - }, - ); - throwIfHttpError(response); - return _mapItems(_itemsArray(response.data)); + }), + _safeFetchItemsArray('/Shows/NextUp', { + 'userId': connection.userId, + 'Limit': count.toString(), + 'Fields': _browseFields, + 'EnableResumable': 'false', + 'EnableTotalRecordCount': 'false', + }), + ]); + + return _mergeContinueWatchingAndNextUp(resume: _mapItems(results[0]), nextUp: _mapItems(results[1]), limit: count); } // ── Browse: hubs ───────────────────────────────────────────────── @@ -1021,6 +1027,8 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'userId': connection.userId, 'Limit': limit.toString(), 'Fields': _browseFields, + 'EnableResumable': 'false', + 'EnableTotalRecordCount': 'false', }), ]); @@ -1082,6 +1090,8 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'ParentId': libraryId, 'Limit': limit.toString(), 'Fields': _browseFields, + 'EnableResumable': 'false', + 'EnableTotalRecordCount': 'false', }), ]); @@ -1154,6 +1164,8 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'Limit': effectiveLimit, 'Fields': _browseFields, 'ParentId': ?parentId, + 'EnableResumable': 'false', + 'EnableTotalRecordCount': 'false', }); break; default: @@ -1967,6 +1979,41 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc // ── Private helpers ────────────────────────────────────────────── + List _mergeContinueWatchingAndNextUp({ + required List resume, + required List nextUp, + required int limit, + }) { + if (limit <= 0) return const []; + + final result = []; + final seenIds = {}; + final seenSeriesIds = {}; + + 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>> _fetchItemsArray(String path, Map queryParameters) async { + final response = await _http.get(path, queryParameters: queryParameters); + throwIfHttpError(response); + return _itemsArray(response.data); + } + Future>> _safeFetchItemsArray(String path, Map queryParameters) async { try { final response = await _http.get(path, queryParameters: queryParameters); diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index da9849fa..32c03e7e 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -683,6 +683,171 @@ void main() { expect(result, hasLength(250)); 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 = []; + 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 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 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', () { @@ -731,6 +896,9 @@ void main() { expect(captured!.queryParameters['userId'], 'user-1'); expect(captured!.queryParameters['Limit'], '25'); expect(captured!.queryParameters.containsKey('ParentId'), isFalse); + expect(captured!.queryParameters['EnableResumable'], 'false'); + expect(captured!.queryParameters['EnableTotalRecordCount'], 'false'); + expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse); client.close(); }); @@ -767,6 +935,9 @@ void main() { expect(captured!.path, '/Shows/NextUp'); expect(captured!.queryParameters['ParentId'], 'lib-99'); expect(captured!.queryParameters['userId'], 'user-1'); + expect(captured!.queryParameters['EnableResumable'], 'false'); + expect(captured!.queryParameters['EnableTotalRecordCount'], 'false'); + expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse); client.close(); });