Files
plezy/lib/services/catalog/trakt_catalog_source.dart
T

219 lines
8.1 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 '../../media/media_kind.dart';
import '../../models/catalog/catalog_cast_member.dart';
import '../../models/catalog/catalog_item.dart';
import '../../models/trakt/trakt_catalog_entry.dart';
import '../../models/trakt/trakt_catalog_media.dart';
import '../../models/trakt/trakt_ids.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);
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,
);
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,
);
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);
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);
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<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async {
final id = item.ids.trakt?.toString() ?? item.ids.slug ?? item.ids.imdb;
if (id == null) return const [];
final type = item.kind == MediaKind.movie ? TraktCatalogType.movies : TraktCatalogType.shows;
final cast = await _client.getPeople(type, id);
return [
for (final entry in cast.take(limit))
if (entry.person?.name case final String name when name.isNotEmpty)
CatalogCastMember(
name: name,
secondary: entry.characters?.firstOrNull,
imageUrl: entry.person?.images?.primaryHeadshot,
),
];
}
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async {
final id = item.ids.trakt?.toString() ?? item.ids.slug ?? item.ids.imdb;
if (id == null) return const [];
final type = item.kind == MediaKind.movie ? TraktCatalogType.movies : TraktCatalogType.shows;
return _fromMedia(await _client.getRelated(type, id, limit: limit), item.kind);
}
@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)!),
];
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 m, MediaKind kind) => CatalogItem(
source: CatalogSourceId.trakt,
kind: kind,
title: m.title ?? '',
year: m.year,
overview: m.overview,
runtimeMinutes: m.runtime,
rating: m.rating,
votes: m.votes,
genres: m.genres,
certification: m.certification,
trailerUrl: m.trailer,
airStatus: airStatusFor(m.status),
episodeCount: m.airedEpisodes,
network: m.network,
ids: CatalogItemIds(trakt: m.ids.trakt, slug: m.ids.slug, imdb: m.ids.imdb, tmdb: m.ids.tmdb, tvdb: m.ids.tvdb),
posterUrl: m.images?.primaryPoster,
backdropUrl: m.images?.primaryBackdrop,
);
@override
void dispose() {
disposeWatchlistMachinery();
}
}