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:
edde746
2026-07-28 05:26:37 +02:00
parent 53535e1678
commit 82d6c5d555
6 changed files with 243 additions and 109 deletions
+2 -2
View File
@@ -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
+68 -35
View File
@@ -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 {
+5 -5
View File
@@ -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);
+3 -8
View File
@@ -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(