fix(playback): harden watch progress edge cases

This commit is contained in:
edde746
2026-05-29 21:16:01 +02:00
parent d93ea9813f
commit dba01f14bb
20 changed files with 593 additions and 163 deletions
@@ -125,6 +125,31 @@ void main() {
});
});
group('DownloadProvider — local file selection', () {
test('falls back to media index when caller has no source id', () async {
const globalKey = 'srv:movie-1';
await db.insertDownload(
serverId: 'srv',
ratingKey: 'movie-1',
globalKey: globalKey,
type: 'movie',
status: DownloadStatus.completed.index,
mediaIndex: 0,
mediaSourceId: 'source-a',
);
await db.updateVideoFilePath(globalKey, 'content://offline/movie-1-v1');
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(ownedDownloadKeys: {globalKey});
expect(await p.getVideoFilePath(globalKey, mediaIndex: 1), isNull);
expect(await p.getVideoFilePath(globalKey, mediaIndex: 0), 'content://offline/movie-1-v1');
p.dispose();
});
});
group('DownloadProvider — sync rule CRUD', () {
test('createSyncRule inserts into the database and updates the in-memory map', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
@@ -1,65 +1,68 @@
import 'package:flutter_test/flutter_test.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/providers/watch_state_overlay_provider.dart';
import 'package:plezy/utils/watch_state_notifier.dart';
MediaItem _item({String id = '1', int? viewOffsetMs, int? viewCount = 0}) {
return MediaItem(
id: id,
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie',
serverId: 'server',
durationMs: 100000,
viewOffsetMs: viewOffsetMs,
viewCount: viewCount,
Future<void> _emit(WatchStateEvent event) async {
WatchStateNotifier().notify(event);
await Future<void>.delayed(Duration.zero);
}
WatchStateEvent _event({
required WatchStateChangeType changeType,
required bool? isNowWatched,
String serverId = 'jf-machine',
String itemId = 'item-1',
String? cacheServerId,
int? viewOffset,
}) {
return WatchStateEvent(
itemId: itemId,
serverId: serverId,
cacheServerId: cacheServerId,
changeType: changeType,
parentChain: const [],
mediaType: 'movie',
isNowWatched: isNowWatched,
viewOffset: viewOffset,
);
}
Future<void> _drainEvents() => Future<void>.delayed(Duration.zero);
void main() {
group('WatchStateOverlayProvider', () {
test('applies watched patches immediately', () async {
final provider = WatchStateOverlayProvider();
addTearDown(provider.dispose);
final item = _item(viewOffsetMs: 40000);
test('removed from continue watching does not replace an existing watched patch', () async {
final provider = WatchStateOverlayProvider();
addTearDown(provider.dispose);
WatchStateNotifier().notifyWatched(item: item, isNowWatched: true);
await _drainEvents();
await _emit(_event(changeType: WatchStateChangeType.watched, isNowWatched: true));
await _emit(_event(changeType: WatchStateChangeType.removedFromContinueWatching, isNowWatched: null));
final patched = provider.apply(item);
expect(patched.isWatched, isTrue);
expect(patched.viewOffsetMs, 0);
});
final patch = provider.patchForGlobalKey('jf-machine:item-1');
expect(patch?.isWatched, isTrue);
expect(patch?.viewOffsetMs, 0);
});
test('applies progress patches without changing watched state', () async {
final provider = WatchStateOverlayProvider();
addTearDown(provider.dispose);
final item = _item(viewCount: 1);
test('newer unscoped patch wins over older active scoped patch', () async {
final provider = WatchStateOverlayProvider();
addTearDown(provider.dispose);
provider.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'});
WatchStateNotifier().notifyProgress(item: item, viewOffset: 30000, duration: 100000);
await _drainEvents();
await _emit(
_event(changeType: WatchStateChangeType.watched, isNowWatched: true, cacheServerId: 'jf-machine/user-a'),
);
await _emit(_event(changeType: WatchStateChangeType.unwatched, isNowWatched: false));
final patched = provider.apply(item);
expect(patched.isWatched, isTrue);
expect(patched.viewOffsetMs, 30000);
});
expect(provider.patchForGlobalKey('jf-machine:item-1')?.isWatched, isFalse);
});
test('clears patches when active profile changes', () async {
final provider = WatchStateOverlayProvider();
addTearDown(provider.dispose);
final item = _item();
test('newer active scoped patch wins over older unscoped patch', () async {
final provider = WatchStateOverlayProvider();
addTearDown(provider.dispose);
provider.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'});
provider.setActiveProfileId('a');
WatchStateNotifier().notifyWatched(item: item, isNowWatched: true);
await _drainEvents();
expect(provider.apply(item).isWatched, isTrue);
await _emit(_event(changeType: WatchStateChangeType.unwatched, isNowWatched: false));
await _emit(
_event(changeType: WatchStateChangeType.watched, isNowWatched: true, cacheServerId: 'jf-machine/user-a'),
);
provider.setActiveProfileId('b');
expect(provider.apply(item).isWatched, isFalse);
});
expect(provider.patchForGlobalKey('jf-machine:item-1')?.isWatched, isTrue);
});
}
@@ -0,0 +1,111 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.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/media/playback_report_metadata.dart';
import 'package:plezy/services/external_player_service.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/offline_watch_sync_service.dart';
class _RecordingClient implements MediaServerClient {
bool failStart = false;
bool failStop = false;
final started = <({int positionMs, int? durationMs})>[];
final stopped = <({int positionMs, int? durationMs})>[];
@override
String get serverId => 'srv';
@override
MediaBackend get backend => MediaBackend.plex;
@override
double get watchedThreshold => 0.9;
@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((positionMs: position.inMilliseconds, durationMs: duration?.inMilliseconds));
if (failStart) throw StateError('start failed');
}
@override
Future<void> reportPlaybackStopped({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? mediaSourceId,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async {
stopped.add((positionMs: position.inMilliseconds, durationMs: duration?.inMilliseconds));
if (failStop) throw StateError('stop failed');
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
MediaItem _item({int? durationMs}) {
return MediaItem(
id: 'item-1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
serverId: 'srv',
durationMs: durationMs,
);
}
void main() {
test('Android external progress preserves null duration and still stops after start failure', () async {
final client = _RecordingClient()..failStart = true;
await ExternalPlayerService.reportAndroidExternalProgressForTesting(
positionMs: 5000,
durationMs: null,
metadata: _item(),
client: client,
);
expect(client.started, [(positionMs: 5000, durationMs: null)]);
expect(client.stopped, [(positionMs: 5000, durationMs: null)]);
});
test('Android external progress queues unknown-duration resume when no client is available', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
JellyfinApiCache.initialize(db);
final manager = MultiServerManager();
final service = OfflineWatchSyncService(database: db, serverManager: manager);
addTearDown(() async {
service.dispose();
manager.dispose();
await db.close();
});
await ExternalPlayerService.reportAndroidExternalProgressForTesting(
positionMs: 5000,
durationMs: null,
metadata: _item(),
client: null,
offlineWatchService: service,
);
final action = await db.getLatestWatchAction('srv:item-1');
expect(action, isNotNull);
expect(action!.viewOffset, 5000);
expect(action.duration, isNull);
expect(action.shouldMarkWatched, isFalse);
});
}
@@ -357,6 +357,30 @@ void main() {
expect(await svc.getPendingSyncCount(), 0);
});
test('unknown-duration offline progress still replays stopped position', () 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: null);
await svc.syncPendingItems();
expect(client.started, hasLength(1));
expect(client.started.single.positionMs, 50000);
expect(client.started.single.durationMs, isNull);
expect(client.stopped, hasLength(1));
expect(client.stopped.single.positionMs, 50000);
expect(client.stopped.single.durationMs, isNull);
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 {
@@ -409,6 +433,24 @@ void main() {
expect(action.shouldMarkWatched, isFalse);
});
test('persists unknown-duration progress without marking watched', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 50, duration: null);
final action = await db.getLatestWatchAction('srv:42');
expect(action, isNotNull);
expect(action!.actionType, 'progress');
expect(action.viewOffset, 50);
expect(action.duration, isNull);
expect(action.shouldMarkWatched, isFalse);
});
test('persists shouldMarkWatched=true at/above the default 0.9 threshold', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
@@ -486,9 +528,9 @@ void main() {
await db.close();
});
// Below threshold is resume-only, not an explicit unwatched override.
// Below threshold is explicit local progress, so it overrides stale watched metadata.
await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 50, duration: 100);
expect(await svc.getLocalWatchStatus('srv:1'), isNull);
expect(await svc.getLocalWatchStatus('srv:1'), isFalse);
// Above threshold → shouldMarkWatched=true → status=true.
await svc.queueProgressUpdate(serverId: 'srv', itemId: '2', viewOffset: 99, duration: 100);
@@ -767,7 +809,7 @@ void main() {
expect(await svc.getLocalWatchStatus('jf-machine:item-1'), isTrue);
expect(await svc.getLocalViewOffset('jf-machine:item-1'), isNull);
expect(await svc.getLocalWatchStatus('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), 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);
});
@@ -134,6 +134,22 @@ void main() {
expect(result.mediaInfo?.audioTracks.single.languageCode, 'fre');
});
test('offline path falls back to media index when caller has no source id', () async {
await _insertDownloaded(
db,
serverId: 'srv-1',
ratingKey: 'movie-1',
videoFilePath: 'content://offline/movie-1-v1',
mediaIndex: 0,
mediaSourceId: 'source-a',
);
final service = PlaybackInitializationService(database: db);
expect(await service.getOfflineVideoPath('srv-1', 'movie-1', mediaIndex: 1), null);
expect(await service.getOfflineVideoPath('srv-1', 'movie-1', mediaIndex: 0), 'content://offline/movie-1-v1');
});
test('pure-offline Jellyfin cache works without a connection row', () async {
await _insertDownloaded(
db,
@@ -312,6 +328,7 @@ Future<void> _insertDownloaded(
required String ratingKey,
required String videoFilePath,
int mediaIndex = 0,
String? mediaSourceId,
}) async {
await db
.into(db.downloadedMedia)
@@ -325,6 +342,7 @@ Future<void> _insertDownloaded(
status: DownloadStatus.completed.index,
videoFilePath: Value(videoFilePath),
mediaIndex: Value(mediaIndex),
mediaSourceId: Value(mediaSourceId),
),
);
}
@@ -8,6 +8,7 @@ import 'package:plezy/services/playback_report_session.dart';
class _RecordingClient implements MediaServerClient {
final calls = <String>[];
Completer<void>? startGate;
Completer<void>? stopGate;
bool failNextStop = false;
@override
@@ -51,6 +52,8 @@ class _RecordingClient implements MediaServerClient {
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async {
calls.add('stopped-attempt:${position.inMilliseconds}:$mediaSourceId');
final gate = stopGate;
if (gate != null) await gate.future;
if (failNextStop) {
failNextStop = false;
throw StateError('stop failed');
@@ -174,4 +177,24 @@ void main() {
expect(client.calls, ['stopped-attempt:1000:null', 'stopped-attempt:3000:null', 'stopped:3000:null']);
});
test('resetAfterStop during in-flight stop reopens reporting after stop completes', () async {
final client = _RecordingClient()..stopGate = Completer<void>();
final session = PlaybackReportSession(client: client, itemId: 'item-1');
await session.report(_snapshot('playing', positionMs: 1000));
client.calls.clear();
final stopFuture = session.report(_snapshot('stopped', positionMs: 3000));
await Future<void>.delayed(Duration.zero);
expect(client.calls, ['stopped-attempt:3000:null']);
session.resetAfterStop();
client.stopGate!.complete();
await stopFuture;
expect(session.isIdle, isTrue);
expect(await session.report(_snapshot('playing', positionMs: 4000)), isTrue);
expect(client.calls, ['stopped-attempt:3000:null', 'stopped:3000:null', 'started:4000:null:null:null']);
});
}
@@ -0,0 +1,62 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.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/models/transcode_quality_preset.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/playback_context.dart';
import 'package:plezy/services/playback_initialization_types.dart';
import 'package:plezy/services/playback_source_resolver.dart';
class _PlaybackClient implements MediaServerClient {
@override
String get serverId => 'srv';
@override
MediaBackend get backend => MediaBackend.plex;
@override
double get watchedThreshold => 0.9;
@override
Map<String, String> get streamHeaders => const {'X-Test': 'token'};
@override
void close() {}
@override
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options) async {
return PlaybackInitializationResult(availableVersions: const [], videoUrl: 'https://example.com/video.mp4');
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
test('online playback uses registered client even when status is stale offline', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final manager = MultiServerManager();
addTearDown(() async {
manager.dispose();
await db.close();
});
final client = _PlaybackClient();
manager.debugRegisterClientForTesting(client, online: false);
final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'),
selectedMediaIndex: 0,
offlineLibraryMode: false,
qualityPreset: TranscodeQualityPreset.original,
);
expect(context.result.videoUrl, 'https://example.com/video.mp4');
expect(context.reportingClient, same(client));
expect(context.reportingMode, PlaybackReportingMode.online);
});
}
@@ -0,0 +1,81 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/services/watch_state_resolver.dart';
import 'package:plezy/utils/watch_state_notifier.dart';
OfflineWatchProgressItem _action({
required String actionType,
required int updatedAt,
int? viewOffset,
int? duration,
bool shouldMarkWatched = false,
}) {
return OfflineWatchProgressItem(
id: updatedAt,
serverId: 'srv',
ratingKey: 'item-1',
globalKey: 'srv:item-1',
actionType: actionType,
viewOffset: viewOffset,
duration: duration,
shouldMarkWatched: shouldMarkWatched,
createdAt: updatedAt,
updatedAt: updatedAt,
syncAttempts: 0,
);
}
void main() {
test('newer sub-threshold progress overrides older watched state without watched-plus-resume', () {
final snapshot = WatchStateResolver.fromActions([
_action(actionType: 'watched', updatedAt: 1),
_action(actionType: 'progress', updatedAt: 2, viewOffset: 5000, duration: 100000),
]);
expect(snapshot.isWatched, isFalse);
expect(snapshot.hasViewOffsetMs, isTrue);
expect(snapshot.viewOffsetMs, 5000);
});
test('newer watched action clears older progress offset', () {
final snapshot = WatchStateResolver.fromActions([
_action(actionType: 'progress', updatedAt: 1, viewOffset: 5000, duration: 100000),
_action(actionType: 'watched', updatedAt: 2),
]);
expect(snapshot.isWatched, isTrue);
expect(snapshot.hasViewOffsetMs, isTrue);
expect(snapshot.viewOffsetMs, 0);
});
test('sub-threshold progress events explicitly clear watched state', () {
final snapshot = WatchStateResolver.fromEvent(
WatchStateEvent(
itemId: 'item-1',
serverId: 'srv',
changeType: WatchStateChangeType.progressUpdate,
parentChain: const [],
mediaType: 'movie',
viewOffset: 5000,
isNowWatched: false,
),
);
expect(snapshot.isWatched, isFalse);
expect(snapshot.viewOffsetMs, 5000);
});
test('removed from continue watching is not a watch-state overlay patch', () {
final snapshot = WatchStateResolver.fromEvent(
WatchStateEvent(
itemId: 'item-1',
serverId: 'srv',
changeType: WatchStateChangeType.removedFromContinueWatching,
parentChain: const [],
mediaType: 'movie',
),
);
expect(snapshot.isEmpty, isTrue);
});
}