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;
|
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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user