Files
plezy/lib/services/trackers/tracker_exceptions.dart
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

76 lines
3.0 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'tracker_constants.dart';
enum TrackerApiFailureCategory { graphqlErrors }
class TrackerApiException implements Exception {
final TrackerService service;
final int statusCode;
final TrackerApiFailureCategory? category;
const TrackerApiException({required this.service, required this.statusCode, this.category});
@override
String toString() {
final categorySuffix = category == null ? '' : ', ${category!.name}';
return 'TrackerApiException(${service.name}, HTTP $statusCode$categorySuffix)';
}
}
class TrackerAuthException implements Exception {
final TrackerService service;
final String message;
final int? statusCode;
final bool isPermanent;
const TrackerAuthException({required this.service, required this.message, this.statusCode, this.isPermanent = false});
@override
String toString() => 'TrackerAuthException(${service.name}): $message';
}
class TrackerRateLimitException implements Exception {
final TrackerService service;
final int? retryAfterSeconds;
const TrackerRateLimitException({required this.service, this.retryAfterSeconds});
@override
String toString() => 'TrackerRateLimitException(${service.name}, retry-after: $retryAfterSeconds s)';
}
/// True when a failed write says nothing about the write itself, so retrying it
/// later is the right answer and it must not spend one of a queued item's
/// attempts.
///
/// Four shapes qualify:
///
/// * The request never arrived — a timeout or transport error. A link coming
/// back up is no proof the endpoint is reachable, so counting these would let
/// a handful of connectivity flaps drop a watch.
/// * The service rate-limited us. Explicitly retryable, and typed only by Trakt
/// and AniList — MAL and Simkl surface a 429 as a plain
/// [TrackerApiException].
/// * The service failed on its own side (5xx). A bad hour for a service is not
/// evidence that this watch is unwritable.
/// * A token refresh failed recoverably. [TrackerAuthException.isPermanent] is
/// the services' own verdict: false means the refresh endpoint misbehaved
/// (5xx, network), true means the session is genuinely dead.
///
/// What is left counting is an answer *about the write*: 4xx says this item, on
/// this account, will not be written however often we ask. The consequence is
/// that a service returning 5xx indefinitely keeps its rows queued rather than
/// dropping them — deliberate, since the alternative is losing watches to an
/// outage, and rows coalesce per remote row so repeats do not accumulate.
bool isTrackerFailureTransient(Object error) {
if (error is TimeoutException || error is SocketException || error is http.ClientException) return true;
if (error is TrackerRateLimitException) return true;
if (error is TrackerAuthException) return !error.isPermanent;
if (error is TrackerApiException) return error.statusCode == 429 || error.statusCode >= 500;
return false;
}