Trakt was the one service outside the tracker abstraction. TraktScrobbleService re-implemented the whole playback lifecycle beside TrackerCoordinator, and TraktSyncService pushed watched state from its own WatchStateNotifier subscription, so the player called two objects at every lifecycle point and one watch could be written twice. TraktTracker now implements RealtimeScrobbleTracker like Simkl; the duplicated player call sites collapse to one each, and Trakt shares the coordinator's ID resolver instead of re-fetching show ids every episode. Capabilities are split so a tracker declares what it is rather than being special-cased: ScrobblePolicy carries each service's own resend/seek rules, EpisodeHistoryTracker names the remote row a per-item history write targets, and SeriesProgressTracker covers one-counter-per-series services. Writes from all four trackers go through a shared TrackerWriteQueue, generalised from the Trakt-only queue, with the legacy Trakt payload migrated on load. Trakt becomes the fourth TrackersProvider slot and TraktAccountProvider is deleted, so one object owns the active session per profile. Two failure paths found while consolidating are fixed here too. The queue's retries only ran on profile bind, connect and app foreground, so a network blip mid-session left queued watches waiting for the next foreground. OfflineModeProvider now notifies on connectivity changes, not just offline-state or WiFi-flag changes, and main.dart flushes the queue when the network returns. The queue also counted every failure toward the five attempts that permanently drop an item, so a rate limit or a service having a bad hour could discard a pending watch - the loss the queue exists to prevent. Only an answer about the write itself now spends an attempt: 4xx counts, while rate limits, 5xx, recoverable token-refresh failures and requests that never arrived do not. A back-off answer also defers that service for the rest of the flush, so a queue holding many rows does not fire all of them at a service that just asked for quiet.
398 lines
15 KiB
Dart
398 lines
15 KiB
Dart
import '../../i18n/strings.g.dart';
|
||
import '../../media/media_kind.dart';
|
||
import '../../models/catalog/catalog_cast_member.dart';
|
||
import '../../models/catalog/catalog_item.dart';
|
||
import '../../models/catalog/catalog_metadata.dart';
|
||
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_ids.dart';
|
||
import '../../utils/app_logger.dart';
|
||
import '../../utils/country_codes.dart';
|
||
import '../../utils/external_ids.dart';
|
||
import '../trackers/trakt/trakt_client.dart';
|
||
import '../trackers/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 `TrackersProvider` (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, totalResults: res.itemCount);
|
||
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,
|
||
totalResults: res.itemCount,
|
||
);
|
||
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,
|
||
totalResults: res.itemCount,
|
||
);
|
||
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,
|
||
totalResults: res.itemCount,
|
||
);
|
||
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,
|
||
totalResults: res.itemCount,
|
||
);
|
||
case CatalogRowId.trendingAnime:
|
||
case CatalogRowId.suggestedAnime:
|
||
case CatalogRowId.airingAnime:
|
||
case CatalogRowId.popularAnime:
|
||
case CatalogRowId.trending:
|
||
case CatalogRowId.upcomingMovies:
|
||
case CatalogRowId.upcomingShows:
|
||
throw ArgumentError('Trakt does not serve ${row.name}');
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<List<CatalogItem>> search(String query, {int limit = 30}) async {
|
||
final trimmed = query.trim();
|
||
if (trimmed.isEmpty) return const [];
|
||
final res = await _client.searchCatalog(trimmed, limit: limit);
|
||
return _fromEntries(res.items);
|
||
}
|
||
|
||
@override
|
||
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async =>
|
||
external.hasAny ? CatalogItemIds.fromExternal(external) : null;
|
||
|
||
@override
|
||
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
|
||
final id = item.ids.trakt?.toString() ?? item.ids.slug ?? item.ids.imdb;
|
||
if (id == null) return CatalogDetail(item: item);
|
||
final type = item.kind == MediaKind.movie ? TraktCatalogType.movies : TraktCatalogType.shows;
|
||
|
||
// Start both requests before awaiting either. Trakt keeps people and
|
||
// related titles on separate endpoints, so two calls are the minimum.
|
||
final peopleFuture = _withoutFailure(_client.getPeople(type, id), 'people', item);
|
||
final relatedFuture = _withoutFailure(_client.getRelated(type, id, limit: relatedLimit), 'related', item);
|
||
final people = await peopleFuture;
|
||
final related = await relatedFuture;
|
||
final credits = _creditsFrom(people?.crew);
|
||
final enrichedItem = credits.isEmpty
|
||
? item
|
||
: item.enrichedWith(
|
||
CatalogItem(
|
||
source: CatalogSourceId.trakt,
|
||
kind: item.kind,
|
||
title: '',
|
||
ids: const CatalogItemIds(),
|
||
credits: credits,
|
||
),
|
||
);
|
||
|
||
return CatalogDetail(
|
||
item: enrichedItem,
|
||
cast: _castFrom(people, castLimit),
|
||
related: related == null ? const [] : _fromMedia(related, item.kind),
|
||
);
|
||
}
|
||
|
||
Future<T?> _withoutFailure<T>(Future<T> request, String requestName, CatalogItem item) async {
|
||
try {
|
||
return await request;
|
||
} catch (error, stackTrace) {
|
||
appLogger.d(
|
||
'Trakt: catalog $requestName load failed for ${item.identityKey}',
|
||
error: error,
|
||
stackTrace: stackTrace,
|
||
);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
List<CatalogCastMember> _castFrom(TraktPeople? people, int limit) {
|
||
if (people == null || limit <= 0) return const [];
|
||
final cast = <CatalogCastMember>[];
|
||
// `guest_stars` can be a very large response. Regular cast stays first and
|
||
// the combined retained list is bounded even though Trakt sends all rows.
|
||
for (final entry in people.cast.followedBy(people.guestStars)) {
|
||
if (cast.length >= limit) break;
|
||
if (entry.person?.name case final String name when name.isNotEmpty) {
|
||
cast.add(
|
||
CatalogCastMember(
|
||
name: name,
|
||
secondary: _castSecondary(entry),
|
||
imageUrl: entry.person?.images?.primaryHeadshot,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
return cast;
|
||
}
|
||
|
||
static String? _castSecondary(TraktCastEntry entry) {
|
||
final characters = entry.characters?.where((character) => character.isNotEmpty).join(', ');
|
||
final episodeCount = entry.episodeCount;
|
||
final parts = [
|
||
if (characters != null && characters.isNotEmpty) characters,
|
||
if (episodeCount != null) t.explore.badge.episodesShort(n: episodeCount),
|
||
];
|
||
return parts.isEmpty ? null : parts.join(' · ');
|
||
}
|
||
|
||
static List<CatalogCredit> _creditsFrom(List<TraktCastEntry>? crew) {
|
||
if (crew == null || crew.isEmpty) return const [];
|
||
final credits = <CatalogCredit>[];
|
||
final seen = <String>{};
|
||
for (final role in const [CatalogCreditRole.director, CatalogCreditRole.writer, CatalogCreditRole.producer]) {
|
||
for (final entry in crew) {
|
||
final name = entry.person?.name;
|
||
if (name == null || name.isEmpty || !_hasCreditRole(entry, role)) continue;
|
||
final key = '${role.name}/${name.toLowerCase()}';
|
||
if (seen.add(key)) credits.add(CatalogCredit(name: name, role: role));
|
||
}
|
||
}
|
||
return credits;
|
||
}
|
||
|
||
static bool _hasCreditRole(TraktCastEntry entry, CatalogCreditRole role) {
|
||
final job = entry.job;
|
||
final jobs = <String>[
|
||
if (job != null) job.toLowerCase(),
|
||
for (final job in entry.jobs ?? const <String>[]) job.toLowerCase(),
|
||
];
|
||
return switch (role) {
|
||
CatalogCreditRole.director => jobs.any((job) => job == 'director' || job == 'co-director'),
|
||
CatalogCreditRole.writer => jobs.any((job) => const {'writer', 'screenplay', 'story', 'teleplay'}.contains(job)),
|
||
CatalogCreditRole.producer => jobs.any((job) => job == 'producer' || job.endsWith(' producer')),
|
||
_ => false,
|
||
};
|
||
}
|
||
|
||
@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)!,
|
||
watchers: entry.watchers,
|
||
addedAt: _parseDate(entry.listedAt),
|
||
),
|
||
];
|
||
|
||
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 media, MediaKind kind, {int? watchers, DateTime? addedAt}) {
|
||
final audience = CatalogAudience(watchingNow: watchers, comments: media.commentCount);
|
||
final broadcast = CatalogBroadcast(
|
||
weekday: weekdayFor(media.airs?.day),
|
||
time: _broadcastTime(media.airs?.time),
|
||
timezone: media.airs?.timezone,
|
||
);
|
||
return CatalogItem(
|
||
source: CatalogSourceId.trakt,
|
||
kind: kind,
|
||
title: media.title ?? '',
|
||
year: media.year,
|
||
overview: media.overview,
|
||
runtimeMinutes: media.runtime,
|
||
rating: media.rating,
|
||
votes: media.votes,
|
||
genres: media.genres,
|
||
certification: media.certification,
|
||
trailerUrl: media.trailer,
|
||
airStatus: airStatusFor(media.status),
|
||
episodeCount: media.airedEpisodes,
|
||
network: media.network,
|
||
ids: CatalogItemIds(
|
||
trakt: media.ids.trakt,
|
||
slug: media.ids.slug,
|
||
imdb: media.ids.imdb,
|
||
tmdb: media.ids.tmdb,
|
||
tvdb: media.ids.tvdb,
|
||
),
|
||
posterUrl: media.images?.primaryPoster,
|
||
backdropUrl: media.images?.primaryBackdrop,
|
||
logoUrl: media.images?.primaryLogo,
|
||
audience: audience.isEmpty ? null : audience,
|
||
broadcast: broadcast.isEmpty ? null : broadcast,
|
||
releaseDate: _parseDate(media.released ?? media.firstAired),
|
||
addedAt: addedAt,
|
||
originalTitle: media.originalTitle,
|
||
tagline: media.tagline,
|
||
countries: media.country == null || media.country!.isEmpty ? null : [CountryCodes.normalizeCode(media.country!)],
|
||
languages: _languagesFor(media),
|
||
recommenders: _recommendersFor(media),
|
||
);
|
||
}
|
||
|
||
static int? weekdayFor(String? day) => switch (day?.toLowerCase()) {
|
||
'monday' => DateTime.monday,
|
||
'tuesday' => DateTime.tuesday,
|
||
'wednesday' => DateTime.wednesday,
|
||
'thursday' => DateTime.thursday,
|
||
'friday' => DateTime.friday,
|
||
'saturday' => DateTime.saturday,
|
||
'sunday' => DateTime.sunday,
|
||
_ => null,
|
||
};
|
||
|
||
static String? _broadcastTime(String? time) {
|
||
if (time == null || time.length < 5 || time[2] != ':') return null;
|
||
final hour = int.tryParse(time.substring(0, 2));
|
||
final minute = int.tryParse(time.substring(3, 5));
|
||
if (hour == null || hour < 0 || hour > 23 || minute == null || minute < 0 || minute > 59) {
|
||
return null;
|
||
}
|
||
return time.substring(0, 5);
|
||
}
|
||
|
||
static DateTime? _parseDate(String? raw) => raw == null ? null : DateTime.tryParse(raw);
|
||
|
||
static List<String>? _languagesFor(TraktCatalogMedia media) {
|
||
final languages = <String>{
|
||
if (media.language case final String language when language.isNotEmpty) language.toLowerCase(),
|
||
...?media.languages?.where((language) => language.isNotEmpty).map((language) => language.toLowerCase()),
|
||
...?media.availableTranslations
|
||
?.where((language) => language.isNotEmpty)
|
||
.map((language) => language.toLowerCase()),
|
||
};
|
||
return languages.isEmpty ? null : languages.toList(growable: false);
|
||
}
|
||
|
||
static List<CatalogRecommender>? _recommendersFor(TraktCatalogMedia media) {
|
||
final recommenders = <CatalogRecommender>[
|
||
for (final user in media.favoritedBy ?? const <TraktRecommendationUser>[])
|
||
if (user.username case final String username when username.isNotEmpty)
|
||
CatalogRecommender(
|
||
username: username,
|
||
name: user.name,
|
||
note: user.notes,
|
||
reason: CatalogRecommendationReason.favorited,
|
||
),
|
||
for (final user in media.recommendedBy ?? const <TraktRecommendationUser>[])
|
||
if (user.username case final String username when username.isNotEmpty)
|
||
CatalogRecommender(
|
||
username: username,
|
||
name: user.name,
|
||
note: user.notes,
|
||
reason: CatalogRecommendationReason.recommended,
|
||
),
|
||
];
|
||
return recommenders.isEmpty ? null : recommenders;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
disposeWatchlistMachinery();
|
||
}
|
||
}
|