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; if (isDisposed || generation != _generation) return;
// A debounced watchlist refresh that landed while this load was in // A debounced watchlist refresh that landed while this load was in
// flight covered later mutations than both the watchlist page and Plex's // flight covered later mutations than both the watchlist page and the
// watchlist-derived hubs — keep the fresher versions. // provider hubs that track it — keep the fresher versions.
if (_watchlistRowFetchedEpoch > mutationEpochAtStart) { if (_watchlistRowFetchedEpoch > mutationEpochAtStart) {
fetched.remove(CatalogRowId.watchlist); fetched.remove(CatalogRowId.watchlist);
fetchedProviderHubs = null; fetchedProviderHubs = null;
@@ -8,7 +8,8 @@ import 'catalog_source.dart';
import 'catalog_watchlist_machinery.dart'; import 'catalog_watchlist_machinery.dart';
/// [CatalogSource] backed by the active Plex profile's universal watchlist /// [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 { class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource, CatalogHubSource {
final PlexDiscoverClient _client; final PlexDiscoverClient _client;
final Map<String, String> _hubKeys = {}; final Map<String, String> _hubKeys = {};
@@ -45,7 +46,7 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
@override @override
Future<List<CatalogHub>> fetchHubs({int limit = 25}) async { 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 keys = <String, String>{};
final result = <CatalogHub>[]; final result = <CatalogHub>[];
for (final hub in fetched) { for (final hub in fetched) {
@@ -66,12 +67,14 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
return result; 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 @override
Future<CatalogPage> fetchHub(String id, {int page = 1, int limit = 25}) async { Future<CatalogPage> fetchHub(String id, {int page = 1, int limit = 25}) async {
final key = _hubKeys[id]; final key = _hubKeys[id];
if (key == null) return const CatalogPage(items: []); if (key == null || page > 1) return const CatalogPage(items: []);
final response = await _client.getHub(key, page: page, limit: limit); final response = await _client.getHub(key, limit: limit);
return CatalogPage(items: _fromMetadata(response.items), hasMore: response.hasMore); return CatalogPage(items: _fromMetadata(response.items), hasMore: false);
} }
@override @override
+68 -35
View File
@@ -4,6 +4,7 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../utils/app_logger.dart';
import '../utils/external_ids.dart'; import '../utils/external_ids.dart';
import '../utils/json_utils.dart'; import '../utils/json_utils.dart';
@@ -56,6 +57,10 @@ class PlexDiscoverException implements Exception {
class PlexDiscoverClient { class PlexDiscoverClient {
static final Uri _baseUri = Uri.parse('https://discover.provider.plex.tv'); 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 PlexDiscoverSession session;
final http.Client _http; final http.Client _http;
final Duration requestTimeout; final Duration requestTimeout;
@@ -78,61 +83,89 @@ class PlexDiscoverClient {
return PlexDiscoverPage(items: items, hasMore: offset + items.length < total); return PlexDiscoverPage(items: items, hasMore: offset + items.length < total);
} }
/// Provider-defined recommendation shelves derived from the active /// Discover's Home shelves — the rows Plex's own web client renders on its
/// profile's universal watchlist. /// Home ▸ Trending tab, which reads
Future<List<PlexDiscoverHub>> getRecommendedHubs({int limit = 25}) async { /// `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 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 container = _mediaContainer(data!);
final result = <PlexDiscoverHub>[]; final candidates = <({String id, String key, String title})>[];
final seen = <String>{}; final seen = <String>{};
for (final hub in flexibleMapList(container['Hub'])) { for (final hub in flexibleMapList(container['Hub'])) {
if (_unrenderableHubTypes.contains(hub['type'])) continue;
final key = _nonEmptyString(hub['key'] ?? hub['hubKey']); final key = _nonEmptyString(hub['key'] ?? hub['hubKey']);
final id = _nonEmptyString(hub['hubIdentifier']) ?? key; final id = _nonEmptyString(hub['hubIdentifier']) ?? key;
final title = _nonEmptyString(hub['title']); final title = _nonEmptyString(hub['title']);
if (key == null || id == null || title == null || !seen.add(id)) continue; if (key == null || id == null || title == null || !seen.add(id)) continue;
final rawItems = flexibleMapList(hub['Metadata']); candidates.add((id: id, key: key, title: title));
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,
),
),
);
} }
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 { /// One Discover hub in full. The provider ignores container offsets on hub
final safePage = page < 1 ? 1 : page; /// 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 safeLimit = limit.clamp(1, 500);
final offset = (safePage - 1) * safeLimit;
final data = await _request( final data = await _request(
'GET', 'GET',
key, 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 container = _mediaContainer(data!);
final hub = firstFlexibleMap(container['Hub']); final hub = firstFlexibleMap(container['Hub']);
final containerItems = flexibleMapList(container['Metadata']); final containerItems = flexibleMapList(container['Metadata']);
final rawItems = containerItems.isNotEmpty ? containerItems : flexibleMapList(hub?['Metadata']); final rawItems = containerItems.isNotEmpty ? containerItems : flexibleMapList(hub?['Metadata']);
final items = rawItems.take(safeLimit).toList(); return PlexDiscoverPage(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,
);
} }
Future<List<Map<String, dynamic>>> search(String query, {int limit = 30}) async { 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 []; if (returnEmptyHubs) return const [];
return [ return [
CatalogHub( CatalogHub(
id: 'because-watchlisted', id: 'trending-plex',
title: 'Because You Watchlisted Inception', title: 'Trending on Plex',
page: CatalogPage(items: [_hubItem('Initial Recommendation')], hasMore: true), page: CatalogPage(items: [_hubItem('Initial Recommendation')], hasMore: true),
), ),
]; ];
@@ -197,8 +197,8 @@ void main() {
expect(explore.rowHubs, hasLength(2)); expect(explore.rowHubs, hasLength(2));
final providerHub = explore.rowHubs.last; final providerHub = explore.rowHubs.last;
expect(providerHub.row, isNull); expect(providerHub.row, isNull);
expect(providerHub.providerHubId, 'because-watchlisted'); expect(providerHub.providerHubId, 'trending-plex');
expect(providerHub.hub.title, 'Because You Watchlisted Inception'); expect(providerHub.hub.title, 'Trending on Plex');
expect(providerHub.hub.items.single.title, 'Initial Recommendation'); expect(providerHub.hub.items.single.title, 'Initial Recommendation');
expect(providerHub.hub.more, isTrue); expect(providerHub.hub.more, isTrue);
@@ -243,7 +243,7 @@ void main() {
expect(explore.rowHubs, isEmpty); 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); final source = _FakeHubSource(CatalogSourceId.plex);
addTearDown(source.dispose); addTearDown(source.dispose);
sources.setActive(source); sources.setActive(source);
+3 -8
View File
@@ -126,12 +126,7 @@ Future<_FakeCatalogSourcesProvider> _pumpExplore(
final mal = _FakeCatalogSource(CatalogSourceId.mal, 'MyAnimeList', malItemId); final mal = _FakeCatalogSource(CatalogSourceId.mal, 'MyAnimeList', malItemId);
final anilist = _FakeCatalogSource(CatalogSourceId.anilist, 'AniList', 3); final anilist = _FakeCatalogSource(CatalogSourceId.anilist, 'AniList', 3);
final simkl = _FakeCatalogSource(CatalogSourceId.simkl, 'Simkl', 4); final simkl = _FakeCatalogSource(CatalogSourceId.simkl, 'Simkl', 4);
final plex = _FakeCatalogSource( final plex = _FakeCatalogSource(CatalogSourceId.plex, 'Plex', 5, providerHubTitle: 'Trending on Plex');
CatalogSourceId.plex,
'Plex',
5,
providerHubTitle: 'Because You Watchlisted Inception',
);
final seerr = _FakeCatalogSource(CatalogSourceId.seerr, 'Seerr', 6); final seerr = _FakeCatalogSource(CatalogSourceId.seerr, 'Seerr', 6);
final sources = _FakeCatalogSourcesProvider([trakt, mal, anilist, simkl, plex, seerr]); final sources = _FakeCatalogSourcesProvider([trakt, mal, anilist, simkl, plex, seerr]);
final explore = ExploreProvider(sources); final explore = ExploreProvider(sources);
@@ -251,14 +246,14 @@ void main() {
expect(find.text('AniList Movie'), findsAtLeast(1)); 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); final sources = await _pumpExplore(tester);
await sources.setActiveSource(CatalogSourceId.plex); await sources.setActiveSource(CatalogSourceId.plex);
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 100)); 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)); expect(find.text('Plex Recommendation'), findsAtLeast(1));
}); });
@@ -18,6 +18,8 @@ Map<String, Object?> _metadata({
String ratingKey = 'plex-movie-1', String ratingKey = 'plex-movie-1',
String type = 'movie', String type = 'movie',
String title = 'Inception', String title = 'Inception',
String imdb = 'tt1375666',
int tmdb = 27205,
}) => { }) => {
'ratingKey': ratingKey, 'ratingKey': ratingKey,
'guid': 'plex://$type/$ratingKey', 'guid': 'plex://$type/$ratingKey',
@@ -34,11 +36,23 @@ Map<String, Object?> _metadata({
{'tag': 'Science Fiction'}, {'tag': 'Science Fiction'},
], ],
'Guid': [ 'Guid': [
{'id': 'imdb://tt1375666'}, {'id': 'imdb://$imdb'},
{'id': 'tmdb://27205'}, {'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() { void main() {
group('PlexCatalogSource', () { group('PlexCatalogSource', () {
test('watchlist uses offset paging and maps Plex metadata', () async { test('watchlist uses offset paging and maps Plex metadata', () async {
@@ -83,49 +97,52 @@ void main() {
expect(item.genres, ['Science Fiction']); 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 requests = <http.Request>[];
final source = PlexCatalogSource( final source = PlexCatalogSource(
PlexDiscoverClient( PlexDiscoverClient(
_session, _session,
httpClient: MockClient((request) async { httpClient: MockClient((request) async {
requests.add(request); requests.add(request);
if (request.url.path == '/hubs/sections/watchlist') { switch (request.url.path) {
return jsonResponse({ case '/hubs/sections/home':
'MediaContainer': { // Discover answers the section listing with placeholders: no
'Hub': [ // hub carries Metadata, so each shelf needs its own request.
{ return jsonResponse({
'hubIdentifier': 'because-watchlisted', 'MediaContainer': {
'key': '/hubs/sections/watchlist/because-watchlisted?source=watchlist', 'Hub': [
'title': 'Because You Watchlisted Inception', _placeholderHub('home.trending-plex', 'Trending on Plex'),
'totalSize': 4, _placeholderHub('home.genres', 'Browse by Genre', type: 'directory'),
'more': 1, _placeholderHub('home.new-trailers', 'New Trailers', type: 'clip'),
'Metadata': [ _placeholderHub('home.people', 'People'),
_metadata(), _placeholderHub('home.chris-nolan', 'The Films of Sir Christopher Nolan'),
_metadata(ratingKey: 'plex-show-1', type: 'show', title: 'Severance'), ],
{'ratingKey': 'person-1', 'type': 'person', 'title': 'A Person'}, },
], });
}, case '/hubs/sections/home/trending-plex':
{ return jsonResponse({
'hubIdentifier': 'people-only', 'MediaContainer': {
'key': '/hubs/sections/watchlist/people-only', 'Metadata': [
'title': 'People', _metadata(),
'Metadata': [ _metadata(ratingKey: 'plex-show-1', type: 'show', title: 'Severance'),
{'ratingKey': 'person-2', 'type': 'person', 'title': 'Another Person'}, _metadata(ratingKey: 'plex-movie-2', title: 'Interstellar'),
], ],
}, },
], });
}, case '/hubs/sections/home/people':
}); return jsonResponse({
} 'MediaContainer': {
if (request.url.path == '/hubs/sections/watchlist/because-watchlisted') { 'Metadata': [
return jsonResponse({ {'ratingKey': 'person-1', 'type': 'person', 'title': 'A Person'},
'MediaContainer': { ],
'offset': 2, },
'totalSize': 3, });
'Metadata': [_metadata(ratingKey: 'plex-movie-2', title: 'Interstellar')], case '/hubs/sections/home/chris-nolan':
}, return jsonResponse({
}); 'MediaContainer': {
'Metadata': [_metadata(ratingKey: 'plex-movie-3', title: 'The Prestige')],
},
});
} }
return jsonResponse({'error': 'unexpected'}, status: 500); return jsonResponse({'error': 'unexpected'}, status: 500);
}), }),
@@ -135,24 +152,109 @@ void main() {
final hubs = await source.fetchHubs(limit: 2); final hubs = await source.fetchHubs(limit: 2);
expect(requests.first.url.queryParameters, containsPair('count', '3')); // Browse-category and trailer shelves cannot produce a catalog item, so
expect(requests.first.url.queryParameters, containsPair('includeMeta', '1')); // they never cost a hydration request.
expect(hubs, hasLength(1)); expect(requests.map((request) => request.url.path), [
expect(hubs.single.id, 'because-watchlisted'); '/hubs/sections/home',
expect(hubs.single.title, 'Because You Watchlisted Inception'); '/hubs/sections/home/trending-plex',
expect(hubs.single.page.items.map((item) => item.title), ['Inception', 'Severance']); '/hubs/sections/home/people',
expect(hubs.single.page.hasMore, isTrue); '/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); // The people-only shelf maps to nothing and drops out; provider order
// and titles survive for the rest.
expect(requests.last.url.queryParameters, containsPair('source', 'watchlist')); expect(hubs.map((hub) => hub.id), ['home.trending-plex', 'home.chris-nolan']);
expect(requests.last.url.queryParameters, containsPair('X-Plex-Container-Start', '2')); expect(hubs.first.title, 'Trending on Plex');
expect(requests.last.url.queryParameters, containsPair('X-Plex-Container-Size', '2')); expect(hubs.first.page.items.map((item) => item.title), ['Inception', 'Severance']);
expect(page.items.single.title, 'Interstellar'); expect(hubs.first.page.hasMore, isTrue);
expect(page.hasMore, isFalse); 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 requests = <http.Request>[];
final source = PlexCatalogSource( final source = PlexCatalogSource(
PlexDiscoverClient( PlexDiscoverClient(
@@ -171,6 +273,7 @@ void main() {
expect(page.hasMore, isFalse); expect(page.hasMore, isFalse);
expect(requests, isEmpty); expect(requests, isEmpty);
}); });
test('search sends Plex universal-search values and deduplicates media', () async { test('search sends Plex universal-search values and deduplicates media', () async {
late http.Request captured; late http.Request captured;
final source = PlexCatalogSource( final source = PlexCatalogSource(