fix(explore): render Plex Discover home shelves
Plex Explore showed only the Watchlist row. `/hubs/sections/watchlist` answers with placeholder hubs — every entry carries `placeholder: true`, `size: 0` and no `Metadata` — so `fetchHubs` mapped each one to an empty page and dropped all of them. That is true no matter what the profile has watchlisted; the shelves never rendered. Read `/hubs/sections/home` instead, the section Plex's own web client renders on its Home > Trending tab, and hydrate each placeholder from its own key (six at a time). `directory` shelves list browse categories and `clip` shelves list trailers, neither of which becomes a catalog item, so they are skipped before spending a request. A shelf that fails degrades to the ones that succeeded; a pass where every shelf failed still throws. Discover ignores container offsets on hub keys and truncates with `limit` instead, so a hub is one page: View All takes the whole shelf in a single request rather than replaying page one, and hub requests drop `Media` and `Image` elements the catalog layer never reads.
This commit is contained in:
@@ -207,8 +207,8 @@ class ExploreProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
if (isDisposed || generation != _generation) return;
|
||||
|
||||
// A debounced watchlist refresh that landed while this load was in
|
||||
// flight covered later mutations than both the watchlist page and Plex's
|
||||
// watchlist-derived hubs — keep the fresher versions.
|
||||
// flight covered later mutations than both the watchlist page and the
|
||||
// provider hubs that track it — keep the fresher versions.
|
||||
if (_watchlistRowFetchedEpoch > mutationEpochAtStart) {
|
||||
fetched.remove(CatalogRowId.watchlist);
|
||||
fetchedProviderHubs = null;
|
||||
|
||||
@@ -8,7 +8,8 @@ import 'catalog_source.dart';
|
||||
import 'catalog_watchlist_machinery.dart';
|
||||
|
||||
/// [CatalogSource] backed by the active Plex profile's universal watchlist
|
||||
/// and its provider-defined recommendation hubs.
|
||||
/// and Discover's Home shelves (what Plex's own web client shows on its
|
||||
/// Home ▸ Trending tab).
|
||||
class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource, CatalogHubSource {
|
||||
final PlexDiscoverClient _client;
|
||||
final Map<String, String> _hubKeys = {};
|
||||
@@ -45,7 +46,7 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
|
||||
|
||||
@override
|
||||
Future<List<CatalogHub>> fetchHubs({int limit = 25}) async {
|
||||
final fetched = await _client.getRecommendedHubs(limit: limit);
|
||||
final fetched = await _client.getHomeHubs(limit: limit);
|
||||
final keys = <String, String>{};
|
||||
final result = <CatalogHub>[];
|
||||
for (final hub in fetched) {
|
||||
@@ -66,12 +67,14 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Discover serves a hub in one shot — it ignores container offsets — so
|
||||
/// View All has nothing to page into beyond the first request.
|
||||
@override
|
||||
Future<CatalogPage> fetchHub(String id, {int page = 1, int limit = 25}) async {
|
||||
final key = _hubKeys[id];
|
||||
if (key == null) return const CatalogPage(items: []);
|
||||
final response = await _client.getHub(key, page: page, limit: limit);
|
||||
return CatalogPage(items: _fromMetadata(response.items), hasMore: response.hasMore);
|
||||
if (key == null || page > 1) return const CatalogPage(items: []);
|
||||
final response = await _client.getHub(key, limit: limit);
|
||||
return CatalogPage(items: _fromMetadata(response.items), hasMore: false);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/external_ids.dart';
|
||||
import '../utils/json_utils.dart';
|
||||
|
||||
@@ -56,6 +57,10 @@ class PlexDiscoverException implements Exception {
|
||||
class PlexDiscoverClient {
|
||||
static final Uri _baseUri = Uri.parse('https://discover.provider.plex.tv');
|
||||
|
||||
/// Home shelf types whose entries never become a catalog item: `directory`
|
||||
/// shelves list browse categories, `clip` shelves list trailers.
|
||||
static const Set<String> _unrenderableHubTypes = {'directory', 'clip'};
|
||||
|
||||
final PlexDiscoverSession session;
|
||||
final http.Client _http;
|
||||
final Duration requestTimeout;
|
||||
@@ -78,61 +83,89 @@ class PlexDiscoverClient {
|
||||
return PlexDiscoverPage(items: items, hasMore: offset + items.length < total);
|
||||
}
|
||||
|
||||
/// Provider-defined recommendation shelves derived from the active
|
||||
/// profile's universal watchlist.
|
||||
Future<List<PlexDiscoverHub>> getRecommendedHubs({int limit = 25}) async {
|
||||
/// Discover's Home shelves — the rows Plex's own web client renders on its
|
||||
/// Home ▸ Trending tab, which reads
|
||||
/// `provider://tv.plex.provider.discover/hubs/sections/home`.
|
||||
///
|
||||
/// The section listing is placeholders only: every hub comes back with
|
||||
/// `placeholder: true`, `size: 0` and no `Metadata`, so each rendered shelf
|
||||
/// costs one further request against its own key. Shelves that can never
|
||||
/// produce a catalog item are dropped before spending that request:
|
||||
/// `directory` shelves list browse categories (genre/decade/award) and
|
||||
/// `clip` shelves list trailers.
|
||||
Future<List<PlexDiscoverHub>> getHomeHubs({int limit = 25, int concurrency = 6}) async {
|
||||
final safeLimit = limit.clamp(1, 100);
|
||||
final data = await _request('GET', '/hubs/sections/watchlist', query: {'count': safeLimit + 1, 'includeMeta': 1});
|
||||
final data = await _request('GET', '/hubs/sections/home', query: {'includeMeta': 1});
|
||||
final container = _mediaContainer(data!);
|
||||
final result = <PlexDiscoverHub>[];
|
||||
final candidates = <({String id, String key, String title})>[];
|
||||
final seen = <String>{};
|
||||
for (final hub in flexibleMapList(container['Hub'])) {
|
||||
if (_unrenderableHubTypes.contains(hub['type'])) continue;
|
||||
final key = _nonEmptyString(hub['key'] ?? hub['hubKey']);
|
||||
final id = _nonEmptyString(hub['hubIdentifier']) ?? key;
|
||||
final title = _nonEmptyString(hub['title']);
|
||||
if (key == null || id == null || title == null || !seen.add(id)) continue;
|
||||
final rawItems = flexibleMapList(hub['Metadata']);
|
||||
final items = rawItems.take(safeLimit).toList();
|
||||
final total = flexibleInt(hub['totalSize']) ?? flexibleInt(hub['size']) ?? rawItems.length;
|
||||
result.add(
|
||||
PlexDiscoverHub(
|
||||
id: id,
|
||||
key: key,
|
||||
title: title,
|
||||
page: PlexDiscoverPage(
|
||||
items: items,
|
||||
hasMore: flexibleBool(hub['more']) == true || rawItems.length > safeLimit || total > items.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
candidates.add((id: id, key: key, title: title));
|
||||
}
|
||||
return result;
|
||||
if (candidates.isEmpty) return const [];
|
||||
|
||||
// One shelf failing (a hub retired between listing and hydration, a
|
||||
// transient 5xx) must not sink the whole tab, but a pass where every
|
||||
// shelf failed is a real failure and keeps the caller's error surface.
|
||||
final pages = List<PlexDiscoverPage?>.filled(candidates.length, null);
|
||||
Object? firstError;
|
||||
StackTrace? firstStackTrace;
|
||||
var cursor = 0;
|
||||
Future<void> hydrate() async {
|
||||
while (true) {
|
||||
final index = cursor++;
|
||||
if (index >= candidates.length) return;
|
||||
final candidate = candidates[index];
|
||||
try {
|
||||
// One over the shelf size is what tells View All there is more.
|
||||
pages[index] = await getHub(candidate.key, limit: safeLimit + 1);
|
||||
} catch (error, stackTrace) {
|
||||
firstError ??= error;
|
||||
firstStackTrace ??= stackTrace;
|
||||
appLogger.w('Plex Discover: home hub ${candidate.id} failed', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Future.wait([for (var i = concurrency.clamp(1, candidates.length); i > 0; i--) hydrate()]);
|
||||
if (firstError case final error? when pages.every((page) => page == null)) {
|
||||
Error.throwWithStackTrace(error, firstStackTrace!);
|
||||
}
|
||||
|
||||
return [
|
||||
for (var i = 0; i < candidates.length; i++)
|
||||
if (pages[i] case final page? when page.items.isNotEmpty)
|
||||
PlexDiscoverHub(
|
||||
id: candidates[i].id,
|
||||
key: candidates[i].key,
|
||||
title: candidates[i].title,
|
||||
page: PlexDiscoverPage(items: page.items.take(safeLimit).toList(), hasMore: page.items.length > safeLimit),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<PlexDiscoverPage> getHub(String key, {int page = 1, int limit = 100}) async {
|
||||
final safePage = page < 1 ? 1 : page;
|
||||
/// One Discover hub in full. The provider ignores container offsets on hub
|
||||
/// keys and truncates with `limit` instead, so a hub is always a single
|
||||
/// page and [PlexDiscoverPage.hasMore] never reports one.
|
||||
Future<PlexDiscoverPage> getHub(String key, {int limit = 100}) async {
|
||||
final safeLimit = limit.clamp(1, 500);
|
||||
final offset = (safePage - 1) * safeLimit;
|
||||
final data = await _request(
|
||||
'GET',
|
||||
key,
|
||||
query: {'X-Plex-Container-Start': offset, 'X-Plex-Container-Size': safeLimit, 'includeMeta': 1},
|
||||
// Streaming parts and image variants roughly double the payload of a
|
||||
// 25-item shelf and nothing in the catalog layer reads them.
|
||||
query: {'limit': safeLimit, 'includeMeta': 1, 'excludeElements': 'Media,Image'},
|
||||
);
|
||||
final container = _mediaContainer(data!);
|
||||
final hub = firstFlexibleMap(container['Hub']);
|
||||
final containerItems = flexibleMapList(container['Metadata']);
|
||||
final rawItems = containerItems.isNotEmpty ? containerItems : flexibleMapList(hub?['Metadata']);
|
||||
final items = rawItems.take(safeLimit).toList();
|
||||
final total =
|
||||
flexibleInt(container['totalSize']) ??
|
||||
flexibleInt(hub?['totalSize']) ??
|
||||
flexibleInt(container['size']) ??
|
||||
flexibleInt(hub?['size']) ??
|
||||
rawItems.length;
|
||||
return PlexDiscoverPage(
|
||||
items: items,
|
||||
hasMore: flexibleBool(hub?['more']) == true || rawItems.length > safeLimit || offset + items.length < total,
|
||||
);
|
||||
return PlexDiscoverPage(items: rawItems.take(safeLimit).toList());
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> search(String query, {int limit = 30}) async {
|
||||
|
||||
@@ -109,8 +109,8 @@ class _FakeHubSource extends _FakeSource implements CatalogHubSource {
|
||||
if (returnEmptyHubs) return const [];
|
||||
return [
|
||||
CatalogHub(
|
||||
id: 'because-watchlisted',
|
||||
title: 'Because You Watchlisted Inception',
|
||||
id: 'trending-plex',
|
||||
title: 'Trending on Plex',
|
||||
page: CatalogPage(items: [_hubItem('Initial Recommendation')], hasMore: true),
|
||||
),
|
||||
];
|
||||
@@ -197,8 +197,8 @@ void main() {
|
||||
expect(explore.rowHubs, hasLength(2));
|
||||
final providerHub = explore.rowHubs.last;
|
||||
expect(providerHub.row, isNull);
|
||||
expect(providerHub.providerHubId, 'because-watchlisted');
|
||||
expect(providerHub.hub.title, 'Because You Watchlisted Inception');
|
||||
expect(providerHub.providerHubId, 'trending-plex');
|
||||
expect(providerHub.hub.title, 'Trending on Plex');
|
||||
expect(providerHub.hub.items.single.title, 'Initial Recommendation');
|
||||
expect(providerHub.hub.more, isTrue);
|
||||
|
||||
@@ -243,7 +243,7 @@ void main() {
|
||||
expect(explore.rowHubs, isEmpty);
|
||||
});
|
||||
|
||||
test('mutation retries watchlist-derived hubs after a partial refresh failure', () async {
|
||||
test('mutation retries provider hubs after a partial refresh failure', () async {
|
||||
final source = _FakeHubSource(CatalogSourceId.plex);
|
||||
addTearDown(source.dispose);
|
||||
sources.setActive(source);
|
||||
|
||||
@@ -126,12 +126,7 @@ Future<_FakeCatalogSourcesProvider> _pumpExplore(
|
||||
final mal = _FakeCatalogSource(CatalogSourceId.mal, 'MyAnimeList', malItemId);
|
||||
final anilist = _FakeCatalogSource(CatalogSourceId.anilist, 'AniList', 3);
|
||||
final simkl = _FakeCatalogSource(CatalogSourceId.simkl, 'Simkl', 4);
|
||||
final plex = _FakeCatalogSource(
|
||||
CatalogSourceId.plex,
|
||||
'Plex',
|
||||
5,
|
||||
providerHubTitle: 'Because You Watchlisted Inception',
|
||||
);
|
||||
final plex = _FakeCatalogSource(CatalogSourceId.plex, 'Plex', 5, providerHubTitle: 'Trending on Plex');
|
||||
final seerr = _FakeCatalogSource(CatalogSourceId.seerr, 'Seerr', 6);
|
||||
final sources = _FakeCatalogSourcesProvider([trakt, mal, anilist, simkl, plex, seerr]);
|
||||
final explore = ExploreProvider(sources);
|
||||
@@ -251,14 +246,14 @@ void main() {
|
||||
expect(find.text('AniList Movie'), findsAtLeast(1));
|
||||
});
|
||||
|
||||
testWidgets('Plex provider-defined recommendation hub renders as an Explore shelf', (tester) async {
|
||||
testWidgets('Plex provider-defined hub renders as an Explore shelf', (tester) async {
|
||||
final sources = await _pumpExplore(tester);
|
||||
|
||||
await sources.setActiveSource(CatalogSourceId.plex);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Because You Watchlisted Inception'), findsOneWidget);
|
||||
expect(find.text('Trending on Plex'), findsOneWidget);
|
||||
expect(find.text('Plex Recommendation'), findsAtLeast(1));
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ Map<String, Object?> _metadata({
|
||||
String ratingKey = 'plex-movie-1',
|
||||
String type = 'movie',
|
||||
String title = 'Inception',
|
||||
String imdb = 'tt1375666',
|
||||
int tmdb = 27205,
|
||||
}) => {
|
||||
'ratingKey': ratingKey,
|
||||
'guid': 'plex://$type/$ratingKey',
|
||||
@@ -34,11 +36,23 @@ Map<String, Object?> _metadata({
|
||||
{'tag': 'Science Fiction'},
|
||||
],
|
||||
'Guid': [
|
||||
{'id': 'imdb://tt1375666'},
|
||||
{'id': 'tmdb://27205'},
|
||||
{'id': 'imdb://$imdb'},
|
||||
{'id': 'tmdb://$tmdb'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Discover answers `/hubs/sections/<section>` with placeholders only — the
|
||||
/// shelf identity, never its items.
|
||||
Map<String, Object?> _placeholderHub(String id, String title, {String type = 'mixed'}) => {
|
||||
'hubIdentifier': id,
|
||||
'key': '/hubs/sections/home/${id.split('.').last}?source=home',
|
||||
'title': title,
|
||||
'type': type,
|
||||
'placeholder': true,
|
||||
'size': 0,
|
||||
'more': true,
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('PlexCatalogSource', () {
|
||||
test('watchlist uses offset paging and maps Plex metadata', () async {
|
||||
@@ -83,49 +97,52 @@ void main() {
|
||||
expect(item.genres, ['Science Fiction']);
|
||||
});
|
||||
|
||||
test('recommendation hubs retain Plex titles and support View All paging', () async {
|
||||
test('home shelves are hydrated from their placeholder keys', () async {
|
||||
final requests = <http.Request>[];
|
||||
final source = PlexCatalogSource(
|
||||
PlexDiscoverClient(
|
||||
_session,
|
||||
httpClient: MockClient((request) async {
|
||||
requests.add(request);
|
||||
if (request.url.path == '/hubs/sections/watchlist') {
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Hub': [
|
||||
{
|
||||
'hubIdentifier': 'because-watchlisted',
|
||||
'key': '/hubs/sections/watchlist/because-watchlisted?source=watchlist',
|
||||
'title': 'Because You Watchlisted Inception',
|
||||
'totalSize': 4,
|
||||
'more': 1,
|
||||
'Metadata': [
|
||||
_metadata(),
|
||||
_metadata(ratingKey: 'plex-show-1', type: 'show', title: 'Severance'),
|
||||
{'ratingKey': 'person-1', 'type': 'person', 'title': 'A Person'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'hubIdentifier': 'people-only',
|
||||
'key': '/hubs/sections/watchlist/people-only',
|
||||
'title': 'People',
|
||||
'Metadata': [
|
||||
{'ratingKey': 'person-2', 'type': 'person', 'title': 'Another Person'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (request.url.path == '/hubs/sections/watchlist/because-watchlisted') {
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'offset': 2,
|
||||
'totalSize': 3,
|
||||
'Metadata': [_metadata(ratingKey: 'plex-movie-2', title: 'Interstellar')],
|
||||
},
|
||||
});
|
||||
switch (request.url.path) {
|
||||
case '/hubs/sections/home':
|
||||
// Discover answers the section listing with placeholders: no
|
||||
// hub carries Metadata, so each shelf needs its own request.
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Hub': [
|
||||
_placeholderHub('home.trending-plex', 'Trending on Plex'),
|
||||
_placeholderHub('home.genres', 'Browse by Genre', type: 'directory'),
|
||||
_placeholderHub('home.new-trailers', 'New Trailers', type: 'clip'),
|
||||
_placeholderHub('home.people', 'People'),
|
||||
_placeholderHub('home.chris-nolan', 'The Films of Sir Christopher Nolan'),
|
||||
],
|
||||
},
|
||||
});
|
||||
case '/hubs/sections/home/trending-plex':
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Metadata': [
|
||||
_metadata(),
|
||||
_metadata(ratingKey: 'plex-show-1', type: 'show', title: 'Severance'),
|
||||
_metadata(ratingKey: 'plex-movie-2', title: 'Interstellar'),
|
||||
],
|
||||
},
|
||||
});
|
||||
case '/hubs/sections/home/people':
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Metadata': [
|
||||
{'ratingKey': 'person-1', 'type': 'person', 'title': 'A Person'},
|
||||
],
|
||||
},
|
||||
});
|
||||
case '/hubs/sections/home/chris-nolan':
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Metadata': [_metadata(ratingKey: 'plex-movie-3', title: 'The Prestige')],
|
||||
},
|
||||
});
|
||||
}
|
||||
return jsonResponse({'error': 'unexpected'}, status: 500);
|
||||
}),
|
||||
@@ -135,24 +152,109 @@ void main() {
|
||||
|
||||
final hubs = await source.fetchHubs(limit: 2);
|
||||
|
||||
expect(requests.first.url.queryParameters, containsPair('count', '3'));
|
||||
expect(requests.first.url.queryParameters, containsPair('includeMeta', '1'));
|
||||
expect(hubs, hasLength(1));
|
||||
expect(hubs.single.id, 'because-watchlisted');
|
||||
expect(hubs.single.title, 'Because You Watchlisted Inception');
|
||||
expect(hubs.single.page.items.map((item) => item.title), ['Inception', 'Severance']);
|
||||
expect(hubs.single.page.hasMore, isTrue);
|
||||
// Browse-category and trailer shelves cannot produce a catalog item, so
|
||||
// they never cost a hydration request.
|
||||
expect(requests.map((request) => request.url.path), [
|
||||
'/hubs/sections/home',
|
||||
'/hubs/sections/home/trending-plex',
|
||||
'/hubs/sections/home/people',
|
||||
'/hubs/sections/home/chris-nolan',
|
||||
]);
|
||||
expect(requests[1].url.queryParameters, containsPair('limit', '3'));
|
||||
expect(requests[1].url.queryParameters, containsPair('includeMeta', '1'));
|
||||
expect(requests[1].url.queryParameters, containsPair('source', 'home'));
|
||||
|
||||
final page = await source.fetchHub(hubs.single.id, page: 2, limit: 2);
|
||||
|
||||
expect(requests.last.url.queryParameters, containsPair('source', 'watchlist'));
|
||||
expect(requests.last.url.queryParameters, containsPair('X-Plex-Container-Start', '2'));
|
||||
expect(requests.last.url.queryParameters, containsPair('X-Plex-Container-Size', '2'));
|
||||
expect(page.items.single.title, 'Interstellar');
|
||||
expect(page.hasMore, isFalse);
|
||||
// The people-only shelf maps to nothing and drops out; provider order
|
||||
// and titles survive for the rest.
|
||||
expect(hubs.map((hub) => hub.id), ['home.trending-plex', 'home.chris-nolan']);
|
||||
expect(hubs.first.title, 'Trending on Plex');
|
||||
expect(hubs.first.page.items.map((item) => item.title), ['Inception', 'Severance']);
|
||||
expect(hubs.first.page.hasMore, isTrue);
|
||||
expect(hubs.last.page.items.single.title, 'The Prestige');
|
||||
expect(hubs.last.page.hasMore, isFalse);
|
||||
});
|
||||
|
||||
test('a vanished recommendation hub degrades to an empty page', () async {
|
||||
test('View All takes a shelf in one request because Discover ignores offsets', () async {
|
||||
final requests = <http.Request>[];
|
||||
final source = PlexCatalogSource(
|
||||
PlexDiscoverClient(
|
||||
_session,
|
||||
httpClient: MockClient((request) async {
|
||||
requests.add(request);
|
||||
if (request.url.path == '/hubs/sections/home') {
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Hub': [_placeholderHub('home.trending-plex', 'Trending on Plex')],
|
||||
},
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Metadata': [
|
||||
_metadata(),
|
||||
_metadata(ratingKey: 'plex-movie-2', title: 'Interstellar', imdb: 'tt0816692', tmdb: 157336),
|
||||
],
|
||||
},
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
addTearDown(source.dispose);
|
||||
|
||||
await source.fetchHubs(limit: 1);
|
||||
requests.clear();
|
||||
|
||||
final page = await source.fetchHub('home.trending-plex', limit: 100);
|
||||
|
||||
expect(requests.single.url.queryParameters, containsPair('limit', '100'));
|
||||
expect(requests.single.url.queryParameters.containsKey('X-Plex-Container-Start'), isFalse);
|
||||
expect(page.items.map((item) => item.title), ['Inception', 'Interstellar']);
|
||||
expect(page.hasMore, isFalse);
|
||||
|
||||
// A second page would replay the same items, so it is never requested.
|
||||
requests.clear();
|
||||
final beyond = await source.fetchHub('home.trending-plex', page: 2, limit: 100);
|
||||
expect(beyond.items, isEmpty);
|
||||
expect(requests, isEmpty);
|
||||
});
|
||||
|
||||
test('one failing shelf degrades, an entirely failing listing surfaces the error', () async {
|
||||
var failEverything = false;
|
||||
final source = PlexCatalogSource(
|
||||
PlexDiscoverClient(
|
||||
_session,
|
||||
httpClient: MockClient((request) async {
|
||||
if (request.url.path == '/hubs/sections/home') {
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Hub': [
|
||||
_placeholderHub('home.trending-plex', 'Trending on Plex'),
|
||||
_placeholderHub('home.retired', 'Retired'),
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (failEverything || request.url.path == '/hubs/sections/home/retired') {
|
||||
return jsonResponse({'error': 'gone'}, status: 500);
|
||||
}
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Metadata': [_metadata()],
|
||||
},
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
addTearDown(source.dispose);
|
||||
|
||||
final hubs = await source.fetchHubs(limit: 25);
|
||||
expect(hubs.map((hub) => hub.id), ['home.trending-plex']);
|
||||
|
||||
failEverything = true;
|
||||
await expectLater(source.fetchHubs(limit: 25), throwsA(isA<PlexDiscoverException>()));
|
||||
});
|
||||
|
||||
test('a vanished home shelf degrades to an empty page', () async {
|
||||
final requests = <http.Request>[];
|
||||
final source = PlexCatalogSource(
|
||||
PlexDiscoverClient(
|
||||
@@ -171,6 +273,7 @@ void main() {
|
||||
expect(page.hasMore, isFalse);
|
||||
expect(requests, isEmpty);
|
||||
});
|
||||
|
||||
test('search sends Plex universal-search values and deduplicates media', () async {
|
||||
late http.Request captured;
|
||||
final source = PlexCatalogSource(
|
||||
|
||||
Reference in New Issue
Block a user