Files
plezy/lib/services/trackers/trakt/trakt_tracker.dart
T
edde746 4c8272d5b1 refactor(trackers): drive Trakt through the tracker coordinator
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.
2026-07-30 14:51:32 +02:00

285 lines
10 KiB
Dart

import 'package:http/http.dart' as http;
import '../../../media/media_kind.dart';
import '../../../models/trackers/tracker_context.dart';
import '../../../models/trakt/trakt_ids.dart';
import '../../../models/trakt/trakt_scrobble_request.dart';
import '../../../utils/app_logger.dart';
import '../../../utils/json_utils.dart';
import '../../settings_service.dart';
import '../tracker.dart';
import '../tracker_constants.dart';
import '../tracker_id_resolver.dart';
import '../tracker_rating_match.dart';
import '../tracker_session.dart';
import '../tracker_write_queue.dart';
import 'trakt_client.dart';
/// Trakt tracker.
///
/// In-player playback is reported in real time through `POST /scrobble/start`,
/// `/pause` and `/stop`; Trakt's own rule then decides watched state — a `stop`
/// at or above 80% progress records a play, below that it stores a resumable
/// position. `POST /sync/history` covers the marks that never pass through the
/// player: manual, container, offline replay, external players, and watch state
/// Plezy observes changing on the server.
///
/// Unlike the other services Trakt splits its user settings in two: the
/// scrobble toggle gates real-time reports ([canReportPlayback]) and a separate
/// watched-sync toggle gates history writes ([canWriteWatched]).
class TraktTracker extends TrackerBase
with ClientBackedTracker<TraktClient>
implements TrackerRatingSource, RealtimeScrobbleTracker, EpisodeHistoryTracker {
static TraktTracker? _instance;
static TraktTracker get instance => _instance ??= TraktTracker._();
TraktTracker._();
@override
String get name => 'trakt';
@override
TrackerService get service => TrackerService.trakt;
@override
bool get needsFribb => false;
/// Trakt counts a `/scrobble/stop` as a watch from this progress upwards.
static const double _scrobbleWatchedPercent = 80.0;
/// The bound client is replaced on every session rebind, so its identity is
/// the account identity.
@override
Object? get scrobbleBinding => client;
@override
bool get canReportPlayback => isEnabledWithSession;
bool _watchedSyncEnabled = false;
@override
bool get canWriteWatched => _watchedSyncEnabled && hasActiveClient;
@override
ScrobblePolicy get scrobblePolicy => const ScrobblePolicy(
// Trakt allows one scrobble per item per 15 minutes and 409s the rest, so a
// re-sent `start` waits out this window instead of collecting conflicts.
resendThrottle: Duration(seconds: 30),
// A slider drag emits many position updates; only one checkpoint per window
// reaches Trakt.
seekThrottle: Duration(seconds: 5),
);
@override
Future<void> initialize() async {
await super.initialize();
final settings = await SettingsService.getInstance();
_watchedSyncEnabled = settings.read(SettingsService.enableTraktWatchedSync);
}
/// Paired with the watched-sync settings toggle, mirroring [setEnabled] for
/// the scrobble toggle.
Future<void> setWatchedSyncEnabled(bool enabled) async {
_watchedSyncEnabled = enabled;
}
void rebindSession(
TrackerSession? session, {
required void Function() onSessionInvalidated,
void Function(TrackerSession session)? onSessionUpdated,
http.Client? httpClient,
}) {
rebindTrackerClient(
session,
createClient: (session) => TraktClient(
session,
onSessionInvalidated: onSessionInvalidated,
onSessionUpdated: onSessionUpdated,
httpClient: httpClient,
),
);
}
/// Trakt matches on the media server's own external ids and nothing else — the
/// anime mappings other trackers use never reach its requests.
@override
String? historyRowIdentity(TrackerContext ctx) => trackerExternalRowIdentity(ctx.external);
/// Push a rotated token pair into the live client instead of rebuilding it —
/// a second client would race the next refresh.
void updateSession(TrackerSession session) => client?.updateSession(session);
@override
Future<void> markWatched(TrackerContext ctx, {DateTime? watchedAt}) async {
final client = this.client;
if (client == null || !canWriteWatched) return;
final body = _requestFor(ctx);
if (body == null) return;
await client.addToHistory(body, watchedAt: watchedAt?.toUtc().toIso8601String());
appLogger.d('Trakt: marked watched (${ctx.ratingKey}, isMovie=${ctx.isMovie})');
}
@override
Future<void> markUnwatched(TrackerContext ctx) async {
final client = this.client;
if (client == null || !canWriteWatched) return;
final body = _requestFor(ctx);
if (body == null) return;
await client.removeFromHistory(body);
appLogger.d('Trakt: marked unwatched (${ctx.ratingKey}, isMovie=${ctx.isMovie})');
}
@override
Future<void> scrobble(TrackerContext ctx, TrackerScrobbleState state, double progressPercent) async {
final client = this.client;
if (client == null) return;
final body = _requestFor(ctx)?.copyWith(progress: progressPercent);
if (body == null) return;
switch (state) {
case TrackerScrobbleState.start:
await client.scrobbleStart(body);
case TrackerScrobbleState.pause:
await client.scrobblePause(body);
case TrackerScrobbleState.seek:
// Trakt has no seek event. Official clients checkpoint with pause+start
// at the new position; without it "resume on another device" stays stuck
// on the pre-seek position until the next pause or stop.
await client.scrobblePause(body);
await client.scrobbleStart(body);
case TrackerScrobbleState.stop:
await client.scrobbleStop(body);
}
appLogger.d('Trakt: scrobble ${state.name} @ ${progressPercent.toStringAsFixed(1)}%');
}
@override
Future<void> reconcileWatchedAfterStop(TrackerContext ctx, double progressPercent) async {
// At or above Trakt's own rule the stop already recorded the play; a history
// write would record a second one.
if (progressPercent >= _scrobbleWatchedPercent) return;
appLogger.d('Trakt: stop below ${_scrobbleWatchedPercent.toStringAsFixed(0)}% — recording watch explicitly');
await markWatched(ctx);
}
/// Trakt matches an episode through the show's ids plus the aired
/// season/episode index — that shape works even when the episode itself is not
/// in its catalog yet. Null when the item carries no usable ids.
TraktScrobbleRequest? _requestFor(TrackerContext ctx) {
final ids = TraktIds.fromExternal(ctx.external);
if (!ids.hasAny) return null;
if (ctx.isMovie) return TraktScrobbleRequest.movie(ids: ids);
final season = ctx.season;
final number = ctx.episodeNumber;
if (season == null || number == null) return null;
return TraktScrobbleRequest.episode(showIds: ids, season: season, number: number);
}
@override
Future<int?> getRating(TrackerRatingContext ctx) async {
final client = this.client;
if (client == null) throw const TrackerRatingUnavailableException('Trakt');
final localIds = TraktIds.fromExternal(ctx.ids.external).toJson();
if (localIds.isEmpty) throw const TrackerRatingUnavailableException('Trakt');
final entries = await client.getRatings(_ratingType(ctx));
for (final entry in entries) {
if (entry is! Map) continue;
if (!_ratingEntryMatches(ctx, entry.cast<String, dynamic>(), localIds)) continue;
final rating = flexibleInt(entry['rating']);
return rating != null && rating > 0 ? rating.clamp(1, 10).toInt() : null;
}
return null;
}
@override
Future<void> rate(TrackerRatingContext ctx, int score) async {
final client = this.client;
if (client == null) throw const TrackerRatingUnavailableException('Trakt');
await client.addRatings(_ratingBody(ctx, rating: score.clamp(1, 10).toInt()));
}
@override
Future<void> clearRating(TrackerRatingContext ctx) async {
final client = this.client;
if (client == null) throw const TrackerRatingUnavailableException('Trakt');
await client.removeRatings(_ratingBody(ctx));
}
String _ratingType(TrackerRatingContext ctx) => switch (ctx.kind) {
MediaKind.movie => 'movies',
MediaKind.show => 'shows',
MediaKind.season => 'seasons',
MediaKind.episode => 'episodes',
_ => throw const TrackerRatingUnavailableException('Trakt'),
};
bool _ratingEntryMatches(TrackerRatingContext ctx, Map<String, dynamic> entry, Map<String, dynamic> localIds) {
final show = entry['show'];
final movie = entry['movie'];
return switch (ctx.kind) {
MediaKind.movie => trackerIdsMatch(trackerNestedIds(movie), localIds),
MediaKind.show => trackerIdsMatch(trackerNestedIds(show), localIds),
MediaKind.season =>
trackerIdsMatch(trackerNestedIds(show), localIds) && _numberMatches(entry['season'], ctx.season),
MediaKind.episode =>
trackerIdsMatch(trackerNestedIds(show), localIds) &&
_numberMatches(entry['episode'], ctx.episodeNumber) &&
_seasonMatches(entry['episode'], ctx.season),
_ => false,
};
}
bool _numberMatches(Object? value, int? expected) {
if (expected == null || value is! Map) return false;
return flexibleInt(value['number']) == expected;
}
bool _seasonMatches(Object? value, int? expected) {
if (expected == null || value is! Map) return false;
return flexibleInt(value['season']) == expected;
}
Map<String, dynamic> _ratingBody(TrackerRatingContext ctx, {int? rating}) {
final ids = TraktIds.fromExternal(ctx.ids.external).toJson();
final item = {'ids': ids, 'rating': ?rating};
return switch (ctx.kind) {
MediaKind.movie => {
'movies': [item],
},
MediaKind.show => {
'shows': [item],
},
MediaKind.season => {
'shows': [
{
'ids': ids,
'seasons': [
{'number': ctx.season, 'rating': ?rating},
],
},
],
},
MediaKind.episode => {
'shows': [
{
'ids': ids,
'seasons': [
{
'number': ctx.season,
'episodes': [
{'number': ctx.episodeNumber, 'rating': ?rating},
],
},
],
},
],
},
_ => throw const TrackerRatingUnavailableException('Trakt'),
};
}
}