@@ -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);
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user