Files
plezy/lib/services/catalog/plex_catalog_source.dart
T
edde746 82d6c5d555 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.
2026-07-28 05:26:37 +02:00

241 lines
8.1 KiB
Dart

import '../../media/media_kind.dart';
import '../../models/catalog/catalog_cast_member.dart';
import '../../models/catalog/catalog_item.dart';
import '../../utils/external_ids.dart';
import '../../utils/json_utils.dart';
import '../plex_discover_client.dart';
import 'catalog_source.dart';
import 'catalog_watchlist_machinery.dart';
/// [CatalogSource] backed by the active Plex profile's universal watchlist
/// 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 = {};
PlexCatalogSource(this._client);
@override
CatalogSourceId get id => CatalogSourceId.plex;
@override
String get displayName => 'Plex';
@override
List<CatalogRowId> get supportedRows => const [CatalogRowId.watchlist];
@override
bool get supportsWatchlist => true;
@override
String get watchlistLogLabel => 'Plex: watchlist';
@override
int get watchlistPageLimit => 500;
@override
int get watchlistMaxPages => 10;
@override
Future<CatalogPage> fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) async {
if (row != CatalogRowId.watchlist) throw ArgumentError('Plex does not serve ${row.name}');
final response = await _client.getWatchlist(page: page, limit: limit);
return CatalogPage(items: _fromMetadata(response.items), hasMore: response.hasMore);
}
@override
Future<List<CatalogHub>> fetchHubs({int limit = 25}) async {
final fetched = await _client.getHomeHubs(limit: limit);
final keys = <String, String>{};
final result = <CatalogHub>[];
for (final hub in fetched) {
final items = _fromMetadata(hub.page.items);
if (items.isEmpty) continue;
keys[hub.id] = hub.key;
result.add(
CatalogHub(
id: hub.id,
title: hub.title,
page: CatalogPage(items: items, hasMore: hub.page.hasMore),
),
);
}
_hubKeys
..clear()
..addAll(keys);
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 || page > 1) return const CatalogPage(items: []);
final response = await _client.getHub(key, limit: limit);
return CatalogPage(items: _fromMetadata(response.items), hasMore: false);
}
@override
Future<List<CatalogItem>> search(String query, {int limit = 30}) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
return _fromMetadata(await _client.search(trimmed, limit: limit));
}
@override
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async {
if (!external.hasAny) return null;
final metadata = await _client.match(external);
final matchedKind = metadata == null ? null : _kindFor(metadata['type']);
if (metadata == null || matchedKind != kind) return null;
final ids = _idsFor(metadata);
if (ids.plex == null) return null;
return CatalogItemIds(
plex: ids.plex,
imdb: ids.imdb ?? external.imdb,
tmdb: ids.tmdb ?? external.tmdb,
tvdb: ids.tvdb ?? external.tvdb,
);
}
@override
Future<CatalogItemIds> resolveWatchlistMutationIds(MediaKind kind, CatalogItemIds ids) async {
if (ids.plex != null && ids.plex!.isNotEmpty) return ids;
final resolved = await resolveItemIds(kind, ids.toExternalIds());
if (resolved?.plex == null || resolved!.plex!.isEmpty) {
throw StateError('Plex: no rating key for ${ids.canonicalKey ?? 'item'}');
}
return resolved;
}
@override
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async {
final ratingKey = item.ids.plex;
if (ratingKey == null) return const [];
final metadata = await _client.getMetadata(ratingKey);
if (metadata == null) return const [];
return [
for (final role in flexibleMapList(metadata['Role']).take(limit))
if (_nonEmptyString(role['tag'] ?? role['name']) case final String name)
CatalogCastMember(
name: name,
secondary: _nonEmptyString(role['role']),
imageUrl: _nonEmptyString(role['thumb']),
),
];
}
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async {
final ratingKey = item.ids.plex;
if (ratingKey == null) return const [];
return _fromMetadata(await _client.getRelated(ratingKey)).take(limit).toList();
}
@override
Future<WatchlistKeyPage> fetchWatchlistKeyPage(int page, int limit) async {
final response = await _client.getWatchlist(page: page, limit: limit);
return (
groups: [for (final item in _fromMetadata(response.items)) membershipKeysFor(item.kind, item.ids)],
hasMore: response.hasMore,
);
}
@override
Future<void> performWatchlistMutation(MediaKind kind, CatalogItemIds ids, {required bool add}) async {
final ratingKey = ids.plex;
if (ratingKey == null || ratingKey.isEmpty) {
throw ArgumentError('Plex watchlist mutations require a Plex rating key');
}
await _client.setWatchlisted(ratingKey, add: add);
}
List<CatalogItem> _fromMetadata(List<Map<String, dynamic>> metadata) {
final items = <CatalogItem>[];
final seen = <String>{};
for (final value in metadata) {
final item = _toCatalogItem(value);
if (item != null && seen.add(item.identityKey)) items.add(item);
}
return items;
}
CatalogItem? _toCatalogItem(Map<String, dynamic> metadata) {
final kind = _kindFor(metadata['type']);
final title = _nonEmptyString(metadata['title']);
final ids = _idsFor(metadata);
if (kind == null || title == null || ids.plex == null) return null;
final genres = [
for (final genre in flexibleMapList(metadata['Genre']))
if (_nonEmptyString(genre['tag']) case final String name) name,
];
final durationMs = flexibleInt(metadata['duration']);
return CatalogItem(
source: CatalogSourceId.plex,
kind: kind,
title: title,
year: flexibleInt(metadata['year']),
overview: _nonEmptyString(metadata['summary']),
runtimeMinutes: durationMs == null ? null : Duration(milliseconds: durationMs).inMinutes,
rating: flexibleDouble(metadata['rating'] ?? metadata['audienceRating']),
genres: genres.isEmpty ? null : genres,
certification: _nonEmptyString(metadata['contentRating']),
episodeCount: kind == MediaKind.show ? flexibleInt(metadata['leafCount']) : null,
network: _nonEmptyString(metadata['studio'] ?? metadata['network']),
ids: ids,
posterUrl: _nonEmptyString(metadata['thumb']),
backdropUrl: _nonEmptyString(metadata['art']),
);
}
static MediaKind? _kindFor(Object? type) => switch (type) {
'movie' => MediaKind.movie,
'show' => MediaKind.show,
_ => null,
};
static CatalogItemIds _idsFor(Map<String, dynamic> metadata) {
String? imdb;
int? tmdb;
int? tvdb;
void consumeGuid(Object? value) {
final guid = _nonEmptyString(value);
if (guid == null) return;
final separator = guid.indexOf('://');
if (separator <= 0) return;
final provider = guid.substring(0, separator).toLowerCase();
final id = guid.substring(separator + 3);
switch (provider) {
case 'imdb':
imdb ??= id;
case 'tmdb':
tmdb ??= int.tryParse(id);
case 'tvdb':
tvdb ??= int.tryParse(id);
}
}
consumeGuid(metadata['guid']);
for (final guid in flexibleMapList(metadata['Guid'])) {
consumeGuid(guid['id']);
}
return CatalogItemIds(plex: _nonEmptyString(metadata['ratingKey']), imdb: imdb, tmdb: tmdb, tvdb: tvdb);
}
static String? _nonEmptyString(Object? value) {
final string = value?.toString().trim();
return string == null || string.isEmpty ? null : string;
}
@override
void dispose() {
disposeWatchlistMachinery();
_client.dispose();
}
}