Files
plezy/test/services/external_player_service_test.dart
T

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);
});
}