fix(playback): sync downloaded watch progress

close #1171, close #1183
This commit is contained in:
edde746
2026-05-29 19:55:01 +02:00
parent 5e5702c961
commit 7501f461b9
23 changed files with 418 additions and 69 deletions
@@ -731,6 +731,32 @@ void main() {
expect(p.getMetadata('srv:absent'), isNull);
p.dispose();
});
test('watched progress events mark downloaded metadata watched and clear resume', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
final item = MediaItem(
id: '42',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie',
serverId: 'srv',
durationMs: 100000,
viewOffsetMs: 12000,
viewCount: 0,
);
p.debugSeedState(metadata: {'srv:42': item});
WatchStateNotifier().notifyProgress(item: item, viewOffset: 95000, duration: 100000, watchedThreshold: 0.9);
await Future<void>.delayed(Duration.zero);
final updated = p.getMetadata('srv:42');
expect(updated?.isWatched, isTrue);
expect(updated?.viewOffsetMs, 0);
p.dispose();
});
});
group('DownloadProvider — progress stream', () {
@@ -64,6 +64,17 @@ void main() {
p.dispose();
});
test('getViewOffset returns null for local progress that crossed watched threshold', () async {
final p = OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider);
await syncService.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 95000, duration: 100000);
expect(await p.isWatched('srv:42'), isTrue);
expect(await p.getViewOffset('srv:42'), isNull);
p.dispose();
});
test('getNextUnwatchedEpisode returns null for show with no downloads', () async {
final p = OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider);
expect(await p.getNextUnwatchedEpisode('show-123'), isNull);
@@ -45,6 +45,9 @@ class _FakeJellyfinClient implements JellyfinClient {
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
}) async {
calls.add('stopped:$itemId:$playSessionId');
}
@@ -6,6 +6,10 @@ import 'package:http/testing.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/database/download_operations.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/jellyfin_api_cache.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/multi_server_manager.dart';
@@ -59,6 +63,75 @@ class _FakeOfflineModeSource extends ChangeNotifier implements OfflineModeSource
bool get hasListeners => super.hasListeners;
}
class _RecordingMediaClient implements MediaServerClient {
_RecordingMediaClient({required this.serverId, required this.backend});
@override
final String serverId;
@override
final MediaBackend backend;
@override
double get watchedThreshold => 0.9;
@override
void close() {}
final started = <({String itemId, int positionMs, int? durationMs})>[];
final stopped =
<({String itemId, int positionMs, int? durationMs, bool offline, DateTime? updatedAt, bool? continuing})>[];
final watched = <String>[];
@override
Future<MediaItem?> fetchItem(String id) async =>
MediaItem(id: id, backend: backend, kind: MediaKind.movie, serverId: serverId);
@override
Future<void> reportPlaybackStarted({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? playMethod,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) async {
started.add((itemId: itemId, positionMs: position.inMilliseconds, durationMs: duration?.inMilliseconds));
}
@override
Future<void> reportPlaybackStopped({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
}) async {
stopped.add((
itemId: itemId,
positionMs: position.inMilliseconds,
durationMs: duration?.inMilliseconds,
offline: offline,
updatedAt: updatedAt,
continuing: continuing,
));
}
@override
Future<void> markWatched(MediaItem item) async {
watched.add(item.id);
WatchStateNotifier().notifyWatched(item: item, isNowWatched: true);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// Build a service against an in-memory database and a bare-metal
/// [MultiServerManager] (no servers added).
({OfflineWatchSyncService svc, AppDatabase db, MultiServerManager mgr}) _makeService() {
@@ -262,6 +335,57 @@ void main() {
expect(retained!.syncAttempts, OfflineWatchSyncService.maxSyncAttempts);
expect(retained.lastError, 'server error');
});
test('partial Plex offline progress replays as offline stopped progress', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
final client = _RecordingMediaClient(serverId: 'srv', backend: MediaBackend.plex);
mgr.debugRegisterClientForTesting(client);
await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 50000, duration: 100000);
final queued = await db.getLatestWatchAction('srv:42');
await svc.syncPendingItems();
expect(client.started, hasLength(1));
expect(client.started.single.positionMs, 50000);
expect(client.stopped, hasLength(1));
expect(client.stopped.single.positionMs, 50000);
expect(client.stopped.single.offline, isTrue);
expect(client.stopped.single.updatedAt?.millisecondsSinceEpoch, queued!.updatedAt);
expect(client.watched, isEmpty);
expect(await svc.getPendingSyncCount(), 0);
});
test('completed Plex offline progress replays at duration and marks watched', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
final client = _RecordingMediaClient(serverId: 'srv', backend: MediaBackend.plex);
mgr.debugRegisterClientForTesting(client);
await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 95000, duration: 100000);
final queued = await db.getLatestWatchAction('srv:42');
await svc.syncPendingItems();
expect(client.started, isEmpty);
expect(client.stopped, hasLength(1));
expect(client.stopped.single.positionMs, 100000);
expect(client.stopped.single.durationMs, 100000);
expect(client.stopped.single.offline, isTrue);
expect(client.stopped.single.continuing, isFalse);
expect(client.stopped.single.updatedAt?.millisecondsSinceEpoch, queued!.updatedAt);
expect(client.watched, ['42']);
expect(await svc.getPendingSyncCount(), 0);
});
});
// ============================================================
@@ -646,7 +770,7 @@ void main() {
);
expect(await svc.getLocalWatchStatus('jf-machine:item-1'), isTrue);
expect(await svc.getLocalViewOffset('jf-machine:item-1'), 90000);
expect(await svc.getLocalViewOffset('jf-machine:item-1'), isNull);
expect(await svc.getLocalWatchStatus('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), isFalse);
expect(await svc.getLocalViewOffset('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), 5000);
});
@@ -129,7 +129,15 @@ class _FakePlexClient implements PlexClient {
Object? throwOnNextCall;
@override
Future<void> updateProgress(String ratingKey, {required int time, required String state, int? duration}) async {
Future<void> updateProgress(
String ratingKey, {
required int time,
required String state,
int? duration,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
}) async {
if (throwOnNextCall != null) {
final err = throwOnNextCall!;
throwOnNextCall = null;
@@ -193,6 +201,9 @@ class _FakePlexClient implements PlexClient {
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
}) {
playbackSessionIds.add(playSessionId);
playbackStreamSelections.add((mediaSourceId: mediaSourceId, audioStreamIndex: null, subtitleStreamIndex: null));
@@ -342,6 +353,23 @@ void main() {
expect(call.duration, 100000); // 100s in ms
});
test('"stopped" can override stale player position for completion', () async {
final client = _FakePlexClient();
final player = _FakePlayer(position: const Duration(seconds: 12), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(ratingKey: '42'),
player: player,
isOffline: false,
);
addTearDown(tracker.dispose);
await tracker.sendProgress('stopped', positionOverride: const Duration(seconds: 100));
expect(client.updateProgressCalls.single.time, 100000);
expect(client.markWatchedCalls, ['42']);
});
test('"playing" fires-and-forgets but eventually invokes updateProgress', () async {
final client = _FakePlexClient();
final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100));
@@ -760,6 +788,34 @@ void main() {
await tracker.sendProgress('playing');
expect(await svc.getPendingSyncCount(), 0);
});
test('online local playback queues fallback progress when reporting fails', () async {
final (svc: svc, db: db, mgr: mgr) = await makeOfflineService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
final client = _FakePlexClient()..throwOnNextCall = StateError('offline');
final player = _FakePlayer(position: const Duration(seconds: 10), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(ratingKey: '42', serverId: 'srv'),
player: player,
isOffline: false,
offlineWatchService: svc,
queueOnOnlineFailure: true,
);
addTearDown(tracker.dispose);
await tracker.sendProgress('stopped', positionOverride: const Duration(seconds: 100));
final action = await db.getLatestWatchAction('srv:42');
expect(action, isNotNull);
expect(action!.viewOffset, 100000);
expect(action.shouldMarkWatched, isTrue);
});
});
// ============================================================
@@ -913,7 +969,15 @@ class _ScrobblePreciseClient implements PlexClient {
int markWatchedSuccesses = 0;
@override
Future<void> updateProgress(String ratingKey, {required int time, required String state, int? duration}) async {}
Future<void> updateProgress(
String ratingKey, {
required int time,
required String state,
int? duration,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
}) async {}
@override
Future<void> reportPlaybackStarted({
@@ -947,6 +1011,9 @@ class _ScrobblePreciseClient implements PlexClient {
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
}) async {}
@override
@@ -47,6 +47,9 @@ class _RecordingClient implements MediaServerClient {
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
}) async {
calls.add('stopped-attempt:${position.inMilliseconds}:$mediaSourceId');
if (failNextStop) {