From 82d6c5d555f94f102ab977801c72fedd6d053b57 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:19:53 +0200 Subject: [PATCH] fix(explore): render Plex Discover home shelves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/providers/explore_provider.dart | 4 +- lib/services/catalog/plex_catalog_source.dart | 13 +- lib/services/plex_discover_client.dart | 103 ++++++--- test/providers/explore_provider_test.dart | 10 +- test/screens/explore_screen_test.dart | 11 +- .../catalog/plex_catalog_source_test.dart | 211 +++++++++++++----- 6 files changed, 243 insertions(+), 109 deletions(-) diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index 796960ff..d725543f 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -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; diff --git a/lib/services/catalog/plex_catalog_source.dart b/lib/services/catalog/plex_catalog_source.dart index 7a2d41c9..65fda46b 100644 --- a/lib/services/catalog/plex_catalog_source.dart +++ b/lib/services/catalog/plex_catalog_source.dart @@ -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 _hubKeys = {}; @@ -45,7 +46,7 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource, @override Future> fetchHubs({int limit = 25}) async { - final fetched = await _client.getRecommendedHubs(limit: limit); + final fetched = await _client.getHomeHubs(limit: limit); final keys = {}; final result = []; 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 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 diff --git a/lib/services/plex_discover_client.dart b/lib/services/plex_discover_client.dart index 15cb715b..2675b8a6 100644 --- a/lib/services/plex_discover_client.dart +++ b/lib/services/plex_discover_client.dart @@ -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 _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> 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> 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 = []; + final candidates = <({String id, String key, String title})>[]; final seen = {}; 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.filled(candidates.length, null); + Object? firstError; + StackTrace? firstStackTrace; + var cursor = 0; + Future 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 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 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>> search(String query, {int limit = 30}) async { diff --git a/test/providers/explore_provider_test.dart b/test/providers/explore_provider_test.dart index 5d4ca461..5c07e130 100644 --- a/test/providers/explore_provider_test.dart +++ b/test/providers/explore_provider_test.dart @@ -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); diff --git a/test/screens/explore_screen_test.dart b/test/screens/explore_screen_test.dart index 9ead4b23..c4be8a8e 100644 --- a/test/screens/explore_screen_test.dart +++ b/test/screens/explore_screen_test.dart @@ -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)); }); diff --git a/test/services/catalog/plex_catalog_source_test.dart b/test/services/catalog/plex_catalog_source_test.dart index eade4e42..90cd2ca1 100644 --- a/test/services/catalog/plex_catalog_source_test.dart +++ b/test/services/catalog/plex_catalog_source_test.dart @@ -18,6 +18,8 @@ Map _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 _metadata({ {'tag': 'Science Fiction'}, ], 'Guid': [ - {'id': 'imdb://tt1375666'}, - {'id': 'tmdb://27205'}, + {'id': 'imdb://$imdb'}, + {'id': 'tmdb://$tmdb'}, ], }; +/// Discover answers `/hubs/sections/
` with placeholders only — the +/// shelf identity, never its items. +Map _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 = []; 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 = []; + 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())); + }); + + test('a vanished home shelf degrades to an empty page', () async { final requests = []; 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(