fix(trackers): respect server completion threshold

close #1194
This commit is contained in:
edde746
2026-05-30 08:30:06 +02:00
parent 139eed9ca9
commit 9e5098f356
3 changed files with 87 additions and 4 deletions
+4 -2
View File
@@ -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);
+15 -2
View File
@@ -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<void> 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<void> _dispatchMarkWatched(TrackerContext ctx) async {
@@ -31,10 +31,14 @@ class _FakeMediaServerClient implements MediaServerClient {
final List<String> externalIdCalls = [];
final List<String> 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 = <Map<String, dynamic>>[];
final httpClient = MockClient((request) async {
expect(request.method, 'POST');
expect(request.url.path, '/sync/history');
posts.add((json.decode(request.body) as Map).cast<String, dynamic>());
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},
},
]);
});
});
}