From 4e6ea5b4c582c4b66ecec0fa70842f35662ef7d5 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:06:21 +0200 Subject: [PATCH] feat(trakt): catalog API and shared catalog core (watchlist, discover, recommendations) --- lib/media/catalog_item_ref.dart | 16 + lib/models/catalog/catalog_cast_member.dart | 13 + lib/models/catalog/catalog_item.dart | 205 ++++++++++++ lib/models/trakt/trakt_cast_entry.dart | 37 ++ lib/models/trakt/trakt_cast_entry.g.dart | 31 ++ lib/models/trakt/trakt_catalog_entry.dart | 31 ++ lib/models/trakt/trakt_catalog_entry.g.dart | 16 + lib/models/trakt/trakt_catalog_media.dart | 61 ++++ lib/models/trakt/trakt_catalog_media.g.dart | 29 ++ lib/models/trakt/trakt_images.dart | 33 ++ lib/models/trakt/trakt_images.g.dart | 15 + lib/providers/trakt_account_provider.dart | 13 + lib/services/catalog/catalog_source.dart | 109 ++++++ .../catalog/catalog_watchlist_machinery.dart | 165 +++++++++ .../catalog/trakt_catalog_source.dart | 217 ++++++++++++ lib/services/trakt/trakt_client.dart | 158 +++++++-- lib/services/trakt/trakt_constants.dart | 9 +- lib/services/trakt/trakt_page.dart | 23 ++ lib/services/trakt/trakt_sync_queue.dart | 7 +- .../catalog/trakt_catalog_source_test.dart | 316 ++++++++++++++++++ test/services/trakt_catalog_test.dart | 245 ++++++++++++++ 21 files changed, 1714 insertions(+), 35 deletions(-) create mode 100644 lib/media/catalog_item_ref.dart create mode 100644 lib/models/catalog/catalog_cast_member.dart create mode 100644 lib/models/catalog/catalog_item.dart create mode 100644 lib/models/trakt/trakt_cast_entry.dart create mode 100644 lib/models/trakt/trakt_cast_entry.g.dart create mode 100644 lib/models/trakt/trakt_catalog_entry.dart create mode 100644 lib/models/trakt/trakt_catalog_entry.g.dart create mode 100644 lib/models/trakt/trakt_catalog_media.dart create mode 100644 lib/models/trakt/trakt_catalog_media.g.dart create mode 100644 lib/models/trakt/trakt_images.dart create mode 100644 lib/models/trakt/trakt_images.g.dart create mode 100644 lib/services/catalog/catalog_source.dart create mode 100644 lib/services/catalog/catalog_watchlist_machinery.dart create mode 100644 lib/services/catalog/trakt_catalog_source.dart create mode 100644 lib/services/trakt/trakt_page.dart create mode 100644 test/services/catalog/trakt_catalog_source_test.dart create mode 100644 test/services/trakt_catalog_test.dart diff --git a/lib/media/catalog_item_ref.dart b/lib/media/catalog_item_ref.dart new file mode 100644 index 00000000..c0508717 --- /dev/null +++ b/lib/media/catalog_item_ref.dart @@ -0,0 +1,16 @@ +import '../models/catalog/catalog_item.dart'; +import 'media_item.dart'; + +/// Recognizes [MediaItem]s synthesized from a [CatalogItem] (see +/// [CatalogItem.toMediaItem]). These are rendering-only stand-ins with no +/// server id; taps and menus must route through catalog paths instead of +/// server-backed ones. +extension CatalogMediaItemX on MediaItem { + bool get isCatalogItem => raw?[CatalogItem.rawKey] != null; + + CatalogItem? get catalogItem { + final data = raw?[CatalogItem.rawKey]; + if (data is! Map) return null; + return CatalogItem.fromJson(data.cast()); + } +} diff --git a/lib/models/catalog/catalog_cast_member.dart b/lib/models/catalog/catalog_cast_member.dart new file mode 100644 index 00000000..382b2ef1 --- /dev/null +++ b/lib/models/catalog/catalog_cast_member.dart @@ -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}); +} diff --git a/lib/models/catalog/catalog_item.dart b/lib/models/catalog/catalog_item.dart new file mode 100644 index 00000000..addc8ee8 --- /dev/null +++ b/lib/models/catalog/catalog_item.dart @@ -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 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 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 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, 0–10. + final double? rating; + + /// How many users the rating is based on (Trakt votes, MAL scoring users). + final int? votes; + final List? 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`). + /// + /// 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 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 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(), + 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() ?? const {}), + posterUrl: json['posterUrl'] as String?, + backdropUrl: json['backdropUrl'] as String?, + ); +} diff --git a/lib/models/trakt/trakt_cast_entry.dart b/lib/models/trakt/trakt_cast_entry.dart new file mode 100644 index 00000000..16261589 --- /dev/null +++ b/lib/models/trakt/trakt_cast_entry.dart @@ -0,0 +1,37 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'trakt_images.dart'; + +part 'trakt_cast_entry.g.dart'; + +@JsonSerializable(createToJson: false) +class TraktPersonImages { + final List? headshot; + + const TraktPersonImages({this.headshot}); + + String? get primaryHeadshot => TraktImages.firstUrl(headshot); + + factory TraktPersonImages.fromJson(Map json) => _$TraktPersonImagesFromJson(json); +} + +@JsonSerializable(createToJson: false) +class TraktPerson { + final String? name; + final TraktPersonImages? images; + + const TraktPerson({this.name, this.images}); + + factory TraktPerson.fromJson(Map json) => _$TraktPersonFromJson(json); +} + +/// One cast credit from `GET /{movies|shows}/{id}/people`. +@JsonSerializable(createToJson: false) +class TraktCastEntry { + final List? characters; + final TraktPerson? person; + + const TraktCastEntry({this.characters, this.person}); + + factory TraktCastEntry.fromJson(Map json) => _$TraktCastEntryFromJson(json); +} diff --git a/lib/models/trakt/trakt_cast_entry.g.dart b/lib/models/trakt/trakt_cast_entry.g.dart new file mode 100644 index 00000000..571f9f39 --- /dev/null +++ b/lib/models/trakt/trakt_cast_entry.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'trakt_cast_entry.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TraktPersonImages _$TraktPersonImagesFromJson(Map json) => + TraktPersonImages( + headshot: (json['headshot'] as List?) + ?.map((e) => e as String) + .toList(), + ); + +TraktPerson _$TraktPersonFromJson(Map json) => TraktPerson( + name: json['name'] as String?, + images: json['images'] == null + ? null + : TraktPersonImages.fromJson(json['images'] as Map), +); + +TraktCastEntry _$TraktCastEntryFromJson(Map json) => + TraktCastEntry( + characters: (json['characters'] as List?) + ?.map((e) => e as String) + .toList(), + person: json['person'] == null + ? null + : TraktPerson.fromJson(json['person'] as Map), + ); diff --git a/lib/models/trakt/trakt_catalog_entry.dart b/lib/models/trakt/trakt_catalog_entry.dart new file mode 100644 index 00000000..1a9fc195 --- /dev/null +++ b/lib/models/trakt/trakt_catalog_entry.dart @@ -0,0 +1,31 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'trakt_catalog_media.dart'; + +part 'trakt_catalog_entry.g.dart'; + +/// A wrapped catalog list entry holding a movie or show. +/// +/// Covers both wrapper shapes Trakt returns: +/// - watchlist entries: `{rank, listed_at, type, movie|show}` +/// - trending entries: `{watchers, movie|show}` +@JsonSerializable(createToJson: false) +class TraktCatalogEntry { + final int? rank; + @JsonKey(name: 'listed_at') + final String? listedAt; + + /// `movie` or `show` on watchlist entries; absent on trending entries. + final String? type; + final int? watchers; + final TraktCatalogMedia? movie; + final TraktCatalogMedia? show; + + const TraktCatalogEntry({this.rank, this.listedAt, this.type, this.watchers, this.movie, this.show}); + + TraktCatalogMedia? get media => movie ?? show; + + bool get isShow => show != null; + + factory TraktCatalogEntry.fromJson(Map json) => _$TraktCatalogEntryFromJson(json); +} diff --git a/lib/models/trakt/trakt_catalog_entry.g.dart b/lib/models/trakt/trakt_catalog_entry.g.dart new file mode 100644 index 00000000..af4f3210 --- /dev/null +++ b/lib/models/trakt/trakt_catalog_entry.g.dart @@ -0,0 +1,16 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'trakt_catalog_entry.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TraktCatalogEntry _$TraktCatalogEntryFromJson(Map json) => TraktCatalogEntry( + rank: (json['rank'] as num?)?.toInt(), + listedAt: json['listed_at'] as String?, + type: json['type'] as String?, + watchers: (json['watchers'] as num?)?.toInt(), + movie: json['movie'] == null ? null : TraktCatalogMedia.fromJson(json['movie'] as Map), + show: json['show'] == null ? null : TraktCatalogMedia.fromJson(json['show'] as Map), +); diff --git a/lib/models/trakt/trakt_catalog_media.dart b/lib/models/trakt/trakt_catalog_media.dart new file mode 100644 index 00000000..8868d10e --- /dev/null +++ b/lib/models/trakt/trakt_catalog_media.dart @@ -0,0 +1,61 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'trakt_ids.dart'; +import 'trakt_images.dart'; + +part 'trakt_catalog_media.g.dart'; + +/// A movie or show summary from Trakt's catalog endpoints (`extended=full`). +/// +/// Trakt uses the same field names for movie and show objects; the fields +/// exclusive to one type (movie `released`, show `first_aired`, ...) are not +/// needed for the Explore surfaces, so a single class covers both. Whether an +/// instance is a movie or a show is known from the endpoint or wrapper key it +/// was parsed from (see [TraktCatalogEntry]). +@JsonSerializable(createToJson: false) +class TraktCatalogMedia { + final String? title; + final int? year; + final TraktIds ids; + final String? overview; + + /// Runtime in minutes. + final int? runtime; + + /// Trakt community rating, 0–10. + final double? rating; + final int? votes; + final List? genres; + final String? certification; + final String? trailer; + + /// Shows: `returning series` / `continuing` / `in production` / `planned` / + /// `upcoming` / `pilot` / `canceled` / `ended`. Movies: `released` / + /// `in production` / `post production` / `planned` / `rumored` / `canceled`. + final String? status; + + /// Shows only. + final String? network; + @JsonKey(name: 'aired_episodes') + final int? airedEpisodes; + final TraktImages? images; + + const TraktCatalogMedia({ + this.title, + this.year, + required this.ids, + this.overview, + this.runtime, + this.rating, + this.votes, + this.genres, + this.certification, + this.trailer, + this.status, + this.network, + this.airedEpisodes, + this.images, + }); + + factory TraktCatalogMedia.fromJson(Map json) => _$TraktCatalogMediaFromJson(json); +} diff --git a/lib/models/trakt/trakt_catalog_media.g.dart b/lib/models/trakt/trakt_catalog_media.g.dart new file mode 100644 index 00000000..16eda52a --- /dev/null +++ b/lib/models/trakt/trakt_catalog_media.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'trakt_catalog_media.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TraktCatalogMedia _$TraktCatalogMediaFromJson(Map json) => + TraktCatalogMedia( + title: json['title'] as String?, + year: (json['year'] as num?)?.toInt(), + ids: TraktIds.fromJson(json['ids'] as Map), + overview: json['overview'] as String?, + runtime: (json['runtime'] as num?)?.toInt(), + rating: (json['rating'] as num?)?.toDouble(), + votes: (json['votes'] as num?)?.toInt(), + genres: (json['genres'] as List?) + ?.map((e) => e as String) + .toList(), + certification: json['certification'] as String?, + trailer: json['trailer'] as String?, + status: json['status'] as String?, + network: json['network'] as String?, + airedEpisodes: (json['aired_episodes'] as num?)?.toInt(), + images: json['images'] == null + ? null + : TraktImages.fromJson(json['images'] as Map), + ); diff --git a/lib/models/trakt/trakt_images.dart b/lib/models/trakt/trakt_images.dart new file mode 100644 index 00000000..53c3147e --- /dev/null +++ b/lib/models/trakt/trakt_images.dart @@ -0,0 +1,33 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'trakt_images.g.dart'; + +/// Image URL arrays returned by Trakt's `?extended=images`. +/// +/// URLs are protocol-less (`walter-r2.trakt.tv/...`) and must be prefixed +/// with `https://`. Trakt requires clients to cache these images; loading +/// them through [PlexImageCacheManager]'s disk cache satisfies that. +@JsonSerializable(createToJson: false) +class TraktImages { + final List? poster; + final List? fanart; + final List? logo; + final List? banner; + final List? thumb; + + const TraktImages({this.poster, this.fanart, this.logo, this.banner, this.thumb}); + + String? get primaryPoster => firstUrl(poster); + + String? get primaryBackdrop => firstUrl(fanart) ?? firstUrl(thumb); + + /// First URL of a Trakt image array, https-prefixed (Trakt serves + /// protocol-less URLs). Shared with person headshots. + static String? firstUrl(List? urls) { + final url = urls?.firstOrNull; + if (url == null || url.isEmpty) return null; + return url.startsWith('http') ? url : 'https://$url'; + } + + factory TraktImages.fromJson(Map json) => _$TraktImagesFromJson(json); +} diff --git a/lib/models/trakt/trakt_images.g.dart b/lib/models/trakt/trakt_images.g.dart new file mode 100644 index 00000000..46fda3d6 --- /dev/null +++ b/lib/models/trakt/trakt_images.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'trakt_images.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TraktImages _$TraktImagesFromJson(Map json) => TraktImages( + poster: (json['poster'] as List?)?.map((e) => e as String).toList(), + fanart: (json['fanart'] as List?)?.map((e) => e as String).toList(), + logo: (json['logo'] as List?)?.map((e) => e as String).toList(), + banner: (json['banner'] as List?)?.map((e) => e as String).toList(), + thumb: (json['thumb'] as List?)?.map((e) => e as String).toList(), +); diff --git a/lib/providers/trakt_account_provider.dart b/lib/providers/trakt_account_provider.dart index 6d4f30a1..09886aa1 100644 --- a/lib/providers/trakt_account_provider.dart +++ b/lib/providers/trakt_account_provider.dart @@ -27,12 +27,18 @@ class TraktAccountProvider extends ChangeNotifier with DisposableChangeNotifierM int _bindingGeneration = 0; bool _isConnecting = false; Completer? _cancelCompleter; + TraktClient? _catalogClient; TrackerSession? get session => _session; bool get isConnected => _session != null; String? get username => _session?.username; bool get isConnecting => _isConnecting; + /// Client for the catalog/watchlist surfaces (Explore tab). Owned and + /// rebound here alongside the scrobble/sync services; null when + /// disconnected. + TraktClient? get catalogClient => _catalogClient; + /// Cancel an in-flight `connect()` (e.g. user dismissed the device-code /// dialog). Completing the completer both wakes the blocking `Future.any` /// race and flips `isCompleted` for the next sync check. @@ -134,6 +140,10 @@ class TraktAccountProvider extends ChangeNotifier with DisposableChangeNotifierM onSessionInvalidated: handleInvalidated, onSessionUpdated: handleUpdated, ); + _catalogClient?.dispose(); + _catalogClient = session == null + ? null + : TraktClient(session, onSessionInvalidated: handleInvalidated, onSessionUpdated: handleUpdated); safeNotifyListeners(); } @@ -146,6 +156,7 @@ class TraktAccountProvider extends ChangeNotifier with DisposableChangeNotifierM _session = session; TraktScrobbleService.instance.updateSession(session); TraktSyncService.instance.updateSession(session); + _catalogClient?.updateSession(session); unawaited(_store.save(userUuid, session)); safeNotifyListeners(); } @@ -175,6 +186,8 @@ class TraktAccountProvider extends ChangeNotifier with DisposableChangeNotifierM @override void dispose() { _auth.dispose(); + _catalogClient?.dispose(); + _catalogClient = null; super.dispose(); } } diff --git a/lib/services/catalog/catalog_source.dart b/lib/services/catalog/catalog_source.dart new file mode 100644 index 00000000..1d019c0f --- /dev/null +++ b/lib/services/catalog/catalog_source.dart @@ -0,0 +1,109 @@ +import 'package:flutter/foundation.dart'; + +import '../../media/media_kind.dart'; +import '../../models/catalog/catalog_cast_member.dart'; +import '../../models/catalog/catalog_item.dart'; +import '../../utils/external_ids.dart'; + +/// Content rows a catalog source can serve on the Explore tab. +enum CatalogRowId { + watchlist, + recommendedMovies, + recommendedShows, + trendingMovies, + trendingShows, + popularMovies, + popularShows, + // Anime rows (MAL has no movie/show split). + suggestedAnime, + airingAnime, + popularAnime, + // Seerr rows (its trending endpoint is mixed movie/TV). + trending, + upcomingMovies, + upcomingShows, +} + +/// Notify-guarded [ChangeNotifier] for [CatalogSource.watchlistChanges]: a +/// snapshot load or mutation that resolves after the source was disposed +/// (provider disconnected mid-session) must not trip the used-after-dispose +/// assert. +class WatchlistChangeNotifier extends ChangeNotifier { + bool _disposed = false; + + void notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} + +/// One page of a catalog row. +class CatalogPage { + final List items; + final bool hasMore; + + const CatalogPage({required this.items, this.hasMore = false}); +} + +/// A pluggable external catalog provider backing the Explore tab (Trakt +/// today; Overseerr/Jellyfin or MAL later). +/// +/// Implementations wrap an authenticated API client owned by their account +/// provider; disposing a source must not dispose that client. +abstract class CatalogSource { + CatalogSourceId get id; + + String get displayName; + + /// Rows this source serves, in display order. + List get supportedRows; + + /// Whether the source has a user watchlist that can be read and mutated. + bool get supportsWatchlist; + + Future fetchRow(CatalogRowId row, {int page = 1, int limit = 25}); + + /// Free-text title search for the Explore search screen. Returns an empty + /// list when the query is below the provider's minimum length (MAL + /// rejects queries under 3 characters). + Future> search(String query, {int limit = 30}); + + /// Cast of an item for its detail screen (actors with characters, or MAL + /// characters with roles), in billing order. One request, fetched lazily + /// on detail open; empty when the provider has none for this item. + Future> fetchCast(CatalogItem item, {int limit = 20}); + + /// "More like this" titles for an item's detail screen (Trakt related, + /// MAL recommendations, Seerr/TMDB recommendations). One request, fetched + /// lazily on detail open; empty when the provider has none. + Future> fetchRelated(CatalogItem item, {int limit = 20}); + + /// Load the full watchlist membership snapshot (coalesced; cached for the + /// session). [isOnWatchlist] returns null until this has completed once. + Future ensureWatchlistLoaded(); + + /// Whether the item is on the user's watchlist, or null when the snapshot + /// has not loaded yet. + bool? isOnWatchlist(MediaKind kind, CatalogItemIds ids); + + /// Resolve the ids this source needs for watchlist membership/mutation of + /// a library item, given the external ids its server knows. Returns null + /// when the item cannot exist in this source's domain (e.g. non-anime for + /// MAL) — callers hide the watchlist action then. + Future resolveItemIds(MediaKind kind, ExternalIds external); + + Future addToWatchlist(MediaKind kind, CatalogItemIds ids); + + Future removeFromWatchlist(MediaKind kind, CatalogItemIds ids); + + /// Fires after any watchlist membership change (mutation or snapshot load) + /// so watchers (Explore rows, detail-screen buttons) can rebuild. + Listenable get watchlistChanges; + + void dispose(); +} diff --git a/lib/services/catalog/catalog_watchlist_machinery.dart b/lib/services/catalog/catalog_watchlist_machinery.dart new file mode 100644 index 00000000..b82d3a71 --- /dev/null +++ b/lib/services/catalog/catalog_watchlist_machinery.dart @@ -0,0 +1,165 @@ +import 'package:flutter/foundation.dart'; + +import '../../media/media_kind.dart'; +import '../../models/catalog/catalog_item.dart'; +import '../../utils/app_logger.dart'; +import '../trackers/future_coalescer.dart'; +import 'catalog_source.dart'; + +/// One snapshot page of watchlist membership: each group holds every key +/// form of a single watchlist entry. +typedef WatchlistKeyPage = ({List> groups, bool hasMore}); + +/// Shared watchlist snapshot + optimistic-mutation machinery behind the +/// watchlist half of [CatalogSource] (Trakt, MAL). Implementations supply +/// key derivation, the snapshot page fetch, and the actual mutation call. +/// +/// The snapshot maps every key form of an entry to the entry's full key +/// group, so a mutation carrying only a subset of the entry's id forms (a +/// media-detail remove works from server-resolved external ids, which lack +/// Trakt's trakt/slug forms) still drops the WHOLE entry. With a flat key +/// set, the sibling keys survived a remove and any-match membership stayed +/// true for the rest of the session. +mixin CatalogWatchlistMachinery { + final WatchlistChangeNotifier _watchlistChanges = WatchlistChangeNotifier(); + final FutureCoalescer _watchlistLoad = FutureCoalescer(); + Map>? _watchlistKeyGroups; + + // ---------- Contract ---------- + + /// Log prefix naming the source and its list, e.g. `Trakt: watchlist`. + String get watchlistLogLabel; + + /// Full-snapshot paging bounds. + int get watchlistPageLimit; + int get watchlistMaxPages; + + /// Every membership key form of [ids] under [kind]'s namespace rules. + /// Empty when [ids] carries no form this source can key on. + List membershipKeysFor(MediaKind kind, CatalogItemIds ids); + + /// One page of the snapshot as key groups (one group per entry). + Future fetchWatchlistKeyPage(int page, int limit); + + /// Resolve [ids] to the concrete forms the mutation call needs (MAL maps + /// external ids to a MAL id via Fribb). Throw when the item cannot exist + /// in this source's domain. Default: pass-through. + Future resolveWatchlistMutationIds(MediaKind kind, CatalogItemIds ids) async => ids; + + /// The actual API mutation for the resolved [ids]. + Future performWatchlistMutation(MediaKind kind, CatalogItemIds ids, {required bool add}); + + // ---------- CatalogSource watchlist surface ---------- + + Listenable get watchlistChanges => _watchlistChanges; + + /// Load failures are logged and swallowed: membership stays unknown + /// (null) and the next call retries — every UI call site fires this + /// unawaited, so a flaky request must not become an uncaught error. + Future ensureWatchlistLoaded() { + if (_watchlistKeyGroups != null) return Future.value(); + return _watchlistLoad.run(_loadWatchlistSnapshot); + } + + Future _loadWatchlistSnapshot() async { + try { + final map = >{}; + var page = 1; + while (true) { + final res = await fetchWatchlistKeyPage(page, watchlistPageLimit); + for (final group in res.groups) { + final shared = group.toSet(); + for (final key in shared) { + map[key] = shared; + } + } + if (!res.hasMore) break; + if (page >= watchlistMaxPages) { + appLogger.w('$watchlistLogLabel snapshot truncated at ${map.length} keys ($page pages)'); + break; + } + page++; + } + _watchlistKeyGroups = map; + _watchlistChanges.notify(); + } catch (e) { + appLogger.w('$watchlistLogLabel snapshot load failed', error: e); + } + } + + bool? isOnWatchlist(MediaKind kind, CatalogItemIds ids) { + final map = _watchlistKeyGroups; + if (map == null) return null; + return membershipKeysFor(kind, ids).any(map.containsKey); + } + + Future addToWatchlist(MediaKind kind, CatalogItemIds ids) => _mutateWatchlist(kind, ids, add: true); + + Future removeFromWatchlist(MediaKind kind, CatalogItemIds ids) => _mutateWatchlist(kind, ids, add: false); + + Future _mutateWatchlist(MediaKind kind, CatalogItemIds ids, {required bool add}) async { + final resolved = await resolveWatchlistMutationIds(kind, ids); + final keys = membershipKeysFor(kind, resolved); + + // Optimistic: flip the snapshot first so UI toggles instantly; revert on + // failure. Callers surface the rethrown error. + final map = _watchlistKeyGroups; + Set? addedGroup; + List>? removedGroups; + var changed = false; + if (map != null && keys.isNotEmpty) { + if (add) { + addedGroup = keys.toSet(); + for (final key in addedGroup) { + map[key] = addedGroup; + } + changed = true; + } else { + removedGroups = _takeGroups(map, keys); + changed = removedGroups.isNotEmpty; + } + if (changed) _watchlistChanges.notify(); + } + + try { + await performWatchlistMutation(kind, resolved, add: add); + } catch (_) { + // Revert only if the snapshot wasn't replaced by a reload meanwhile. + if (changed && identical(map, _watchlistKeyGroups)) { + if (addedGroup != null) { + for (final key in addedGroup) { + if (identical(map![key], addedGroup)) map.remove(key); + } + } + for (final group in removedGroups ?? const >[]) { + for (final key in group) { + map![key] = group; + } + } + _watchlistChanges.notify(); + } + rethrow; + } + } + + /// Remove every entry group hit by [keys] from [map], returning them for + /// a potential revert. + static List> _takeGroups(Map> map, Iterable keys) { + final groups = >[]; + for (final key in keys) { + final group = map[key]; + if (group != null && !groups.any((existing) => identical(existing, group))) { + groups.add(group); + } + } + for (final group in groups) { + group.forEach(map.remove); + } + return groups; + } + + /// Call from the source's [CatalogSource.dispose]. + void disposeWatchlistMachinery() { + _watchlistChanges.dispose(); + } +} diff --git a/lib/services/catalog/trakt_catalog_source.dart b/lib/services/catalog/trakt_catalog_source.dart new file mode 100644 index 00000000..51f8fa86 --- /dev/null +++ b/lib/services/catalog/trakt_catalog_source.dart @@ -0,0 +1,217 @@ +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 get supportedRows => const [ + CatalogRowId.watchlist, + CatalogRowId.recommendedMovies, + CatalogRowId.recommendedShows, + CatalogRowId.trendingMovies, + CatalogRowId.trendingShows, + CatalogRowId.popularMovies, + CatalogRowId.popularShows, + ]; + + @override + bool get supportsWatchlist => true; + + @override + Future 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.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> 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 resolveItemIds(MediaKind kind, ExternalIds external) async => + external.hasAny ? CatalogItemIds.fromExternal(external) : null; + + @override + Future> 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> 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 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 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 membershipKeysFor(MediaKind kind, CatalogItemIds ids) => [ + for (final key in ids.allKeys) '${kind.id}/$key', + ]; + + List _fromEntries(List 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 _fromMedia(List 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(); + } +} diff --git a/lib/services/trakt/trakt_client.dart b/lib/services/trakt/trakt_client.dart index b4350944..e7a8e32e 100644 --- a/lib/services/trakt/trakt_client.dart +++ b/lib/services/trakt/trakt_client.dart @@ -3,14 +3,19 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +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_scrobble_request.dart'; import '../../models/trakt/trakt_user.dart'; import '../../utils/app_logger.dart'; +import '../trackers/future_coalescer.dart'; import '../trackers/tracker_constants.dart'; import '../trackers/tracker_exceptions.dart'; import '../trackers/tracker_http_client.dart'; import '../trackers/tracker_session.dart'; import 'trakt_constants.dart'; +import 'trakt_page.dart'; /// HTTP wrapper for the Trakt REST API. /// @@ -19,7 +24,7 @@ import 'trakt_constants.dart'; class TraktClient { static const Set _scrobbleAllowedStatuses = {200, 201, 409}; static const Set _permanentRefreshFailureStatuses = {400, 401, 403}; - static final Map> _refreshesByToken = {}; + static final KeyedFutureCoalescer _refreshesByToken = KeyedFutureCoalescer(); TrackerSession _session; final TrackerHttpClient _http; @@ -78,6 +83,100 @@ class TraktClient { return res is List ? res : const []; } + // --- Catalog endpoints (Explore tab) --- + + static const String _catalogExtended = 'extended=full,images'; + + /// `GET /sync/watchlist[/{type}/{sort}]`. A null [type] returns all entry + /// types mixed, in the user's rank order. Pagination is currently optional + /// on this endpoint; sending page/limit makes Trakt echo X-Pagination + /// headers. + Future> getWatchlist({ + TraktCatalogType? type, + String sort = 'added', + int page = 1, + int limit = 100, + }) async { + final path = type == null ? '/sync/watchlist' : '/sync/watchlist/${type.name}/$sort'; + final res = await _requestResponse('GET', '$path?$_catalogExtended&page=$page&limit=$limit'); + return TraktPage.fromResponse(res, _decodeEntries(res.body)); + } + + /// Items are wrapped as `{watchers, movie|show}`. Public endpoint, but sent + /// authenticated: the tab only exists with a session and per-user rate + /// limiting is cleaner than app-level. + Future> getTrending(TraktCatalogType type, {int page = 1, int limit = 25}) async { + final res = await _requestResponse('GET', '/${type.name}/trending?$_catalogExtended&page=$page&limit=$limit'); + return TraktPage.fromResponse(res, _decodeEntries(res.body)); + } + + /// Returns bare movie/show objects (not wrapped like trending). + Future> getPopular(TraktCatalogType type, {int page = 1, int limit = 25}) async { + final res = await _requestResponse('GET', '/${type.name}/popular?$_catalogExtended&page=$page&limit=$limit'); + return TraktPage.fromResponse(res, _decodeMedia(res.body)); + } + + /// Personalized recommendations. OAuth-required, limit-only (no pagination). + Future> getRecommended( + TraktCatalogType type, { + int limit = 25, + bool ignoreCollected = false, + bool ignoreWatchlisted = true, + }) async { + final res = await _requestResponse( + 'GET', + '/recommendations/${type.name}' + '?$_catalogExtended&limit=$limit&ignore_collected=$ignoreCollected&ignore_watchlisted=$ignoreWatchlisted', + ); + return _decodeMedia(res.body); + } + + /// Title search across movies and shows (`GET /search/movie,show`). + /// Results are wrapped `{type, score, movie|show}` like watchlist entries. + Future> searchCatalog(String query, {int page = 1, int limit = 25}) async { + final res = await _requestResponse( + 'GET', + '/search/movie,show?query=${Uri.encodeQueryComponent(query)}&$_catalogExtended&page=$page&limit=$limit', + ); + return TraktPage.fromResponse(res, _decodeEntries(res.body)); + } + + /// Similar titles (`GET /{movies|shows}/{id}/related`) — bare media + /// objects of the same type. [id] is a Trakt numeric id or slug. + Future> getRelated(TraktCatalogType type, String id, {int limit = 20}) async { + final res = await _requestResponse('GET', '/${type.name}/$id/related?$_catalogExtended&limit=$limit'); + return _decodeMedia(res.body); + } + + /// Cast credits of a title (`GET /{movies|shows}/{id}/people`), in billing + /// order. Crew is not parsed. [id] is a Trakt numeric id or slug. + Future> getPeople(TraktCatalogType type, String id) async { + final res = await _requestResponse('GET', '/${type.name}/$id/people?$_catalogExtended'); + final decoded = TrackerHttpClient.decodeJson(res.body); + if (decoded is! Map) return const []; + final cast = decoded['cast']; + if (cast is! List) return const []; + return [for (final e in cast.whereType>()) TraktCastEntry.fromJson(e)]; + } + + /// Body shape: `{"movies":[{"ids":{...}}],"shows":[{"ids":{...}}]}`. + Future addToWatchlist(Map body) => + _request('POST', '/sync/watchlist', body: body, allowStatuses: const {200, 201}); + + Future removeFromWatchlist(Map body) => _request('POST', '/sync/watchlist/remove', body: body); + + static List _decodeEntries(String body) { + final decoded = TrackerHttpClient.decodeJson(body); + if (decoded is! List) return const []; + return [for (final e in decoded.whereType>()) TraktCatalogEntry.fromJson(e)]; + } + + static List _decodeMedia(String body) { + final decoded = TrackerHttpClient.decodeJson(body); + if (decoded is! List) return const []; + return [for (final e in decoded.whereType>()) TraktCatalogMedia.fromJson(e)]; + } + /// Refresh the access token. Coalesces concurrent calls so /// duplicate POSTs don't race when multiple in-flight requests hit 401. Future refresh() async { @@ -88,31 +187,26 @@ class TraktClient { if (e.isPermanent) onSessionInvalidated(); rethrow; } - final existing = _refreshesByToken[refreshToken]; - if (existing != null) { - try { - final session = await existing; - if (_session.refreshToken == refreshToken) { - _session = session; - onSessionUpdated?.call(session); - } - return _session; - } on TrackerAuthException catch (e) { - if (e.isPermanent && _session.refreshToken == refreshToken) { - onSessionInvalidated(); - } - rethrow; + var initiated = false; + try { + final session = await _refreshesByToken.run(refreshToken, () { + initiated = true; + return _doRefresh(refreshToken); + }); + // No-op for the initiating client (_doRefresh already adopted, so its + // refreshToken moved on); joiners sharing the token adopt here. + if (_session.refreshToken == refreshToken) { + _session = session; + onSessionUpdated?.call(session); } + return _session; + } on TrackerAuthException catch (e) { + // The initiator's _doRefresh already invalidated; joiners do it here. + if (!initiated && e.isPermanent && _session.refreshToken == refreshToken) { + onSessionInvalidated(); + } + rethrow; } - - late final Future refresh; - refresh = _doRefresh(refreshToken).whenComplete(() { - if (identical(_refreshesByToken[refreshToken], refresh)) { - _refreshesByToken.remove(refreshToken); - } - }); - _refreshesByToken[refreshToken] = refresh; - return refresh; } Future _doRefresh(String refreshToken) async { @@ -187,6 +281,18 @@ class TraktClient { String path, { Map? body, Set allowStatuses = const {200, 201, 204}, + }) async { + final res = await _requestResponse(method, path, body: body, allowStatuses: allowStatuses); + return TrackerHttpClient.decodeJson(res.body); + } + + /// [_request] variant exposing the raw response for callers that need + /// headers (pagination). + Future _requestResponse( + String method, + String path, { + Map? body, + Set allowStatuses = const {200, 201, 204}, }) async { if (_session.needsRefresh) { try { @@ -203,9 +309,7 @@ class TraktClient { res = await _send(method, path, body: body); } - if (allowStatuses.contains(res.statusCode)) { - return TrackerHttpClient.decodeJson(res.body); - } + if (allowStatuses.contains(res.statusCode)) return res; if (res.statusCode == 429) { throw TrackerRateLimitException( diff --git a/lib/services/trakt/trakt_constants.dart b/lib/services/trakt/trakt_constants.dart index 92e669fb..c758c62c 100644 --- a/lib/services/trakt/trakt_constants.dart +++ b/lib/services/trakt/trakt_constants.dart @@ -37,14 +37,13 @@ class TraktConstants { } } -/// Build a `SharedPreferences` key scoped to the given Plex profile UUID. -/// Mirrors the `_userPrefix` pattern in [StorageService] so each profile gets -/// its own Trakt session and sync queue. -String traktUserKey(String userUuid, String baseKey) => userUuid.isEmpty ? baseKey : 'user_${userUuid}_$baseKey'; - /// Scrobble lifecycle state sent to Trakt's `/scrobble/{name}` endpoints. enum TraktScrobbleState { start, pause, stop } +/// Catalog list flavor for Trakt's discover/watchlist endpoints, named after +/// the URL path segment (`/movies/trending`, `/sync/watchlist/shows/...`). +enum TraktCatalogType { movies, shows } + /// Direction of a watched-status sync push. enum TraktSyncOp { add, diff --git a/lib/services/trakt/trakt_page.dart b/lib/services/trakt/trakt_page.dart new file mode 100644 index 00000000..5fc297f1 --- /dev/null +++ b/lib/services/trakt/trakt_page.dart @@ -0,0 +1,23 @@ +import 'package:http/http.dart' as http; + +/// One page of a paginated Trakt response, parsed from the +/// `X-Pagination-*` headers. +class TraktPage { + final List items; + final int page; + final int pageCount; + final int itemCount; + + const TraktPage({required this.items, required this.page, required this.pageCount, required this.itemCount}); + + bool get hasMore => page < pageCount; + + /// Endpoints where pagination is optional omit the headers; default to a + /// single page so callers never loop. + factory TraktPage.fromResponse(http.Response res, List items) => TraktPage( + items: items, + page: int.tryParse(res.headers['x-pagination-page'] ?? '') ?? 1, + pageCount: int.tryParse(res.headers['x-pagination-page-count'] ?? '') ?? 1, + itemCount: int.tryParse(res.headers['x-pagination-item-count'] ?? '') ?? items.length, + ); +} diff --git a/lib/services/trakt/trakt_sync_queue.dart b/lib/services/trakt/trakt_sync_queue.dart index 570f026c..4b59fa38 100644 --- a/lib/services/trakt/trakt_sync_queue.dart +++ b/lib/services/trakt/trakt_sync_queue.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import '../../models/trakt/trakt_ids.dart'; +import '../../profiles/profile.dart'; import '../base_shared_preferences_service.dart'; import '../../utils/app_logger.dart'; import 'trakt_constants.dart'; @@ -97,7 +98,7 @@ class TraktSyncQueue { Future> load(String userUuid) async { final prefs = await BaseSharedPreferencesService.sharedCache(); - final key = traktUserKey(userUuid, _baseKey); + final key = profileScopedPrefsKey(userUuid, _baseKey); final raw = prefs.getString(key); if (raw == null) return []; try { @@ -105,7 +106,7 @@ class TraktSyncQueue { return list.map((e) => TraktSyncQueueItem.fromJson(e as Map)).toList(); } catch (e, st) { appLogger.e('Trakt sync queue parse failed, discarding', error: e, stackTrace: st); - await prefs.setString(traktUserKey(userUuid, '${_baseKey}_corrupt'), raw); + await prefs.setString(profileScopedPrefsKey(userUuid, '${_baseKey}_corrupt'), raw); await prefs.remove(key); return []; } @@ -117,7 +118,7 @@ class TraktSyncQueue { Future _saveRaw(String userUuid, List items) async { final prefs = await BaseSharedPreferencesService.sharedCache(); - final key = traktUserKey(userUuid, _baseKey); + final key = profileScopedPrefsKey(userUuid, _baseKey); if (items.isEmpty) { await prefs.remove(key); } else { diff --git a/test/services/catalog/trakt_catalog_source_test.dart b/test/services/catalog/trakt_catalog_source_test.dart new file mode 100644 index 00000000..4f82aece --- /dev/null +++ b/test/services/catalog/trakt_catalog_source_test.dart @@ -0,0 +1,316 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/media/catalog_item_ref.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/models/catalog/catalog_item.dart'; +import 'package:plezy/services/catalog/catalog_source.dart'; +import 'package:plezy/services/catalog/trakt_catalog_source.dart'; +import 'package:plezy/services/trackers/tracker_session.dart'; +import 'package:plezy/services/trakt/trakt_client.dart'; + +TrackerSession _session() { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return TrackerSession( + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: now + 86400, + scope: 'public', + createdAt: now - 3600, + username: 'alice', + ); +} + +Map _watchlistBody() => { + 'entries': [ + { + 'rank': 1, + 'type': 'movie', + 'movie': { + 'title': 'The Matrix', + 'year': 1999, + 'ids': {'trakt': 1, 'imdb': 'tt0133093', 'tmdb': 603}, + }, + }, + { + 'rank': 2, + 'type': 'show', + 'show': { + 'title': 'Severance', + 'year': 2022, + 'ids': {'trakt': 2, 'imdb': 'tt11280740', 'tmdb': 95396, 'tvdb': 371980}, + 'status': 'returning series', + 'network': 'Apple TV+', + 'aired_episodes': 19, + 'votes': 7294, + 'rating': 8.5, + }, + }, + // Episode entries are not Explore rows and must be skipped. + { + 'rank': 3, + 'type': 'episode', + 'episode': { + 'title': 'Pilot', + 'ids': {'trakt': 99}, + }, + }, + ], +}; + +void main() { + group('TraktCatalogSource', () { + late List requests; + late List handlers; + late TraktClient client; + late TraktCatalogSource source; + + setUp(() { + requests = []; + handlers = []; + client = TraktClient( + _session(), + onSessionInvalidated: () => fail('should not invalidate'), + httpClient: MockClient((request) async { + requests.add(request); + if (handlers.isNotEmpty) return handlers.removeAt(0)(request); + return http.Response(json.encode(_watchlistBody()['entries']), 200); + }), + ); + source = TraktCatalogSource(client); + }); + + tearDown(() { + source.dispose(); + client.dispose(); + }); + + test('fetchRow(watchlist) maps mixed entries and skips non-movie/show types', () async { + final page = await source.fetchRow(CatalogRowId.watchlist); + + expect(requests.single.url.path, '/sync/watchlist'); + expect(page.items, hasLength(2)); + expect(page.items[0].kind, MediaKind.movie); + expect(page.items[0].identityKey, 'movie/imdb:tt0133093'); + expect(page.items[1].kind, MediaKind.show); + + // extended=full metadata flows through to the item. + final show = page.items[1]; + expect(show.airStatus, CatalogAirStatus.airing); + expect(show.network, 'Apple TV+'); + expect(show.episodeCount, 19); + expect(show.votes, 7294); + expect(show.rating, 8.5); + expect(page.items[0].airStatus, isNull); + + final rendered = page.items[0].toMediaItem(); + expect(rendered.serverId, isNull); + expect(rendered.title, 'The Matrix'); + expect(rendered.isCatalogItem, isTrue); + final roundTripped = rendered.catalogItem; + expect(roundTripped?.title, 'The Matrix'); + expect(roundTripped?.ids.imdb, 'tt0133093'); + expect(roundTripped?.kind, MediaKind.movie); + }); + + test('membership matches on any shared id form after snapshot load', () async { + expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isNull); + + var notified = 0; + source.watchlistChanges.addListener(() => notified++); + await source.ensureWatchlistLoaded(); + + expect(notified, 1); + // Query by tmdb only — snapshot entry also carries imdb/trakt. + expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isTrue); + // Same tmdb id under the other kind must not match. + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(tmdb: 603)), isFalse); + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(imdb: 'tt11280740')), isTrue); + expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(imdb: 'tt9999999')), isFalse); + }); + + test('addToWatchlist is optimistic and posts a typed ids body', () async { + await source.ensureWatchlistLoaded(); + requests.clear(); + + handlers.add((request) => http.Response('{"added":{"shows":1}}', 201)); + await source.addToWatchlist(MediaKind.show, const CatalogItemIds(imdb: 'tt0903747', tmdb: 1396)); + + final request = requests.single; + expect(request.url.path, '/sync/watchlist'); + expect(json.decode(request.body), { + 'shows': [ + { + 'ids': {'imdb': 'tt0903747', 'tmdb': 1396}, + }, + ], + }); + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(tmdb: 1396)), isTrue); + }); + + test('fetchCast maps people to cast members with https-prefixed headshots', () async { + handlers.add( + (request) => http.Response( + json.encode({ + 'cast': [ + { + 'characters': ['Walter White'], + 'person': { + 'name': 'Bryan Cranston', + 'images': { + 'headshot': ['media.trakt.tv/images/people/headshots/medium/25eb34a2d5.jpg.webp'], + }, + }, + }, + { + 'characters': [], + 'person': {'name': 'Aaron Paul'}, + }, + {'characters': []}, // no person — skipped + ], + 'crew': {}, + }), + 200, + ), + ); + + final cast = await source.fetchCast( + const CatalogItem( + source: CatalogSourceId.trakt, + kind: MediaKind.show, + title: 'Breaking Bad', + ids: CatalogItemIds(trakt: 1388, slug: 'breaking-bad'), + ), + ); + + expect(requests.single.url.path, '/shows/1388/people'); + expect(cast, hasLength(2)); + expect(cast[0].name, 'Bryan Cranston'); + expect(cast[0].secondary, 'Walter White'); + expect(cast[0].imageUrl, 'https://media.trakt.tv/images/people/headshots/medium/25eb34a2d5.jpg.webp'); + expect(cast[1].imageUrl, isNull); + expect(cast[1].secondary, isNull); + }); + + test('air status normalization covers the Trakt vocabulary', () { + expect(TraktCatalogSource.airStatusFor('returning series'), CatalogAirStatus.airing); + expect(TraktCatalogSource.airStatusFor('continuing'), CatalogAirStatus.airing); + expect(TraktCatalogSource.airStatusFor('ended'), CatalogAirStatus.ended); + expect(TraktCatalogSource.airStatusFor('canceled'), CatalogAirStatus.canceled); + expect(TraktCatalogSource.airStatusFor('in production'), CatalogAirStatus.upcoming); + expect(TraktCatalogSource.airStatusFor('post production'), CatalogAirStatus.upcoming); + expect(TraktCatalogSource.airStatusFor('released'), isNull); + expect(TraktCatalogSource.airStatusFor(null), isNull); + }); + + test('remove with a subset of id forms drops the sibling keys too', () async { + await source.ensureWatchlistLoaded(); + handlers.add((request) => http.Response('{"deleted":{"movies":1}}', 200)); + + // Media-detail path: ids come from server externals — no trakt/slug. + await source.removeFromWatchlist(MediaKind.movie, const CatalogItemIds(imdb: 'tt0133093', tmdb: 603)); + + expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isFalse); + // The sibling trakt id the mutation didn't carry must be gone as well. + expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(trakt: 1)), isFalse); + // The other entry is untouched. + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(imdb: 'tt11280740')), isTrue); + }); + + test('snapshot load failure is swallowed; membership stays unknown and retries', () async { + handlers.add((request) => http.Response('oops', 500)); + + await source.ensureWatchlistLoaded(); // must not throw + expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isNull); + + // Next call retries (default handler serves the snapshot). + await source.ensureWatchlistLoaded(); + expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isTrue); + }); + + test('failed mutation reverts the optimistic snapshot flip', () async { + await source.ensureWatchlistLoaded(); + + var notified = 0; + source.watchlistChanges.addListener(() => notified++); + handlers.add((request) => http.Response('oops', 500)); + + await expectLater( + source.removeFromWatchlist(MediaKind.movie, const CatalogItemIds(imdb: 'tt0133093')), + throwsA(anything), + ); + + expect(notified, 2); // optimistic flip + revert + expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(imdb: 'tt0133093')), isTrue); + }); + + test('search hits /search/movie,show and maps the typed wrappers', () async { + handlers.add((request) { + expect(request.url.path, '/search/movie,show'); + expect(request.url.queryParameters['query'], 'blade runner'); + return http.Response( + json.encode([ + { + 'type': 'movie', + 'score': 100.0, + 'movie': { + 'title': 'Blade Runner', + 'year': 1982, + 'ids': {'trakt': 3, 'imdb': 'tt0083658'}, + }, + }, + { + 'type': 'show', + 'score': 50.0, + 'show': { + 'title': 'Blade Runner: Black Lotus', + 'year': 2021, + 'ids': {'trakt': 4, 'tmdb': 93830}, + }, + }, + ]), + 200, + ); + }); + + final items = await source.search('blade runner'); + expect(items, hasLength(2)); + expect(items[0].kind, MediaKind.movie); + expect(items[0].title, 'Blade Runner'); + expect(items[1].kind, MediaKind.show); + }); + + test('search with a blank query returns empty without a request', () async { + expect(await source.search(' '), isEmpty); + expect(requests, isEmpty); + }); + + test('fetchRelated hits /related and keeps the item kind', () async { + handlers.add((request) { + expect(request.url.path, '/shows/2/related'); + return http.Response( + json.encode([ + { + 'title': 'Dark', + 'year': 2017, + 'ids': {'trakt': 5, 'tmdb': 70523}, + }, + ]), + 200, + ); + }); + + final item = CatalogItem( + source: CatalogSourceId.trakt, + kind: MediaKind.show, + title: 'Severance', + ids: const CatalogItemIds(trakt: 2), + ); + final related = await source.fetchRelated(item); + expect(related.single.title, 'Dark'); + expect(related.single.kind, MediaKind.show); + }); + }); +} diff --git a/test/services/trakt_catalog_test.dart b/test/services/trakt_catalog_test.dart new file mode 100644 index 00000000..f1113b76 --- /dev/null +++ b/test/services/trakt_catalog_test.dart @@ -0,0 +1,245 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/models/trakt/trakt_catalog_entry.dart'; +import 'package:plezy/models/trakt/trakt_catalog_media.dart'; +import 'package:plezy/models/trakt/trakt_images.dart'; +import 'package:plezy/services/trackers/tracker_session.dart'; +import 'package:plezy/services/trakt/trakt_client.dart'; +import 'package:plezy/services/trakt/trakt_constants.dart'; + +int _now() => DateTime.now().millisecondsSinceEpoch ~/ 1000; + +TrackerSession _session() { + final now = _now(); + return TrackerSession( + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: now + 86400, + scope: 'public', + createdAt: now - 3600, + username: 'alice', + ); +} + +Map _movieJson({int trakt = 1, String? posterUrl = 'walter-r2.trakt.tv/images/movies/p.webp'}) { + return { + 'title': 'The Matrix', + 'year': 1999, + 'ids': {'trakt': trakt, 'slug': 'the-matrix-1999', 'imdb': 'tt0133093', 'tmdb': 603}, + 'overview': 'A hacker learns the truth.', + 'runtime': 136, + 'rating': 8.7, + 'votes': 42000, + 'genres': ['action', 'sci-fi'], + 'certification': 'R', + 'trailer': 'https://youtube.com/watch?v=m8e-FF8MsqU', + 'images': { + 'poster': [?posterUrl], + 'fanart': ['walter-r2.trakt.tv/images/movies/f.webp'], + }, + }; +} + +Map _showJson() { + return { + 'title': 'Severance', + 'year': 2022, + 'ids': {'trakt': 2, 'slug': 'severance', 'imdb': 'tt11280740', 'tmdb': 95396, 'tvdb': 371980}, + 'overview': 'Work-life balance, surgically.', + 'runtime': 50, + 'rating': 8.9, + 'images': {}, + }; +} + +TraktClient _client(Future Function(http.Request) handler, {List? requests}) { + return TraktClient( + _session(), + onSessionInvalidated: () => fail('should not invalidate'), + httpClient: MockClient((request) { + requests?.add(request); + return handler(request); + }), + ); +} + +void main() { + group('TraktImages', () { + test('prefixes protocol-less CDN URLs with https', () { + final images = TraktImages.fromJson({ + 'poster': ['walter-r2.trakt.tv/images/movies/p.webp'], + }); + expect(images.primaryPoster, 'https://walter-r2.trakt.tv/images/movies/p.webp'); + }); + + test('keeps absolute URLs and falls back fanart -> thumb for backdrop', () { + final images = TraktImages.fromJson({ + 'poster': ['https://example.com/p.webp'], + 'thumb': ['walter-r2.trakt.tv/t.webp'], + }); + expect(images.primaryPoster, 'https://example.com/p.webp'); + expect(images.primaryBackdrop, 'https://walter-r2.trakt.tv/t.webp'); + }); + + test('returns null for missing or empty image arrays', () { + final images = TraktImages.fromJson({'poster': []}); + expect(images.primaryPoster, isNull); + expect(images.primaryBackdrop, isNull); + }); + }); + + group('TraktClient catalog', () { + test('getWatchlist parses wrapped entries and sends extended=full,images', () async { + final requests = []; + final client = _client(requests: requests, (request) async { + return http.Response( + json.encode([ + {'rank': 1, 'listed_at': '2026-01-01T00:00:00.000Z', 'type': 'movie', 'movie': _movieJson()}, + {'rank': 2, 'listed_at': '2026-01-02T00:00:00.000Z', 'type': 'show', 'show': _showJson()}, + ]), + 200, + headers: {'x-pagination-page': '1', 'x-pagination-page-count': '3', 'x-pagination-item-count': '250'}, + ); + }); + + final page = await client.getWatchlist(type: TraktCatalogType.movies); + + final request = requests.single; + expect(request.url.path, '/sync/watchlist/movies/added'); + expect(request.url.queryParameters['extended'], 'full,images'); + expect(request.headers['Authorization'], 'Bearer access'); + + expect(page.items, hasLength(2)); + expect(page.items[0].isShow, isFalse); + expect(page.items[0].media?.title, 'The Matrix'); + expect(page.items[0].media?.ids.imdb, 'tt0133093'); + expect(page.items[0].media?.images?.primaryPoster, 'https://walter-r2.trakt.tv/images/movies/p.webp'); + expect(page.items[1].isShow, isTrue); + expect(page.items[1].media?.ids.tvdb, 371980); + expect(page.items[1].media?.images?.primaryPoster, isNull); + expect(page.page, 1); + expect(page.pageCount, 3); + expect(page.itemCount, 250); + expect(page.hasMore, isTrue); + + client.dispose(); + }); + + test('getWatchlist defaults to a single page when pagination headers are absent', () async { + final client = _client((request) async => http.Response(json.encode([]), 200)); + + final page = await client.getWatchlist(type: TraktCatalogType.shows); + + expect(page.items, isEmpty); + expect(page.page, 1); + expect(page.pageCount, 1); + expect(page.hasMore, isFalse); + + client.dispose(); + }); + + test('getTrending parses watcher-wrapped entries', () async { + final requests = []; + final client = _client(requests: requests, (request) async { + return http.Response( + json.encode([ + {'watchers': 120, 'movie': _movieJson()}, + ]), + 200, + ); + }); + + final page = await client.getTrending(TraktCatalogType.movies, page: 2, limit: 10); + + expect(requests.single.url.path, '/movies/trending'); + expect(requests.single.url.queryParameters['page'], '2'); + expect(requests.single.url.queryParameters['limit'], '10'); + expect(page.items.single.watchers, 120); + expect(page.items.single.media?.title, 'The Matrix'); + + client.dispose(); + }); + + test('getPopular parses bare media objects', () async { + final requests = []; + final client = _client(requests: requests, (request) async { + return http.Response(json.encode([_showJson()]), 200); + }); + + final page = await client.getPopular(TraktCatalogType.shows); + + expect(requests.single.url.path, '/shows/popular'); + expect(page.items.single, isA()); + expect(page.items.single.title, 'Severance'); + + client.dispose(); + }); + + test('getRecommended passes ignore flags and parses bare media', () async { + final requests = []; + final client = _client(requests: requests, (request) async { + return http.Response(json.encode([_movieJson()]), 200); + }); + + final items = await client.getRecommended(TraktCatalogType.movies, limit: 15); + + final request = requests.single; + expect(request.url.path, '/recommendations/movies'); + expect(request.url.queryParameters['limit'], '15'); + expect(request.url.queryParameters['ignore_collected'], 'false'); + expect(request.url.queryParameters['ignore_watchlisted'], 'true'); + expect(items.single.title, 'The Matrix'); + + client.dispose(); + }); + + test('addToWatchlist accepts 201 and posts the ids body untouched', () async { + final requests = []; + final client = _client(requests: requests, (request) async => http.Response('{"added":{"movies":1}}', 201)); + + final body = { + 'movies': [ + { + 'ids': {'imdb': 'tt0133093'}, + }, + ], + }; + await client.addToWatchlist(body); + + expect(requests.single.url.path, '/sync/watchlist'); + expect(json.decode(requests.single.body), body); + + client.dispose(); + }); + + test('removeFromWatchlist posts to the remove endpoint', () async { + final requests = []; + final client = _client(requests: requests, (request) async => http.Response('{"deleted":{"shows":1}}', 200)); + + await client.removeFromWatchlist(const {'shows': []}); + + expect(requests.single.url.path, '/sync/watchlist/remove'); + + client.dispose(); + }); + + test('malformed entries are skipped instead of throwing', () async { + final client = _client((request) async { + return http.Response(json.encode(['not-a-map', 42]), 200); + }); + + final page = await client.getTrending(TraktCatalogType.shows); + expect(page.items, isEmpty); + + client.dispose(); + }); + + test('entry without movie or show yields null media', () { + final entry = TraktCatalogEntry.fromJson(const {'rank': 1, 'type': 'movie'}); + expect(entry.media, isNull); + }); + }); +}