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;
|
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
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user