Files
plezy/lib/services/catalog/trakt_catalog_source.dart
T
edde746 27acbaf435 feat(explore): surface the catalog data providers already return
Explore shelf cards drew a poster, a title and a year. An audit of all six
catalog sources found the rest was lost at two boundaries — the wire-to-DTO
mapping and the DTO-to-CatalogItem mapping — and then simply not drawn: the
grid card fell through every branch of buildMetadataSubtitle to the year-only
case, while the list card used by search already composed certification,
runtime and rating from fields the synthesized MediaItem already held.

Extend CatalogItem with the neutral facts every provider had been dropping:
attributed rating sources, leaderboard ranks that keep their season window,
audience counters that keep their timeframe, broadcast slots, next-episode air
times, server availability and request state, exact release dates, alternate
titles, format, source material, studios, countries, languages, credits, tags,
links, artwork variants, play state, gallery art and background prose. Replace
fetchCast and fetchRelated with one fetchDetail returning the enriched item,
its cast, its recommendations and labelled franchise relations without adding
a request: sources needing two calls keep two and run them concurrently with
isolated failures.

Map those fields in all six sources, widening only field selections that cost
no extra round trip — MAL's fields list, AniList's selection set and a bounded
row cast that lets detail skip its character call, Trakt's guest stars, Seerr's
language parameter and TMDB size ladder, and Plex's includeUserState. Plex hub
artwork widens only on TV, where the spotlight is its only consumer, because it
doubles the payload.

Render them: a rating-first caption and bounded badges on the shelf card,
labelled sections on the detail screen, provider hub styles and result counts
on shelves, and logo, banner and accent art in the TV spotlight.

Verified against live Plex, AniList, Simkl and MAL responses, and on a Pixel 7.
2026-07-29 06:47:54 +02:00

398 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import '../../i18n/strings.g.dart';
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 '../../models/trakt/trakt_cast_entry.dart';
import '../../models/trakt/trakt_catalog_entry.dart';
import '../../models/trakt/trakt_catalog_media.dart';
import '../../models/trakt/trakt_ids.dart';
import '../../utils/app_logger.dart';
import '../../utils/country_codes.dart';
import '../../utils/external_ids.dart';
import '../trakt/trakt_client.dart';
import '../trakt/trakt_constants.dart';
import 'catalog_source.dart';
import 'catalog_watchlist_machinery.dart';
/// [CatalogSource] backed by the Trakt API.
///
/// Wraps the catalog [TraktClient] owned by `TraktAccountProvider` (not owned
/// here — never disposed by this class). Watchlist membership rides
/// [CatalogWatchlistMachinery] with kind-namespaced keys over every id form.
class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
final TraktClient _client;
TraktCatalogSource(this._client);
@override
String get watchlistLogLabel => 'Trakt: watchlist';
/// Full-snapshot paging: 4 × 250 covers 1000 watchlist entries.
@override
int get watchlistPageLimit => 250;
@override
int get watchlistMaxPages => 4;
@override
CatalogSourceId get id => CatalogSourceId.trakt;
@override
String get displayName => 'Trakt';
@override
List<CatalogRowId> get supportedRows => const [
CatalogRowId.watchlist,
CatalogRowId.recommendedMovies,
CatalogRowId.recommendedShows,
CatalogRowId.trendingMovies,
CatalogRowId.trendingShows,
CatalogRowId.popularMovies,
CatalogRowId.popularShows,
];
@override
bool get supportsWatchlist => true;
@override
Future<CatalogPage> fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) async {
switch (row) {
case CatalogRowId.watchlist:
final res = await _client.getWatchlist(page: page, limit: limit);
return CatalogPage(items: _fromEntries(res.items), hasMore: res.hasMore, totalResults: res.itemCount);
case CatalogRowId.recommendedMovies:
return CatalogPage(
items: _fromMedia(await _client.getRecommended(TraktCatalogType.movies, limit: limit), MediaKind.movie),
);
case CatalogRowId.recommendedShows:
return CatalogPage(
items: _fromMedia(await _client.getRecommended(TraktCatalogType.shows, limit: limit), MediaKind.show),
);
case CatalogRowId.trendingMovies:
final res = await _client.getTrending(TraktCatalogType.movies, page: page, limit: limit);
return CatalogPage(
items: _fromEntries(res.items, kind: MediaKind.movie),
hasMore: res.hasMore,
totalResults: res.itemCount,
);
case CatalogRowId.trendingShows:
final res = await _client.getTrending(TraktCatalogType.shows, page: page, limit: limit);
return CatalogPage(
items: _fromEntries(res.items, kind: MediaKind.show),
hasMore: res.hasMore,
totalResults: res.itemCount,
);
case CatalogRowId.popularMovies:
final res = await _client.getPopular(TraktCatalogType.movies, page: page, limit: limit);
return CatalogPage(
items: _fromMedia(res.items, MediaKind.movie),
hasMore: res.hasMore,
totalResults: res.itemCount,
);
case CatalogRowId.popularShows:
final res = await _client.getPopular(TraktCatalogType.shows, page: page, limit: limit);
return CatalogPage(
items: _fromMedia(res.items, MediaKind.show),
hasMore: res.hasMore,
totalResults: res.itemCount,
);
case CatalogRowId.trendingAnime:
case CatalogRowId.suggestedAnime:
case CatalogRowId.airingAnime:
case CatalogRowId.popularAnime:
case CatalogRowId.trending:
case CatalogRowId.upcomingMovies:
case CatalogRowId.upcomingShows:
throw ArgumentError('Trakt does not serve ${row.name}');
}
}
@override
Future<List<CatalogItem>> search(String query, {int limit = 30}) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
final res = await _client.searchCatalog(trimmed, limit: limit);
return _fromEntries(res.items);
}
@override
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async =>
external.hasAny ? CatalogItemIds.fromExternal(external) : null;
@override
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
final id = item.ids.trakt?.toString() ?? item.ids.slug ?? item.ids.imdb;
if (id == null) return CatalogDetail(item: item);
final type = item.kind == MediaKind.movie ? TraktCatalogType.movies : TraktCatalogType.shows;
// Start both requests before awaiting either. Trakt keeps people and
// related titles on separate endpoints, so two calls are the minimum.
final peopleFuture = _withoutFailure(_client.getPeople(type, id), 'people', item);
final relatedFuture = _withoutFailure(_client.getRelated(type, id, limit: relatedLimit), 'related', item);
final people = await peopleFuture;
final related = await relatedFuture;
final credits = _creditsFrom(people?.crew);
final enrichedItem = credits.isEmpty
? item
: item.enrichedWith(
CatalogItem(
source: CatalogSourceId.trakt,
kind: item.kind,
title: '',
ids: const CatalogItemIds(),
credits: credits,
),
);
return CatalogDetail(
item: enrichedItem,
cast: _castFrom(people, castLimit),
related: related == null ? const [] : _fromMedia(related, item.kind),
);
}
Future<T?> _withoutFailure<T>(Future<T> request, String requestName, CatalogItem item) async {
try {
return await request;
} catch (error, stackTrace) {
appLogger.d(
'Trakt: catalog $requestName load failed for ${item.identityKey}',
error: error,
stackTrace: stackTrace,
);
return null;
}
}
List<CatalogCastMember> _castFrom(TraktPeople? people, int limit) {
if (people == null || limit <= 0) return const [];
final cast = <CatalogCastMember>[];
// `guest_stars` can be a very large response. Regular cast stays first and
// the combined retained list is bounded even though Trakt sends all rows.
for (final entry in people.cast.followedBy(people.guestStars)) {
if (cast.length >= limit) break;
if (entry.person?.name case final String name when name.isNotEmpty) {
cast.add(
CatalogCastMember(
name: name,
secondary: _castSecondary(entry),
imageUrl: entry.person?.images?.primaryHeadshot,
),
);
}
}
return cast;
}
static String? _castSecondary(TraktCastEntry entry) {
final characters = entry.characters?.where((character) => character.isNotEmpty).join(', ');
final episodeCount = entry.episodeCount;
final parts = [
if (characters != null && characters.isNotEmpty) characters,
if (episodeCount != null) t.explore.badge.episodesShort(n: episodeCount),
];
return parts.isEmpty ? null : parts.join(' · ');
}
static List<CatalogCredit> _creditsFrom(List<TraktCastEntry>? crew) {
if (crew == null || crew.isEmpty) return const [];
final credits = <CatalogCredit>[];
final seen = <String>{};
for (final role in const [CatalogCreditRole.director, CatalogCreditRole.writer, CatalogCreditRole.producer]) {
for (final entry in crew) {
final name = entry.person?.name;
if (name == null || name.isEmpty || !_hasCreditRole(entry, role)) continue;
final key = '${role.name}/${name.toLowerCase()}';
if (seen.add(key)) credits.add(CatalogCredit(name: name, role: role));
}
}
return credits;
}
static bool _hasCreditRole(TraktCastEntry entry, CatalogCreditRole role) {
final job = entry.job;
final jobs = <String>[
if (job != null) job.toLowerCase(),
for (final job in entry.jobs ?? const <String>[]) job.toLowerCase(),
];
return switch (role) {
CatalogCreditRole.director => jobs.any((job) => job == 'director' || job == 'co-director'),
CatalogCreditRole.writer => jobs.any((job) => const {'writer', 'screenplay', 'story', 'teleplay'}.contains(job)),
CatalogCreditRole.producer => jobs.any((job) => job == 'producer' || job.endsWith(' producer')),
_ => false,
};
}
@override
Future<WatchlistKeyPage> fetchWatchlistKeyPage(int page, int limit) async {
final res = await _client.getWatchlist(page: page, limit: limit);
return (
groups: [for (final item in _fromEntries(res.items)) membershipKeysFor(item.kind, item.ids)],
hasMore: res.hasMore,
);
}
@override
Future<void> performWatchlistMutation(MediaKind kind, CatalogItemIds ids, {required bool add}) async {
final body = {
kind == MediaKind.show ? 'shows' : 'movies': [
{'ids': TraktIds(trakt: ids.trakt, slug: ids.slug, imdb: ids.imdb, tmdb: ids.tmdb, tvdb: ids.tvdb).toJson()},
],
};
add ? await _client.addToWatchlist(body) : await _client.removeFromWatchlist(body);
}
@override
List<String> membershipKeysFor(MediaKind kind, CatalogItemIds ids) => [
for (final key in ids.allKeys) '${kind.id}/$key',
];
List<CatalogItem> _fromEntries(List<TraktCatalogEntry> entries, {MediaKind? kind}) => [
for (final entry in entries)
if (entry.media != null && _entryKind(entry, kind) != null && entry.media!.ids.hasAny)
_toCatalogItem(
entry.media!,
_entryKind(entry, kind)!,
watchers: entry.watchers,
addedAt: _parseDate(entry.listedAt),
),
];
List<CatalogItem> _fromMedia(List<TraktCatalogMedia> media, MediaKind kind) => [
for (final m in media)
if (m.ids.hasAny) _toCatalogItem(m, kind),
];
/// Watchlist entries carry a `type` field; trending entries are typed by
/// which wrapper key is present; fixed-kind endpoints pass [fixed].
static MediaKind? _entryKind(TraktCatalogEntry entry, MediaKind? fixed) {
if (fixed != null) return fixed;
return switch (entry.type) {
'movie' => MediaKind.movie,
'show' => MediaKind.show,
null => entry.isShow ? MediaKind.show : MediaKind.movie,
_ => null, // seasons/episodes on the watchlist are not Explore rows
};
}
/// Normalize Trakt's status strings. Movies' `released` maps to null —
/// a "Released" chip on every movie is noise.
static CatalogAirStatus? airStatusFor(String? status) => switch (status) {
'returning series' || 'continuing' => CatalogAirStatus.airing,
'ended' => CatalogAirStatus.ended,
'canceled' => CatalogAirStatus.canceled,
'in production' ||
'post production' ||
'planned' ||
'upcoming' ||
'pilot' ||
'rumored' => CatalogAirStatus.upcoming,
_ => null,
};
CatalogItem _toCatalogItem(TraktCatalogMedia media, MediaKind kind, {int? watchers, DateTime? addedAt}) {
final audience = CatalogAudience(watchingNow: watchers, comments: media.commentCount);
final broadcast = CatalogBroadcast(
weekday: weekdayFor(media.airs?.day),
time: _broadcastTime(media.airs?.time),
timezone: media.airs?.timezone,
);
return CatalogItem(
source: CatalogSourceId.trakt,
kind: kind,
title: media.title ?? '',
year: media.year,
overview: media.overview,
runtimeMinutes: media.runtime,
rating: media.rating,
votes: media.votes,
genres: media.genres,
certification: media.certification,
trailerUrl: media.trailer,
airStatus: airStatusFor(media.status),
episodeCount: media.airedEpisodes,
network: media.network,
ids: CatalogItemIds(
trakt: media.ids.trakt,
slug: media.ids.slug,
imdb: media.ids.imdb,
tmdb: media.ids.tmdb,
tvdb: media.ids.tvdb,
),
posterUrl: media.images?.primaryPoster,
backdropUrl: media.images?.primaryBackdrop,
logoUrl: media.images?.primaryLogo,
audience: audience.isEmpty ? null : audience,
broadcast: broadcast.isEmpty ? null : broadcast,
releaseDate: _parseDate(media.released ?? media.firstAired),
addedAt: addedAt,
originalTitle: media.originalTitle,
tagline: media.tagline,
countries: media.country == null || media.country!.isEmpty ? null : [CountryCodes.normalizeCode(media.country!)],
languages: _languagesFor(media),
recommenders: _recommendersFor(media),
);
}
static int? weekdayFor(String? day) => switch (day?.toLowerCase()) {
'monday' => DateTime.monday,
'tuesday' => DateTime.tuesday,
'wednesday' => DateTime.wednesday,
'thursday' => DateTime.thursday,
'friday' => DateTime.friday,
'saturday' => DateTime.saturday,
'sunday' => DateTime.sunday,
_ => null,
};
static String? _broadcastTime(String? time) {
if (time == null || time.length < 5 || time[2] != ':') return null;
final hour = int.tryParse(time.substring(0, 2));
final minute = int.tryParse(time.substring(3, 5));
if (hour == null || hour < 0 || hour > 23 || minute == null || minute < 0 || minute > 59) {
return null;
}
return time.substring(0, 5);
}
static DateTime? _parseDate(String? raw) => raw == null ? null : DateTime.tryParse(raw);
static List<String>? _languagesFor(TraktCatalogMedia media) {
final languages = <String>{
if (media.language case final String language when language.isNotEmpty) language.toLowerCase(),
...?media.languages?.where((language) => language.isNotEmpty).map((language) => language.toLowerCase()),
...?media.availableTranslations
?.where((language) => language.isNotEmpty)
.map((language) => language.toLowerCase()),
};
return languages.isEmpty ? null : languages.toList(growable: false);
}
static List<CatalogRecommender>? _recommendersFor(TraktCatalogMedia media) {
final recommenders = <CatalogRecommender>[
for (final user in media.favoritedBy ?? const <TraktRecommendationUser>[])
if (user.username case final String username when username.isNotEmpty)
CatalogRecommender(
username: username,
name: user.name,
note: user.notes,
reason: CatalogRecommendationReason.favorited,
),
for (final user in media.recommendedBy ?? const <TraktRecommendationUser>[])
if (user.username case final String username when username.isNotEmpty)
CatalogRecommender(
username: username,
name: user.name,
note: user.notes,
reason: CatalogRecommendationReason.recommended,
),
];
return recommenders.isEmpty ? null : recommenders;
}
@override
void dispose() {
disposeWatchlistMachinery();
}
}