195 lines
6.6 KiB
Dart
195 lines
6.6 KiB
Dart
import 'package:drift/native.dart';
|
|
import 'package:plezy/media/ids.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/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';
|
|
import 'package:plezy/utils/active_client_scope.dart';
|
|
import 'package:plezy/utils/watch_state_notifier.dart';
|
|
import '../test_helpers/media_items.dart';
|
|
import '../test_helpers/playback_report_fakes.dart';
|
|
|
|
class _RecordingClient with PlaybackReportRecorder implements MediaServerClient, ScopedMediaServerClient {
|
|
_RecordingClient({this.backend = MediaBackend.plex, String? scopedServerId})
|
|
: scopedServerId =
|
|
scopedServerId ??
|
|
(backend == MediaBackend.plex
|
|
? buildPlexProfileScopeId(serverId: ServerId('srv'), profileId: 'profile-a')
|
|
: 'srv/user-a');
|
|
|
|
bool failStart = false;
|
|
bool failStop = false;
|
|
final started = <({int positionMs, int? durationMs})>[];
|
|
final stopped = <({int positionMs, int? durationMs})>[];
|
|
final watched = <String>[];
|
|
|
|
@override
|
|
ServerId get serverId => ServerId('srv');
|
|
|
|
@override
|
|
final MediaBackend backend;
|
|
@override
|
|
final String scopedServerId;
|
|
|
|
@override
|
|
double get watchedThreshold => 0.9;
|
|
|
|
// Mirror the real clients: Jellyfin marks played from the stopped report, so
|
|
// the external-player completion path emits only the local watch event
|
|
// (#1287); Plex needs the explicit markWatched.
|
|
@override
|
|
bool get marksWatchedOnPlaybackStopped => backend == MediaBackend.jellyfin;
|
|
|
|
@override
|
|
Future<void> onPlaybackReport(PlaybackReportCall call) async {
|
|
final entry = (positionMs: call.position.inMilliseconds, durationMs: call.duration?.inMilliseconds);
|
|
switch (call.kind) {
|
|
case PlaybackReportKind.started:
|
|
started.add(entry);
|
|
if (failStart) throw StateError('start failed');
|
|
case PlaybackReportKind.progress:
|
|
throw UnimplementedError();
|
|
case PlaybackReportKind.stopped:
|
|
stopped.add(entry);
|
|
if (failStop) throw StateError('stop failed');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> markWatched(MediaItem item) async {
|
|
watched.add(item.id);
|
|
}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
MediaItem _item({int? durationMs}) {
|
|
return testMediaItem(
|
|
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);
|
|
});
|
|
|
|
test('Android external progress emits the exact client cache scope', () async {
|
|
final scope = buildPlexProfileScopeId(serverId: ServerId('srv'), profileId: 'profile-a');
|
|
final client = _RecordingClient(scopedServerId: scope);
|
|
final events = <WatchStateEvent>[];
|
|
final subscription = WatchStateNotifier()
|
|
.forItem('item-1')
|
|
.where((event) => event.changeType == WatchStateChangeType.progressUpdate)
|
|
.listen(events.add);
|
|
addTearDown(subscription.cancel);
|
|
|
|
await ExternalPlayerService.reportAndroidExternalProgressForTesting(
|
|
positionMs: 5000,
|
|
durationMs: 100000,
|
|
metadata: _item(durationMs: 100000),
|
|
client: client,
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(events, hasLength(1));
|
|
expect(events.single.cacheServerId, scope);
|
|
});
|
|
|
|
test('Android external progress ignores missing position without explicit completion', () async {
|
|
final client = _RecordingClient();
|
|
|
|
await ExternalPlayerService.reportAndroidExternalProgressForTesting(
|
|
positionMs: null,
|
|
durationMs: 100000,
|
|
playbackCompleted: false,
|
|
metadata: _item(durationMs: 100000),
|
|
client: client,
|
|
);
|
|
|
|
expect(client.started, isEmpty);
|
|
expect(client.stopped, isEmpty);
|
|
});
|
|
|
|
test('Android external progress reports full duration for explicit completion', () async {
|
|
final client = _RecordingClient();
|
|
|
|
await ExternalPlayerService.reportAndroidExternalProgressForTesting(
|
|
positionMs: null,
|
|
durationMs: 100000,
|
|
playbackCompleted: true,
|
|
metadata: _item(durationMs: 100000),
|
|
client: client,
|
|
);
|
|
|
|
expect(client.started, [(positionMs: 100000, durationMs: 100000)]);
|
|
expect(client.stopped, [(positionMs: 100000, durationMs: 100000)]);
|
|
expect(client.watched, ['item-1']);
|
|
});
|
|
|
|
test('Android external completion on Jellyfin marks watched via the stop report, not markWatched (#1287)', () async {
|
|
final client = _RecordingClient(backend: MediaBackend.jellyfin);
|
|
|
|
await ExternalPlayerService.reportAndroidExternalProgressForTesting(
|
|
positionMs: null,
|
|
durationMs: 100000,
|
|
playbackCompleted: true,
|
|
metadata: _item(durationMs: 100000),
|
|
client: client,
|
|
);
|
|
|
|
// The stopped report at full duration marks it played server-side…
|
|
expect(client.stopped, [(positionMs: 100000, durationMs: 100000)]);
|
|
// …so the explicit markWatched is skipped — issuing it would double-scrobble
|
|
// through the Jellyfin Trakt plugin.
|
|
expect(client.watched, isEmpty);
|
|
});
|
|
}
|