import '../../media/media_kind.dart'; import '../../models/catalog/catalog_cast_member.dart'; import '../../models/catalog/catalog_item.dart'; import '../../models/catalog/catalog_metadata.dart'; import '../../utils/external_ids.dart'; import '../../utils/app_logger.dart'; import '../../utils/country_codes.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 bool includeImageVariants; final Map _hubKeys = {}; PlexCatalogSource(this._client, {this.includeImageVariants = false}); @override CatalogSourceId get id => CatalogSourceId.plex; @override String get displayName => 'Plex'; @override List get supportedRows => const [CatalogRowId.watchlist]; @override bool get supportsWatchlist => true; @override String get watchlistLogLabel => 'Plex: watchlist'; // Discover validates X-Plex-Container-Size against a cap it drifts // without notice (#1715: 500 became invalid). 100 keeps the snapshot at // few requests while staying well under the observed cap, and the client // degrades to 25-item chunks if the cap ever drops below it; more pages // preserve the 5000-entry coverage. @override int get watchlistPageLimit => 100; @override int get watchlistMaxPages => 50; @override Future 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, totalResults: response.totalResults, ); } @override Future> fetchHubs({int limit = 25}) async { final fetched = await _client.getHomeHubs(limit: limit, includeImageVariants: includeImageVariants); final keys = {}; final result = []; for (final hub in fetched) { final items = _fromMetadata(hub.page.items); if (items.isEmpty) continue; final style = _hubStyleFor(hub.style); keys[hub.id] = hub.key; result.add( CatalogHub( id: hub.id, title: hub.title, style: style, page: CatalogPage(items: items, hasMore: hub.page.hasMore, totalResults: hub.page.totalResults), ), ); } _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 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, includeImageVariants: includeImageVariants); return CatalogPage(items: _fromMetadata(response.items), hasMore: false, totalResults: response.totalResults); } @override Future> search(String query, {int limit = 30}) async { final trimmed = query.trim(); if (trimmed.isEmpty) return const []; return _fromSearchResults(await _client.search(trimmed, limit: limit)); } @override Future 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 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 fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async { final ratingKey = item.ids.plex; if (ratingKey == null || ratingKey.isEmpty) return CatalogDetail(item: item); final metadataFuture = _loadDetailMetadata(ratingKey); final relatedFuture = _loadRelatedMetadata(ratingKey); final metadata = await metadataFuture; final relatedMetadata = await relatedFuture; final detailItem = metadata == null ? null : _toCatalogItem(metadata); final safeCastLimit = castLimit < 0 ? 0 : castLimit; final safeRelatedLimit = relatedLimit < 0 ? 0 : relatedLimit; return CatalogDetail( item: detailItem == null ? item : item.enrichedWith(detailItem), cast: metadata == null ? const [] : [ for (final role in flexibleMapList(metadata['Role']).take(safeCastLimit)) if (_nonEmptyString(role['tag'] ?? role['name']) case final String name) CatalogCastMember( name: name, secondary: _nonEmptyString(role['role']), imageUrl: _nonEmptyString(role['thumb']), ), ], related: _fromMetadata(relatedMetadata).take(safeRelatedLimit).toList(), ); } Future?> _loadDetailMetadata(String ratingKey) async { try { return await _client.getMetadata(ratingKey); } catch (error, stackTrace) { appLogger.w('Plex: detail metadata failed', error: error, stackTrace: stackTrace); return null; } } Future>> _loadRelatedMetadata(String ratingKey) async { try { return await _client.getRelated(ratingKey); } catch (error, stackTrace) { appLogger.w('Plex: related metadata failed', error: error, stackTrace: stackTrace); return const []; } } @override Future 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 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 _fromMetadata(List> metadata) { final items = []; final seen = {}; for (final value in metadata) { final item = _toCatalogItem(value); if (item != null && seen.add(item.identityKey)) items.add(item); } return items; } List _fromSearchResults(List results) { final items = []; final seen = {}; for (final result in results) { final item = _toCatalogItem(result.metadata, relevance: result.score); if (item != null && seen.add(item.identityKey)) items.add(item); } return items; } CatalogItem? _toCatalogItem(Map metadata, {double? relevance}) { 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 = _tagsFor(metadata['Genre']); final studios = _tagsFor(metadata['Studio']); final countries = _countriesFor(metadata['Country']); final credits = _creditsFor(metadata); final ratings = _ratingsFor(metadata); final durationMs = flexibleInt(metadata['duration']); final continuing = flexibleBoolNullable(metadata['isContinuingSeries']); final nextAirDate = kind == MediaKind.show ? _date(metadata['nextEpisodeOriginallyAvailableAt']) ?? _date(metadata['nextSeasonOriginallyAvailableAt']) : null; final playState = CatalogPlayState( viewCount: flexibleInt(metadata['viewCount']), viewOffsetMs: flexibleInt(metadata['viewOffset']), viewedLeafCount: flexibleInt(metadata['viewedLeafCount']), ); String? coverPoster; String? coverArt; String? background; String? clearLogo; String? clearLogoWide; String? imageBanner; for (final image in flexibleMapList(metadata['Image'])) { final type = _nonEmptyString(image['type']); final url = _nonEmptyString(image['url']); if (type == null || url == null) continue; switch (type) { case 'coverPoster': coverPoster ??= url; case 'coverArt': coverArt ??= url; case 'background': background ??= url; case 'clearLogo': clearLogo ??= url; case 'clearLogoWide': clearLogoWide ??= url; case 'banner': imageBanner ??= url; } } final headlineRating = _normalizedRating(metadata['rating']); final audienceRating = _normalizedRating(metadata['audienceRating']); return CatalogItem( source: CatalogSourceId.plex, kind: kind, title: title, year: flexibleInt(metadata['year']), overview: _overviewFor(metadata), runtimeMinutes: durationMs == null ? null : Duration(milliseconds: durationMs).inMinutes, rating: headlineRating ?? audienceRating, genres: genres, certification: _nonEmptyString(metadata['contentRating']), airStatus: kind == MediaKind.show && continuing != null ? continuing ? CatalogAirStatus.airing : CatalogAirStatus.ended : null, episodeCount: kind == MediaKind.show ? flexibleInt(metadata['leafCount']) : null, network: _nonEmptyString(metadata['studio'] ?? metadata['network']), ids: ids, posterUrl: _nonEmptyString(metadata['thumb']) ?? coverPoster ?? coverArt, backdropUrl: _nonEmptyString(metadata['art']) ?? background, logoUrl: clearLogoWide ?? clearLogo, bannerUrl: _nonEmptyString(metadata['banner']) ?? imageBanner, ratings: ratings, nextEpisode: nextAirDate == null ? null : CatalogNextEpisode(airsAt: nextAirDate), releaseDate: _date(metadata['originallyAvailableAt']), endDate: kind == MediaKind.show && continuing == false ? _date(metadata['lastEpisodeOriginallyAvailableAt']) ?? _date(metadata['lastSeasonOriginallyAvailableAt']) : null, originalTitle: _nonEmptyString(metadata['originalTitle']), tagline: _nonEmptyString(metadata['tagline']), studios: studios, countries: countries, credits: credits, contentAdvisory: _contentAdvisoryFor(metadata), budget: flexibleInt(metadata['budget']), revenue: flexibleInt(metadata['revenue']), playState: playState.isEmpty ? null : playState, relevance: relevance, ); } static List? _tagsFor(Object? value) { final tags = []; final seen = {}; for (final entry in flexibleMapList(value)) { final tag = _nonEmptyString(entry['tag'] ?? entry['name']); if (tag != null && seen.add(tag)) tags.add(tag); } return tags.isEmpty ? null : tags; } static List? _countriesFor(Object? value) { final countries = []; final seen = {}; for (final entry in flexibleMapList(value)) { final tag = _nonEmptyString(entry['tag'] ?? entry['name']); if (tag == null) continue; final country = CountryCodes.normalizeCode(tag); if (country.isNotEmpty && seen.add(country)) countries.add(country); } return countries.isEmpty ? null : countries; } static List? _creditsFor(Map metadata) { final credits = []; final seen = {}; void addCredits(Object? value, CatalogCreditRole role) { for (final entry in flexibleMapList(value)) { final name = _nonEmptyString(entry['tag'] ?? entry['name'] ?? entry['role']); if (name != null && seen.add('${role.name}\u0000$name')) { credits.add(CatalogCredit(name: name, role: role)); } } } addCredits(metadata['Director'], CatalogCreditRole.director); addCredits(metadata['Writer'], CatalogCreditRole.writer); addCredits(metadata['Producer'], CatalogCreditRole.producer); return credits.isEmpty ? null : credits; } static List? _ratingsFor(Map metadata) { final ratings = []; final imdbVotes = flexibleInt(metadata['imdbRatingCount']); void add(Object? rawValue, {required String fallbackSource, Object? image, Object? type}) { final value = _normalizedRating(rawValue); if (value == null) return; final source = _ratingSource(image: image, type: type, fallback: fallbackSource); if (ratings.any((rating) => rating.source == source && rating.value == value)) return; ratings.add(CatalogRatingSource(source: source, value: value, votes: source == 'imdb' ? imdbVotes : null)); } add(metadata['rating'], fallbackSource: 'critic', image: metadata['ratingImage']); add(metadata['audienceRating'], fallbackSource: 'audience', image: metadata['audienceRatingImage']); for (final rating in flexibleMapList(metadata['Rating'])) { add(rating['value'], fallbackSource: 'audience', image: rating['image'], type: rating['type']); } return ratings.isEmpty ? null : ratings; } static String _ratingSource({Object? image, Object? type, required String fallback}) { final scheme = Uri.tryParse(_nonEmptyString(image) ?? '')?.scheme.toLowerCase(); if (scheme == 'imdb') return 'imdb'; if (scheme == 'themoviedb' || scheme == 'tmdb') return 'tmdb'; final ratingType = _nonEmptyString(type)?.toLowerCase() ?? fallback; if (scheme == 'rottentomatoes') { return ratingType == 'critic' ? 'rottenTomatoesCritic' : 'rottenTomatoesAudience'; } return switch (ratingType) { 'critic' => 'critic', 'audience' => 'audience', _ => fallback, }; } static double? _normalizedRating(Object? value) { final rating = flexibleDouble(value); if (rating == null || !rating.isFinite || rating < 0 || rating > 100) return null; return rating > 10 ? rating / 10 : rating; } static String? _overviewFor(Map metadata) { var overview = _nonEmptyString(metadata['summary']); for (final summary in flexibleMapList(metadata['Summary'])) { final candidate = _nonEmptyString(summary['tag'] ?? summary['summary']); if (candidate != null && (overview == null || candidate.length > overview.length)) { overview = candidate; } } return overview; } static String? _contentAdvisoryFor(Map metadata) { final advisories = []; final seen = {}; for (final commonSense in flexibleMapList(metadata['CommonSenseMedia'])) { final oneLiner = _nonEmptyString(commonSense['oneLiner']); int? age; for (final rating in flexibleMapList(commonSense['AgeRating'])) { age ??= flexibleInt(rating['age']); } final advisory = [if (age != null) '$age+', ?oneLiner].join(' · '); if (advisory.isNotEmpty && seen.add(advisory)) advisories.add(advisory); } return advisories.isEmpty ? null : advisories.join('\n'); } static DateTime? _date(Object? value) => value is String ? DateTime.tryParse(value) : null; static MediaKind? _kindFor(Object? type) => switch (type) { 'movie' => MediaKind.movie, 'show' => MediaKind.show, _ => null, }; static CatalogHubStyle? _hubStyleFor(Object? style) => switch (style) { 'shelf' => CatalogHubStyle.shelf, 'availabilityPlatforms' => CatalogHubStyle.availabilityPlatforms, _ => null, }; static CatalogItemIds _idsFor(Map 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(); } }