From 9e5098f356cd6a289aa865f9313e586d8f73490a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 30 May 2026 08:13:42 +0200 Subject: [PATCH] fix(trackers): respect server completion threshold close #1194 --- lib/services/trackers/tracker_constants.dart | 6 +- .../trackers/tracker_coordinator.dart | 17 ++++- .../tracker_coordinator_manual_test.dart | 68 +++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/lib/services/trackers/tracker_constants.dart b/lib/services/trackers/tracker_constants.dart index 0a5fbf2f..ec73a460 100644 --- a/lib/services/trackers/tracker_constants.dart +++ b/lib/services/trackers/tracker_constants.dart @@ -2,8 +2,10 @@ class TrackerConstants { TrackerConstants._(); - /// Progress percent at which an episode/movie counts as watched and is - /// pushed to each tracker. + /// Fallback watched threshold (percent) used only until the active server's + /// threshold is known. The operative value follows + /// [MediaServerClient.watchedThreshold] (captured per playback in + /// [TrackerCoordinator]); this constant just seeds the field before playback. static const double watchedThresholdPercent = 80.0; static const Duration requestTimeout = Duration(seconds: 20); diff --git a/lib/services/trackers/tracker_coordinator.dart b/lib/services/trackers/tracker_coordinator.dart index 518277f8..c34d7924 100644 --- a/lib/services/trackers/tracker_coordinator.dart +++ b/lib/services/trackers/tracker_coordinator.dart @@ -42,6 +42,17 @@ class TrackerCoordinator { Duration _lastPosition = Duration.zero; bool _thresholdCrossed = false; + /// Seed used before [startPlayback] captures the server's threshold; never + /// actually consulted (a crossing is only evaluated once `_ctx` is set, + /// after the client value is assigned). + static const double _fallbackWatchedThreshold = TrackerConstants.watchedThresholdPercent / 100.0; + + /// Captured from the active server client in [startPlayback]; trackers mark + /// watched once progress crosses it (Plex's `LibraryVideoPlayedThreshold`, + /// Jellyfin's fixed 0.9). Mirrors [PlaybackProgressTracker]'s local-marking + /// path so trackers and the server stay in lock-step. + double _watchedThreshold = _fallbackWatchedThreshold; + Future initialize() async { await Future.wait(_trackers.map((t) => t.initialize())); } @@ -71,6 +82,7 @@ class TrackerCoordinator { } _reset(); _ctx = ctx; + _watchedThreshold = client.watchedThreshold; } bool _anyTrackerNeedsFribb() => _anyTrackerNeedsFribbForLibrary(_activeLibraryGlobalKey); @@ -332,12 +344,13 @@ class TrackerCoordinator { _duration = Duration.zero; _lastPosition = Duration.zero; _thresholdCrossed = false; + _watchedThreshold = _fallbackWatchedThreshold; } - static bool _crossed(Duration duration, Duration position) { + bool _crossed(Duration duration, Duration position) { final dMs = duration.inMilliseconds; if (dMs == 0) return false; - return position.inMilliseconds * 100 >= dMs * TrackerConstants.watchedThresholdPercent; + return position.inMilliseconds / dMs >= _watchedThreshold; } Future _dispatchMarkWatched(TrackerContext ctx) async { diff --git a/test/services/trackers/tracker_coordinator_manual_test.dart b/test/services/trackers/tracker_coordinator_manual_test.dart index c4432376..a5af62b2 100644 --- a/test/services/trackers/tracker_coordinator_manual_test.dart +++ b/test/services/trackers/tracker_coordinator_manual_test.dart @@ -31,10 +31,14 @@ class _FakeMediaServerClient implements MediaServerClient { final List externalIdCalls = []; final List descendantCalls = []; + @override + final double watchedThreshold; + _FakeMediaServerClient({ this.serverId = 'server-1', required this.externalIdsByItem, required this.descendantsByParent, + this.watchedThreshold = 0.9, }); @override @@ -113,6 +117,15 @@ MediaItem _show() => MediaItem( libraryId: 'lib-1', ); +MediaItem _movie() => MediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Movie 1', + serverId: 'server-1', + libraryId: 'lib-1', +); + AnimeEpisodeMatch _match({required int anidbId, required int serverEpisode, required int animeEpisode}) => AnimeEpisodeMatch( anidbId: anidbId, @@ -496,4 +509,59 @@ void main() { expect(secondClient.externalIdCalls, ['show-b']); }); }); + + group('TrackerCoordinator playback threshold', () { + final coordinator = TrackerCoordinator.instance; + final simkl = SimklTracker.instance; + final mal = MalTracker.instance; + final anilist = AnilistTracker.instance; + + setUp(() async { + await mal.setEnabled(false); + await anilist.setEnabled(false); + await simkl.setEnabled(true); + }); + + tearDown(() async { + coordinator.cancelInFlight(); + coordinator.debugUseResolverDependencies(); + simkl.rebindSession(null, onSessionInvalidated: () {}); + await simkl.setEnabled(false); + }); + + test('marks watched at the server threshold, not the tracker default', () async { + final posts = >[]; + final httpClient = MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/sync/history'); + posts.add((json.decode(request.body) as Map).cast()); + return http.Response('{}', 200); + }); + simkl.rebindSession(_simklSession(), onSessionInvalidated: () {}, httpClient: httpClient); + + final client = _FakeMediaServerClient( + externalIdsByItem: {'movie-1': const ExternalIds(tmdb: 603)}, + descendantsByParent: const {}, + watchedThreshold: 0.95, + ); + + await coordinator.startPlayback(_movie(), client); + coordinator.updateDuration(const Duration(seconds: 100)); + + // 90% — past the old hardcoded 80% tracker default but below the server's 95%. + coordinator.updatePosition(const Duration(seconds: 90)); + await pumpEventQueue(); + expect(posts, isEmpty); + + // 95% — crosses the server threshold; fires exactly once. + coordinator.updatePosition(const Duration(seconds: 95)); + await pumpEventQueue(); + expect(posts, hasLength(1)); + expect(posts.single['movies'], [ + { + 'ids': {'tmdb': 603}, + }, + ]); + }); + }); }