fix: merge Jellyfin next up into continue watching
This commit is contained in:
@@ -566,14 +566,16 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
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
|
||||
|
||||
@@ -986,17 +986,23 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> 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<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 {
|
||||
try {
|
||||
final response = await _http.get(path, queryParameters: queryParameters);
|
||||
|
||||
@@ -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 = <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', () {
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user