@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user