feat(trakt): catalog API and shared catalog core (watchlist, discover, recommendations)
This commit is contained in:
@@ -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});
|
||||
}
|
||||
@@ -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, 0–10.
|
||||
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?,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>),
|
||||
);
|
||||
@@ -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>),
|
||||
);
|
||||
@@ -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<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>),
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
@@ -27,12 +27,18 @@ class TraktAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
int _bindingGeneration = 0;
|
||||
bool _isConnecting = false;
|
||||
Completer<void>? _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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<int> _scrobbleAllowedStatuses = {200, 201, 409};
|
||||
static const Set<int> _permanentRefreshFailureStatuses = {400, 401, 403};
|
||||
static final Map<String, Future<TrackerSession>> _refreshesByToken = {};
|
||||
static final KeyedFutureCoalescer<String, TrackerSession> _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<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
|
||||
/// duplicate POSTs don't race when multiple in-flight requests hit 401.
|
||||
Future<TrackerSession> refresh() async {
|
||||
@@ -88,33 +187,28 @@ class TraktClient {
|
||||
if (e.isPermanent) onSessionInvalidated();
|
||||
rethrow;
|
||||
}
|
||||
final existing = _refreshesByToken[refreshToken];
|
||||
if (existing != null) {
|
||||
var initiated = false;
|
||||
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) {
|
||||
_session = session;
|
||||
onSessionUpdated?.call(session);
|
||||
}
|
||||
return _session;
|
||||
} 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();
|
||||
}
|
||||
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 {
|
||||
appLogger.d('Trakt: refreshing access token');
|
||||
final tokenUri = Uri.parse(TraktConstants.tokenUrl);
|
||||
@@ -187,6 +281,18 @@ class TraktClient {
|
||||
String path, {
|
||||
Map<String, dynamic>? body,
|
||||
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 {
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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<List<TraktSyncQueueItem>> 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<String, dynamic>)).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<void> _saveRaw(String userUuid, List<TraktSyncQueueItem> 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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user