feat(trakt): catalog API and shared catalog core (watchlist, discover, recommendations)

This commit is contained in:
edde746
2026-07-10 07:06:21 +02:00
parent ede80d225f
commit 4e6ea5b4c5
21 changed files with 1714 additions and 35 deletions
@@ -0,0 +1,13 @@
/// One entry of a catalog item's cast section: an actor with their character
/// (Trakt) or an anime character with its role (MAL).
class CatalogCastMember {
final String name;
/// Character name (Trakt) or role such as `Main` / `Supporting` (MAL).
final String? secondary;
/// Absolute https headshot/portrait URL.
final String? imageUrl;
const CatalogCastMember({required this.name, this.secondary, this.imageUrl});
}
+205
View File
@@ -0,0 +1,205 @@
import '../../media/media_backend.dart';
import '../../media/media_item.dart';
import '../../media/media_kind.dart';
import '../../utils/external_ids.dart';
/// External catalog providers that can back the Explore tab.
enum CatalogSourceId { trakt, mal, seerr }
/// Normalized airing/production status across providers (Trakt `status`,
/// MAL `status`). Null when unknown or uninteresting (released movies).
enum CatalogAirStatus { airing, ended, canceled, upcoming }
/// External ids identifying a catalog item across providers and media
/// servers. A superset of [ExternalIds] that also carries provider-native
/// ids (Trakt id/slug, MAL id today).
class CatalogItemIds {
final int? trakt;
final String? slug;
final int? mal;
final String? imdb;
final int? tmdb;
final int? tvdb;
const CatalogItemIds({this.trakt, this.slug, this.mal, this.imdb, this.tmdb, this.tvdb});
factory CatalogItemIds.fromExternal(ExternalIds ids) =>
CatalogItemIds(imdb: ids.imdb, tmdb: ids.tmdb, tvdb: ids.tvdb);
bool get hasAny => imdb != null || tmdb != null || tvdb != null || trakt != null || slug != null || mal != null;
/// Stable identity key preferring globally-unique ids. Callers must
/// namespace it by [MediaKind] (tmdb movie/show ids can collide).
String? get canonicalKey {
if (imdb != null) return 'imdb:$imdb';
if (tmdb != null) return 'tmdb:$tmdb';
if (tvdb != null) return 'tvdb:$tvdb';
if (mal != null) return 'mal:$mal';
if (trakt != null) return 'trakt:$trakt';
if (slug != null) return 'slug:$slug';
return null;
}
/// Every id-form key. Membership checks match on any of these so that two
/// sides carrying different id subsets (e.g. Jellyfin tmdb-only vs a Trakt
/// entry keyed by imdb) still intersect.
List<String> get allKeys => [
if (imdb != null) 'imdb:$imdb',
if (tmdb != null) 'tmdb:$tmdb',
if (tvdb != null) 'tvdb:$tvdb',
if (mal != null) 'mal:$mal',
if (trakt != null) 'trakt:$trakt',
if (slug != null) 'slug:$slug',
];
ExternalIds toExternalIds() => ExternalIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb);
Map<String, Object?> toJson() => {
if (trakt != null) 'trakt': trakt,
if (slug != null) 'slug': slug,
if (mal != null) 'mal': mal,
if (imdb != null) 'imdb': imdb,
if (tmdb != null) 'tmdb': tmdb,
if (tvdb != null) 'tvdb': tvdb,
};
factory CatalogItemIds.fromJson(Map<String, Object?> json) => CatalogItemIds(
trakt: json['trakt'] as int?,
slug: json['slug'] as String?,
mal: json['mal'] as int?,
imdb: json['imdb'] as String?,
tmdb: json['tmdb'] as int?,
tvdb: json['tvdb'] as int?,
);
}
/// A movie or show from an external catalog provider (Trakt trending, the
/// user's watchlist, ...). Not a library item: it has no server id and is
/// matched back to the user's libraries on demand.
class CatalogItem {
/// Key under [MediaItem.raw] where a synthesized rendering item stashes its
/// backing [CatalogItem] (see [toMediaItem]).
static const String rawKey = 'plezyCatalog';
final CatalogSourceId source;
/// [MediaKind.movie] or [MediaKind.show].
final MediaKind kind;
final String title;
final int? year;
final String? overview;
final int? runtimeMinutes;
/// Provider community rating, 010.
final double? rating;
/// How many users the rating is based on (Trakt votes, MAL scoring users).
final int? votes;
final List<String>? genres;
final String? certification;
final String? trailerUrl;
final CatalogAirStatus? airStatus;
/// Aired episodes (Trakt) or total episodes (MAL); shows only.
final int? episodeCount;
/// TV network (Trakt) or animation studio (MAL).
final String? network;
final CatalogItemIds ids;
/// Absolute https URLs served by the provider's CDN.
final String? posterUrl;
final String? backdropUrl;
const CatalogItem({
required this.source,
required this.kind,
required this.title,
this.year,
this.overview,
this.runtimeMinutes,
this.rating,
this.votes,
this.genres,
this.certification,
this.trailerUrl,
this.airStatus,
this.episodeCount,
this.network,
required this.ids,
this.posterUrl,
this.backdropUrl,
});
/// Kind-namespaced identity key for caches and dedupe.
String get identityKey => '${kind.id}/${ids.canonicalKey}';
/// Synthesize a [MediaItem] so catalog items flow through the existing
/// shelf/grid/card stack ([MediaHub.items] is `List<MediaItem>`).
///
/// The result is rendering-only and must never be persisted or handed to
/// server-backed paths: `serverId` stays null and taps are intercepted by
/// the catalog branch in `navigateToMediaItem`. `backend` is an arbitrary
/// tag required by the union type. Poster/backdrop are absolute URLs, which
/// the image pipeline loads directly.
MediaItem toMediaItem() => MediaItem(
id: 'catalog:${source.name}:$identityKey',
backend: MediaBackend.plex,
kind: kind,
title: title,
summary: overview,
year: year,
contentRating: certification,
durationMs: runtimeMinutes == null ? null : Duration(minutes: runtimeMinutes!).inMilliseconds,
rating: rating,
genres: genres,
thumbPath: posterUrl,
artPath: backdropUrl,
raw: {rawKey: toJson()},
);
Map<String, Object?> toJson() => {
'source': source.name,
'kind': kind.id,
'title': title,
if (year != null) 'year': year,
if (overview != null) 'overview': overview,
if (runtimeMinutes != null) 'runtimeMinutes': runtimeMinutes,
if (rating != null) 'rating': rating,
if (votes != null) 'votes': votes,
if (genres != null) 'genres': genres,
if (certification != null) 'certification': certification,
if (trailerUrl != null) 'trailerUrl': trailerUrl,
if (airStatus != null) 'airStatus': airStatus!.name,
if (episodeCount != null) 'episodeCount': episodeCount,
if (network != null) 'network': network,
'ids': ids.toJson(),
if (posterUrl != null) 'posterUrl': posterUrl,
if (backdropUrl != null) 'backdropUrl': backdropUrl,
};
factory CatalogItem.fromJson(Map<String, Object?> json) => CatalogItem(
// Round-trips are same-session toJson output — an unknown source is a
// bug, and defaulting it would bind watchlist/cast/related calls to the
// wrong provider. Fail loudly instead.
source:
CatalogSourceId.values.asNameMap()[json['source']] ??
(throw ArgumentError('Unknown catalog source: ${json['source']}')),
kind: MediaKind.fromString(json['kind'] as String?),
title: json['title'] as String? ?? '',
year: json['year'] as int?,
overview: json['overview'] as String?,
runtimeMinutes: json['runtimeMinutes'] as int?,
rating: (json['rating'] as num?)?.toDouble(),
votes: json['votes'] as int?,
genres: (json['genres'] as List?)?.cast<String>(),
certification: json['certification'] as String?,
trailerUrl: json['trailerUrl'] as String?,
airStatus: CatalogAirStatus.values.asNameMap()[json['airStatus']],
episodeCount: json['episodeCount'] as int?,
network: json['network'] as String?,
ids: CatalogItemIds.fromJson((json['ids'] as Map?)?.cast<String, Object?>() ?? const {}),
posterUrl: json['posterUrl'] as String?,
backdropUrl: json['backdropUrl'] as String?,
);
}