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
+16
View File
@@ -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<String, Object?>());
}
}
@@ -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?,
);
}
+37
View File
@@ -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<String>? headshot;
const TraktPersonImages({this.headshot});
String? get primaryHeadshot => TraktImages.firstUrl(headshot);
factory TraktPersonImages.fromJson(Map<String, dynamic> json) => _$TraktPersonImagesFromJson(json);
}
@JsonSerializable(createToJson: false)
class TraktPerson {
final String? name;
final TraktPersonImages? images;
const TraktPerson({this.name, this.images});
factory TraktPerson.fromJson(Map<String, dynamic> json) => _$TraktPersonFromJson(json);
}
/// One cast credit from `GET /{movies|shows}/{id}/people`.
@JsonSerializable(createToJson: false)
class TraktCastEntry {
final List<String>? characters;
final TraktPerson? person;
const TraktCastEntry({this.characters, this.person});
factory TraktCastEntry.fromJson(Map<String, dynamic> json) => _$TraktCastEntryFromJson(json);
}
+31
View File
@@ -0,0 +1,31 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'trakt_cast_entry.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TraktPersonImages _$TraktPersonImagesFromJson(Map<String, dynamic> json) =>
TraktPersonImages(
headshot: (json['headshot'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
);
TraktPerson _$TraktPersonFromJson(Map<String, dynamic> json) => TraktPerson(
name: json['name'] as String?,
images: json['images'] == null
? null
: TraktPersonImages.fromJson(json['images'] as Map<String, dynamic>),
);
TraktCastEntry _$TraktCastEntryFromJson(Map<String, dynamic> json) =>
TraktCastEntry(
characters: (json['characters'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
person: json['person'] == null
? null
: TraktPerson.fromJson(json['person'] as Map<String, dynamic>),
);
+31
View File
@@ -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<String, dynamic> json) => _$TraktCatalogEntryFromJson(json);
}
@@ -0,0 +1,16 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'trakt_catalog_entry.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TraktCatalogEntry _$TraktCatalogEntryFromJson(Map<String, dynamic> 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<String, dynamic>),
show: json['show'] == null ? null : TraktCatalogMedia.fromJson(json['show'] as Map<String, dynamic>),
);
+61
View File
@@ -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, 010.
final double? rating;
final int? votes;
final List<String>? 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<String, dynamic> json) => _$TraktCatalogMediaFromJson(json);
}
@@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'trakt_catalog_media.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TraktCatalogMedia _$TraktCatalogMediaFromJson(Map<String, dynamic> json) =>
TraktCatalogMedia(
title: json['title'] as String?,
year: (json['year'] as num?)?.toInt(),
ids: TraktIds.fromJson(json['ids'] as Map<String, dynamic>),
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<dynamic>?)
?.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<String, dynamic>),
);
+33
View File
@@ -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<String>? poster;
final List<String>? fanart;
final List<String>? logo;
final List<String>? banner;
final List<String>? 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<String>? urls) {
final url = urls?.firstOrNull;
if (url == null || url.isEmpty) return null;
return url.startsWith('http') ? url : 'https://$url';
}
factory TraktImages.fromJson(Map<String, dynamic> json) => _$TraktImagesFromJson(json);
}
+15
View File
@@ -0,0 +1,15 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'trakt_images.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TraktImages _$TraktImagesFromJson(Map<String, dynamic> json) => TraktImages(
poster: (json['poster'] as List<dynamic>?)?.map((e) => e as String).toList(),
fanart: (json['fanart'] as List<dynamic>?)?.map((e) => e as String).toList(),
logo: (json['logo'] as List<dynamic>?)?.map((e) => e as String).toList(),
banner: (json['banner'] as List<dynamic>?)?.map((e) => e as String).toList(),
thumb: (json['thumb'] as List<dynamic>?)?.map((e) => e as String).toList(),
);
+13
View File
@@ -27,12 +27,18 @@ class TraktAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
int _bindingGeneration = 0; int _bindingGeneration = 0;
bool _isConnecting = false; bool _isConnecting = false;
Completer<void>? _cancelCompleter; Completer<void>? _cancelCompleter;
TraktClient? _catalogClient;
TrackerSession? get session => _session; TrackerSession? get session => _session;
bool get isConnected => _session != null; bool get isConnected => _session != null;
String? get username => _session?.username; String? get username => _session?.username;
bool get isConnecting => _isConnecting; 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 /// Cancel an in-flight `connect()` (e.g. user dismissed the device-code
/// dialog). Completing the completer both wakes the blocking `Future.any` /// dialog). Completing the completer both wakes the blocking `Future.any`
/// race and flips `isCompleted` for the next sync check. /// race and flips `isCompleted` for the next sync check.
@@ -134,6 +140,10 @@ class TraktAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
onSessionInvalidated: handleInvalidated, onSessionInvalidated: handleInvalidated,
onSessionUpdated: handleUpdated, onSessionUpdated: handleUpdated,
); );
_catalogClient?.dispose();
_catalogClient = session == null
? null
: TraktClient(session, onSessionInvalidated: handleInvalidated, onSessionUpdated: handleUpdated);
safeNotifyListeners(); safeNotifyListeners();
} }
@@ -146,6 +156,7 @@ class TraktAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
_session = session; _session = session;
TraktScrobbleService.instance.updateSession(session); TraktScrobbleService.instance.updateSession(session);
TraktSyncService.instance.updateSession(session); TraktSyncService.instance.updateSession(session);
_catalogClient?.updateSession(session);
unawaited(_store.save(userUuid, session)); unawaited(_store.save(userUuid, session));
safeNotifyListeners(); safeNotifyListeners();
} }
@@ -175,6 +186,8 @@ class TraktAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
@override @override
void dispose() { void dispose() {
_auth.dispose(); _auth.dispose();
_catalogClient?.dispose();
_catalogClient = null;
super.dispose(); super.dispose();
} }
} }
+109
View File
@@ -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<CatalogItem> 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<CatalogRowId> get supportedRows;
/// Whether the source has a user watchlist that can be read and mutated.
bool get supportsWatchlist;
Future<CatalogPage> 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<List<CatalogItem>> 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<List<CatalogCastMember>> 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<List<CatalogItem>> 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<void> 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<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external);
Future<void> addToWatchlist(MediaKind kind, CatalogItemIds ids);
Future<void> 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();
}
@@ -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<List<String>> 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<void> _watchlistLoad = FutureCoalescer();
Map<String, Set<String>>? _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<String> membershipKeysFor(MediaKind kind, CatalogItemIds ids);
/// One page of the snapshot as key groups (one group per entry).
Future<WatchlistKeyPage> 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<CatalogItemIds> resolveWatchlistMutationIds(MediaKind kind, CatalogItemIds ids) async => ids;
/// The actual API mutation for the resolved [ids].
Future<void> 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<void> ensureWatchlistLoaded() {
if (_watchlistKeyGroups != null) return Future.value();
return _watchlistLoad.run(_loadWatchlistSnapshot);
}
Future<void> _loadWatchlistSnapshot() async {
try {
final map = <String, Set<String>>{};
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<void> addToWatchlist(MediaKind kind, CatalogItemIds ids) => _mutateWatchlist(kind, ids, add: true);
Future<void> removeFromWatchlist(MediaKind kind, CatalogItemIds ids) => _mutateWatchlist(kind, ids, add: false);
Future<void> _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<String>? addedGroup;
List<Set<String>>? 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 <Set<String>>[]) {
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<Set<String>> _takeGroups(Map<String, Set<String>> map, Iterable<String> keys) {
final groups = <Set<String>>[];
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();
}
}
@@ -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<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.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();
}
}
+122 -18
View File
@@ -3,14 +3,19 @@ import 'dart:convert';
import 'package:http/http.dart' as http; 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_scrobble_request.dart';
import '../../models/trakt/trakt_user.dart'; import '../../models/trakt/trakt_user.dart';
import '../../utils/app_logger.dart'; import '../../utils/app_logger.dart';
import '../trackers/future_coalescer.dart';
import '../trackers/tracker_constants.dart'; import '../trackers/tracker_constants.dart';
import '../trackers/tracker_exceptions.dart'; import '../trackers/tracker_exceptions.dart';
import '../trackers/tracker_http_client.dart'; import '../trackers/tracker_http_client.dart';
import '../trackers/tracker_session.dart'; import '../trackers/tracker_session.dart';
import 'trakt_constants.dart'; import 'trakt_constants.dart';
import 'trakt_page.dart';
/// HTTP wrapper for the Trakt REST API. /// HTTP wrapper for the Trakt REST API.
/// ///
@@ -19,7 +24,7 @@ import 'trakt_constants.dart';
class TraktClient { class TraktClient {
static const Set<int> _scrobbleAllowedStatuses = {200, 201, 409}; static const Set<int> _scrobbleAllowedStatuses = {200, 201, 409};
static const Set<int> _permanentRefreshFailureStatuses = {400, 401, 403}; static const Set<int> _permanentRefreshFailureStatuses = {400, 401, 403};
static final Map<String, Future<TrackerSession>> _refreshesByToken = {}; static final KeyedFutureCoalescer<String, TrackerSession> _refreshesByToken = KeyedFutureCoalescer();
TrackerSession _session; TrackerSession _session;
final TrackerHttpClient _http; final TrackerHttpClient _http;
@@ -78,6 +83,100 @@ class TraktClient {
return res is List ? res : const []; 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<TraktPage<TraktCatalogEntry>> 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<TraktPage<TraktCatalogEntry>> 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<TraktPage<TraktCatalogMedia>> 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<List<TraktCatalogMedia>> 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<TraktPage<TraktCatalogEntry>> 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<List<TraktCatalogMedia>> 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<List<TraktCastEntry>> 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<Map<String, dynamic>>()) TraktCastEntry.fromJson(e)];
}
/// Body shape: `{"movies":[{"ids":{...}}],"shows":[{"ids":{...}}]}`.
Future<void> addToWatchlist(Map<String, dynamic> body) =>
_request('POST', '/sync/watchlist', body: body, allowStatuses: const {200, 201});
Future<void> removeFromWatchlist(Map<String, dynamic> body) => _request('POST', '/sync/watchlist/remove', body: body);
static List<TraktCatalogEntry> _decodeEntries(String body) {
final decoded = TrackerHttpClient.decodeJson(body);
if (decoded is! List) return const [];
return [for (final e in decoded.whereType<Map<String, dynamic>>()) TraktCatalogEntry.fromJson(e)];
}
static List<TraktCatalogMedia> _decodeMedia(String body) {
final decoded = TrackerHttpClient.decodeJson(body);
if (decoded is! List) return const [];
return [for (final e in decoded.whereType<Map<String, dynamic>>()) TraktCatalogMedia.fromJson(e)];
}
/// Refresh the access token. Coalesces concurrent calls so /// Refresh the access token. Coalesces concurrent calls so
/// duplicate POSTs don't race when multiple in-flight requests hit 401. /// duplicate POSTs don't race when multiple in-flight requests hit 401.
Future<TrackerSession> refresh() async { Future<TrackerSession> refresh() async {
@@ -88,33 +187,28 @@ class TraktClient {
if (e.isPermanent) onSessionInvalidated(); if (e.isPermanent) onSessionInvalidated();
rethrow; rethrow;
} }
final existing = _refreshesByToken[refreshToken]; var initiated = false;
if (existing != null) {
try { try {
final session = await existing; 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) { if (_session.refreshToken == refreshToken) {
_session = session; _session = session;
onSessionUpdated?.call(session); onSessionUpdated?.call(session);
} }
return _session; return _session;
} on TrackerAuthException catch (e) { } on TrackerAuthException catch (e) {
if (e.isPermanent && _session.refreshToken == refreshToken) { // The initiator's _doRefresh already invalidated; joiners do it here.
if (!initiated && e.isPermanent && _session.refreshToken == refreshToken) {
onSessionInvalidated(); onSessionInvalidated();
} }
rethrow; rethrow;
} }
} }
late final Future<TrackerSession> refresh;
refresh = _doRefresh(refreshToken).whenComplete(() {
if (identical(_refreshesByToken[refreshToken], refresh)) {
_refreshesByToken.remove(refreshToken);
}
});
_refreshesByToken[refreshToken] = refresh;
return refresh;
}
Future<TrackerSession> _doRefresh(String refreshToken) async { Future<TrackerSession> _doRefresh(String refreshToken) async {
appLogger.d('Trakt: refreshing access token'); appLogger.d('Trakt: refreshing access token');
final tokenUri = Uri.parse(TraktConstants.tokenUrl); final tokenUri = Uri.parse(TraktConstants.tokenUrl);
@@ -187,6 +281,18 @@ class TraktClient {
String path, { String path, {
Map<String, dynamic>? body, Map<String, dynamic>? body,
Set<int> allowStatuses = const {200, 201, 204}, Set<int> 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<http.Response> _requestResponse(
String method,
String path, {
Map<String, dynamic>? body,
Set<int> allowStatuses = const {200, 201, 204},
}) async { }) async {
if (_session.needsRefresh) { if (_session.needsRefresh) {
try { try {
@@ -203,9 +309,7 @@ class TraktClient {
res = await _send(method, path, body: body); res = await _send(method, path, body: body);
} }
if (allowStatuses.contains(res.statusCode)) { if (allowStatuses.contains(res.statusCode)) return res;
return TrackerHttpClient.decodeJson(res.body);
}
if (res.statusCode == 429) { if (res.statusCode == 429) {
throw TrackerRateLimitException( throw TrackerRateLimitException(
+4 -5
View File
@@ -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. /// Scrobble lifecycle state sent to Trakt's `/scrobble/{name}` endpoints.
enum TraktScrobbleState { start, pause, stop } 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. /// Direction of a watched-status sync push.
enum TraktSyncOp { enum TraktSyncOp {
add, add,
+23
View File
@@ -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<T> {
final List<T> 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<T> 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,
);
}
+4 -3
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import '../../models/trakt/trakt_ids.dart'; import '../../models/trakt/trakt_ids.dart';
import '../../profiles/profile.dart';
import '../base_shared_preferences_service.dart'; import '../base_shared_preferences_service.dart';
import '../../utils/app_logger.dart'; import '../../utils/app_logger.dart';
import 'trakt_constants.dart'; import 'trakt_constants.dart';
@@ -97,7 +98,7 @@ class TraktSyncQueue {
Future<List<TraktSyncQueueItem>> load(String userUuid) async { Future<List<TraktSyncQueueItem>> load(String userUuid) async {
final prefs = await BaseSharedPreferencesService.sharedCache(); final prefs = await BaseSharedPreferencesService.sharedCache();
final key = traktUserKey(userUuid, _baseKey); final key = profileScopedPrefsKey(userUuid, _baseKey);
final raw = prefs.getString(key); final raw = prefs.getString(key);
if (raw == null) return []; if (raw == null) return [];
try { try {
@@ -105,7 +106,7 @@ class TraktSyncQueue {
return list.map((e) => TraktSyncQueueItem.fromJson(e as Map<String, dynamic>)).toList(); return list.map((e) => TraktSyncQueueItem.fromJson(e as Map<String, dynamic>)).toList();
} catch (e, st) { } catch (e, st) {
appLogger.e('Trakt sync queue parse failed, discarding', error: e, stackTrace: 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); await prefs.remove(key);
return []; return [];
} }
@@ -117,7 +118,7 @@ class TraktSyncQueue {
Future<void> _saveRaw(String userUuid, List<TraktSyncQueueItem> items) async { Future<void> _saveRaw(String userUuid, List<TraktSyncQueueItem> items) async {
final prefs = await BaseSharedPreferencesService.sharedCache(); final prefs = await BaseSharedPreferencesService.sharedCache();
final key = traktUserKey(userUuid, _baseKey); final key = profileScopedPrefsKey(userUuid, _baseKey);
if (items.isEmpty) { if (items.isEmpty) {
await prefs.remove(key); await prefs.remove(key);
} else { } else {
@@ -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<String, dynamic> _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<http.Request> requests;
late List<http.Response Function(http.Request)> 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': <String>[],
'person': {'name': 'Aaron Paul'},
},
{'characters': <String>[]}, // no person — skipped
],
'crew': <String, dynamic>{},
}),
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);
});
});
}
+245
View File
@@ -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<String, dynamic> _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<String, dynamic> _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': <String, dynamic>{},
};
}
TraktClient _client(Future<http.Response> Function(http.Request) handler, {List<http.Request>? 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': <String>[]});
expect(images.primaryPoster, isNull);
expect(images.primaryBackdrop, isNull);
});
});
group('TraktClient catalog', () {
test('getWatchlist parses wrapped entries and sends extended=full,images', () async {
final requests = <http.Request>[];
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 = <http.Request>[];
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 = <http.Request>[];
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<TraktCatalogMedia>());
expect(page.items.single.title, 'Severance');
client.dispose();
});
test('getRecommended passes ignore flags and parses bare media', () async {
final requests = <http.Request>[];
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 = <http.Request>[];
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 = <http.Request>[];
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);
});
});
}