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.
356 lines
12 KiB
Dart
356 lines
12 KiB
Dart
import 'dart:async';
|
|
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/ids.dart';
|
|
import 'package:plezy/media/media_backend.dart';
|
|
import 'package:plezy/media/media_item.dart';
|
|
import 'package:plezy/media/media_kind.dart';
|
|
import 'package:plezy/media/media_server_client.dart';
|
|
import 'package:plezy/services/settings_service.dart';
|
|
import 'package:plezy/services/trackers/anilist/anilist_tracker.dart';
|
|
import 'package:plezy/services/trackers/mal/mal_tracker.dart';
|
|
import 'package:plezy/services/trackers/simkl/simkl_tracker.dart';
|
|
import 'package:plezy/services/trackers/tracker_coordinator.dart';
|
|
import 'package:plezy/services/trackers/tracker_session.dart';
|
|
import 'package:plezy/services/trackers/trakt/trakt_tracker.dart';
|
|
import 'package:plezy/utils/external_ids.dart';
|
|
|
|
import '../../test_helpers/media_items.dart';
|
|
import '../../test_helpers/prefs.dart';
|
|
|
|
class _FakeMediaServerClient implements MediaServerClient {
|
|
@override
|
|
final ServerId serverId;
|
|
@override
|
|
String? get serverName => null;
|
|
|
|
final Map<String, ExternalIds> externalIdsByItem;
|
|
|
|
@override
|
|
final double watchedThreshold;
|
|
|
|
_FakeMediaServerClient({required this.externalIdsByItem, this.watchedThreshold = 0.9})
|
|
: serverId = ServerId('server-1');
|
|
|
|
@override
|
|
MediaBackend get backend => MediaBackend.plex;
|
|
|
|
@override
|
|
Future<ExternalIds> fetchExternalIds(String itemId) async => externalIdsByItem[itemId] ?? const ExternalIds();
|
|
|
|
@override
|
|
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async => const [];
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
class _Call {
|
|
final String path;
|
|
final Map<String, dynamic> body;
|
|
|
|
_Call(this.path, this.body);
|
|
|
|
@override
|
|
String toString() => '$path ${json.encode(body)}';
|
|
}
|
|
|
|
class _TraktRecorder {
|
|
final List<_Call> calls = [];
|
|
final Map<String, int> statuses = {};
|
|
Completer<void>? gate;
|
|
|
|
http.Client get client => MockClient((request) async {
|
|
final body = request.body.isEmpty
|
|
? <String, dynamic>{}
|
|
: (json.decode(request.body) as Map).cast<String, dynamic>();
|
|
calls.add(_Call(request.url.path, body));
|
|
final pending = gate;
|
|
if (pending != null) await pending.future;
|
|
return http.Response('{}', statuses[request.url.path] ?? 200);
|
|
});
|
|
|
|
List<String> get paths => calls.map((call) => call.path).toList();
|
|
|
|
List<_Call> callsFor(String path) => calls.where((call) => call.path == path).toList();
|
|
|
|
_Call callFor(String path) => calls.firstWhere((call) => call.path == path, orElse: () => fail('no $path in $paths'));
|
|
}
|
|
|
|
MediaItem _episode({int? viewOffsetMs, int? durationMs}) => testMediaItem(
|
|
id: 'episode-1-3',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.episode,
|
|
title: 'Episode 3',
|
|
serverId: ServerId('server-1'),
|
|
libraryId: 'lib-1',
|
|
parentIndex: 1,
|
|
index: 3,
|
|
grandparentId: 'show-1',
|
|
viewOffsetMs: viewOffsetMs,
|
|
durationMs: durationMs,
|
|
);
|
|
|
|
TrackerSession _session([String token = 'token']) =>
|
|
TrackerSession(accessToken: token, createdAt: DateTime(2026, 7, 30).millisecondsSinceEpoch ~/ 1000);
|
|
|
|
_FakeMediaServerClient _client({double watchedThreshold = 0.9}) => _FakeMediaServerClient(
|
|
externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345)},
|
|
watchedThreshold: watchedThreshold,
|
|
);
|
|
|
|
void main() {
|
|
final coordinator = TrackerCoordinator.instance;
|
|
final trakt = TraktTracker.instance;
|
|
final simkl = SimklTracker.instance;
|
|
final mal = MalTracker.instance;
|
|
final anilist = AnilistTracker.instance;
|
|
|
|
late _TraktRecorder recorder;
|
|
late DateTime now;
|
|
|
|
setUp(() async {
|
|
resetSharedPreferencesForTest();
|
|
SettingsService.resetForTesting();
|
|
await SettingsService.getInstance();
|
|
|
|
recorder = _TraktRecorder();
|
|
now = DateTime(2026, 7, 30, 12);
|
|
coordinator.debugUseScrobbleClock(() => now);
|
|
|
|
simkl.rebindSession(null, onSessionInvalidated: () {});
|
|
mal.rebindSession(null, onSessionInvalidated: () {});
|
|
anilist.rebindSession(null, onSessionInvalidated: () {});
|
|
trakt.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client);
|
|
|
|
await simkl.setEnabled(false);
|
|
await mal.setEnabled(false);
|
|
await anilist.setEnabled(false);
|
|
await trakt.setEnabled(true);
|
|
await trakt.setWatchedSyncEnabled(true);
|
|
});
|
|
|
|
tearDown(() async {
|
|
if (recorder.gate?.isCompleted == false) recorder.gate!.complete();
|
|
coordinator.cancelInFlight();
|
|
coordinator.debugUseResolverDependencies();
|
|
coordinator.debugUseScrobbleClock(null);
|
|
|
|
trakt.rebindSession(null, onSessionInvalidated: () {});
|
|
simkl.rebindSession(null, onSessionInvalidated: () {});
|
|
mal.rebindSession(null, onSessionInvalidated: () {});
|
|
anilist.rebindSession(null, onSessionInvalidated: () {});
|
|
|
|
await trakt.setEnabled(false);
|
|
await trakt.setWatchedSyncEnabled(false);
|
|
await simkl.setEnabled(false);
|
|
await mal.setEnabled(false);
|
|
await anilist.setEnabled(false);
|
|
SettingsService.resetForTesting();
|
|
});
|
|
|
|
void moveWithoutSeeking(Duration target) {
|
|
for (var milliseconds = 5000; milliseconds < target.inMilliseconds; milliseconds += 5000) {
|
|
coordinator.updatePosition(Duration(milliseconds: milliseconds));
|
|
}
|
|
coordinator.updatePosition(target);
|
|
}
|
|
|
|
Future<void> startAtZero({_FakeMediaServerClient? client}) async {
|
|
await coordinator.startPlayback(_episode(durationMs: 100000), client ?? _client());
|
|
await pumpEventQueue();
|
|
}
|
|
|
|
group('Trakt real-time playback', () {
|
|
test('start posts the resume offset and episode identity', () async {
|
|
await coordinator.startPlayback(
|
|
_episode(viewOffsetMs: const Duration(minutes: 10).inMilliseconds, durationMs: 2000000),
|
|
_client(),
|
|
);
|
|
await pumpEventQueue();
|
|
|
|
expect(recorder.paths, ['/scrobble/start']);
|
|
expect(recorder.calls.single.body, {
|
|
'progress': 30.0,
|
|
'show': {
|
|
'ids': {'tvdb': 12345},
|
|
},
|
|
'episode': {'season': 1, 'number': 3},
|
|
});
|
|
});
|
|
|
|
test('pause checkpoints progress and resume starts again', () async {
|
|
await startAtZero();
|
|
moveWithoutSeeking(const Duration(seconds: 40));
|
|
|
|
await coordinator.pausePlayback();
|
|
await coordinator.resumePlayback();
|
|
await pumpEventQueue();
|
|
|
|
expect(recorder.paths, ['/scrobble/start', '/scrobble/pause', '/scrobble/start']);
|
|
expect(recorder.calls[1].body['progress'], 40.0);
|
|
expect(recorder.calls[2].body['progress'], 40.0);
|
|
});
|
|
|
|
test('same-state start obeys the thirty-second resend throttle', () async {
|
|
await startAtZero();
|
|
|
|
now = now.add(const Duration(seconds: 5));
|
|
await coordinator.resumePlayback();
|
|
await pumpEventQueue();
|
|
expect(recorder.paths, ['/scrobble/start']);
|
|
|
|
now = now.add(const Duration(seconds: 25));
|
|
await coordinator.resumePlayback();
|
|
await pumpEventQueue();
|
|
expect(recorder.paths, ['/scrobble/start', '/scrobble/start']);
|
|
});
|
|
|
|
test('seek checkpoints are throttled and ignored while paused', () async {
|
|
await startAtZero();
|
|
coordinator.updatePosition(const Duration(seconds: 4));
|
|
coordinator.updatePosition(const Duration(seconds: 40));
|
|
await pumpEventQueue();
|
|
|
|
expect(recorder.paths, ['/scrobble/start', '/scrobble/pause', '/scrobble/start']);
|
|
expect(recorder.calls[1].body['progress'], 40.0);
|
|
expect(recorder.calls[2].body['progress'], 40.0);
|
|
|
|
coordinator.updatePosition(const Duration(seconds: 70));
|
|
await pumpEventQueue();
|
|
expect(recorder.paths, ['/scrobble/start', '/scrobble/pause', '/scrobble/start']);
|
|
|
|
now = now.add(const Duration(seconds: 5));
|
|
coordinator.updatePosition(const Duration(seconds: 20));
|
|
await pumpEventQueue();
|
|
expect(recorder.paths, [
|
|
'/scrobble/start',
|
|
'/scrobble/pause',
|
|
'/scrobble/start',
|
|
'/scrobble/pause',
|
|
'/scrobble/start',
|
|
]);
|
|
expect(recorder.calls[3].body['progress'], 20.0);
|
|
expect(recorder.calls[4].body['progress'], 20.0);
|
|
|
|
await coordinator.pausePlayback();
|
|
final callsBeforePausedJump = recorder.calls.length;
|
|
coordinator.updatePosition(const Duration(seconds: 80));
|
|
await pumpEventQueue();
|
|
expect(recorder.calls, hasLength(callsBeforePausedJump));
|
|
});
|
|
|
|
test('stop reports measured progress without inflating it to watched', () async {
|
|
await startAtZero();
|
|
moveWithoutSeeking(const Duration(seconds: 60));
|
|
|
|
await coordinator.stopPlayback();
|
|
|
|
expect(recorder.paths, ['/scrobble/start', '/scrobble/stop']);
|
|
expect(recorder.callFor('/scrobble/stop').body['progress'], 60.0);
|
|
});
|
|
|
|
test('a low server watched threshold falls back to Trakt history', () async {
|
|
await startAtZero(client: _client(watchedThreshold: 0.5));
|
|
moveWithoutSeeking(const Duration(seconds: 60));
|
|
await pumpEventQueue();
|
|
|
|
await coordinator.stopPlayback();
|
|
await pumpEventQueue();
|
|
|
|
expect(recorder.paths, ['/scrobble/start', '/scrobble/stop', '/sync/history']);
|
|
expect(recorder.callFor('/scrobble/stop').body['progress'], 60.0);
|
|
expect(recorder.callFor('/sync/history').body, {
|
|
'shows': [
|
|
{
|
|
'ids': {'tvdb': 12345},
|
|
'seasons': [
|
|
{
|
|
'number': 1,
|
|
'episodes': [
|
|
{'number': 3},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('an unconfirmed completed stop falls back to Trakt history', () async {
|
|
recorder.statuses['/scrobble/stop'] = 500;
|
|
await startAtZero(client: _client(watchedThreshold: 0.8));
|
|
moveWithoutSeeking(const Duration(seconds: 85));
|
|
await pumpEventQueue();
|
|
|
|
await coordinator.stopPlayback();
|
|
await pumpEventQueue();
|
|
|
|
expect(recorder.paths, ['/scrobble/start', '/scrobble/stop', '/sync/history']);
|
|
expect(recorder.callFor('/scrobble/stop').body['progress'], 85.0);
|
|
expect(recorder.callsFor('/sync/history'), hasLength(1));
|
|
});
|
|
|
|
Future<void> crossThresholdThenStopBelowTraktRule() async {
|
|
await startAtZero();
|
|
moveWithoutSeeking(const Duration(seconds: 95));
|
|
await pumpEventQueue();
|
|
coordinator.updateDuration(const Duration(seconds: 200));
|
|
await coordinator.stopPlayback();
|
|
await pumpEventQueue();
|
|
}
|
|
|
|
test('scrobble and watched sync together record one watch', () async {
|
|
await crossThresholdThenStopBelowTraktRule();
|
|
|
|
expect(recorder.paths, ['/scrobble/start', '/scrobble/stop', '/sync/history']);
|
|
expect(recorder.callFor('/scrobble/stop').body['progress'], 47.5);
|
|
expect(recorder.callsFor('/sync/history'), hasLength(1));
|
|
});
|
|
|
|
test('watched sync works with real-time scrobbling disabled', () async {
|
|
await trakt.setEnabled(false);
|
|
|
|
await crossThresholdThenStopBelowTraktRule();
|
|
|
|
expect(recorder.paths.where((path) => path.startsWith('/scrobble/')), isEmpty);
|
|
expect(recorder.paths, ['/sync/history']);
|
|
});
|
|
|
|
test('real-time scrobbling never writes history when watched sync is disabled', () async {
|
|
await trakt.setWatchedSyncEnabled(false);
|
|
|
|
await crossThresholdThenStopBelowTraktRule();
|
|
|
|
expect(recorder.paths, ['/scrobble/start', '/scrobble/stop']);
|
|
expect(recorder.callFor('/scrobble/stop').body['progress'], 47.5);
|
|
expect(recorder.callsFor('/sync/history'), isEmpty);
|
|
});
|
|
|
|
test('disabling both Trakt toggles suppresses every request', () async {
|
|
await trakt.setEnabled(false);
|
|
await trakt.setWatchedSyncEnabled(false);
|
|
|
|
await crossThresholdThenStopBelowTraktRule();
|
|
|
|
expect(recorder.calls, isEmpty);
|
|
});
|
|
|
|
test('an account rebind before stop keeps the terminal report off the new account', () async {
|
|
await startAtZero();
|
|
moveWithoutSeeking(const Duration(seconds: 40));
|
|
|
|
final replacement = _TraktRecorder();
|
|
trakt.rebindSession(_session('replacement-token'), onSessionInvalidated: () {}, httpClient: replacement.client);
|
|
await coordinator.stopPlayback();
|
|
await pumpEventQueue();
|
|
|
|
expect(recorder.paths, ['/scrobble/start']);
|
|
expect(replacement.calls, isEmpty);
|
|
});
|
|
});
|
|
}
|