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:
@@ -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