import 'package:drift/native.dart'; import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/media/library_query.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/server_capabilities.dart'; import 'package:plezy/models/download_models.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/sync_rule_executor.dart'; import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection( machineId: 'jf-machine', userId: userId, serverName: 'Shared JF', userName: userId, accessToken: 'token-$userId', deviceId: 'device', createdAt: DateTime.fromMillisecondsSinceEpoch(0), ); void main() { setUp(resetSharedPreferencesForTest); test('profile-scoped Jellyfin sync rule executes through active client', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); JellyfinApiCache.initialize(db); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); await db.close(); }); final pathsByUser = >{'user-a': [], 'user-b': []}; JellyfinClient clientFor(String userId) { return JellyfinClient.forTesting( connection: _jellyfinConnection(userId), httpClient: MockClient((request) async { pathsByUser[userId]!.add('${request.method} ${request.url.path}?${request.url.query}'); if (request.method == 'GET' && request.url.path == '/Users/$userId/Items/show-1') { return http.Response( '{"Id":"show-1","Type":"Series","Name":"Show $userId","RecursiveItemCount":1}', 200, headers: {'content-type': 'application/json'}, ); } if (request.method == 'GET' && request.url.path == '/Items') { return http.Response( '{"Items":[{"Id":"ep-1","Type":"Episode","Name":"Episode $userId","SeriesId":"show-1","UserData":{"PlayCount":0}}]}', 200, headers: {'content-type': 'application/json'}, ); } return http.Response('not found', 404); }), ); } final userA = clientFor('user-a'); final userB = clientFor('user-b'); addTearDown(userA.close); addTearDown(userB.close); manager.debugRegisterJellyfinClientForTesting(userB, online: false); manager.debugRegisterJellyfinClientForTesting(userA); await db.insertSyncRule( profileId: 'profile-a', serverId: ServerId('jf-machine'), ratingKey: 'show-1', globalKey: 'profile-a|jf-machine:show-1', targetType: 'show', episodeCount: 1, ); await db.insertWatchAction( serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-b', ratingKey: 'ep-1', actionType: OfflineActionType.watched.id, ); await db.insertWatchAction( profileId: 'profile-b', serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'ep-1', actionType: OfflineActionType.watched.id, ); final queued = <({MediaItem item, MediaServerClient client})>[]; final executor = SyncRuleExecutor(database: db); final results = await executor.executeSyncRules( profileId: 'profile-a', serverManager: manager, downloads: const {}, metadata: const {}, queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queued.add((item: item, client: client)); return true; }, isOffline: false, force: true, ); expect(results.single.queuedCount, 1); expect(queued.single.client, same(userA)); expect(pathsByUser['user-a']!.where((p) => p.startsWith('GET /Items?')), isNotEmpty); expect(pathsByUser['user-b'], isEmpty); }); test('profile-scoped sync rule excludes only the active profile local watched actions', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); JellyfinApiCache.initialize(db); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); await db.close(); }); final paths = []; final userA = JellyfinClient.forTesting( connection: _jellyfinConnection('user-a'), httpClient: MockClient((request) async { paths.add('${request.method} ${request.url.path}?${request.url.query}'); if (request.method == 'GET' && request.url.path == '/Users/user-a/Items/show-1') { return http.Response( '{"Id":"show-1","Type":"Series","Name":"Show","RecursiveItemCount":1}', 200, headers: {'content-type': 'application/json'}, ); } if (request.method == 'GET' && request.url.path == '/Items') { return http.Response( '{"Items":[{"Id":"ep-1","Type":"Episode","Name":"Episode","SeriesId":"show-1","UserData":{"PlayCount":0}}]}', 200, headers: {'content-type': 'application/json'}, ); } return http.Response('not found', 404); }), ); addTearDown(userA.close); manager.debugRegisterJellyfinClientForTesting(userA); await db.insertSyncRule( profileId: 'profile-a', serverId: ServerId('jf-machine'), ratingKey: 'show-1', globalKey: 'profile-a|jf-machine:show-1', targetType: 'show', episodeCount: 1, ); await db.insertWatchAction( profileId: 'profile-a', serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'ep-1', actionType: OfflineActionType.watched.id, ); final queued = []; final executor = SyncRuleExecutor(database: db); final results = await executor.executeSyncRules( profileId: 'profile-a', serverManager: manager, downloads: const {}, metadata: const {}, queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queued.add(item); return true; }, isOffline: false, force: true, ); expect(results, isEmpty); expect(queued, isEmpty); expect(paths.where((p) => p.startsWith('GET /Items?')), isNotEmpty); }); test('profile-scoped Jellyfin sync rule does not execute for another profile', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); JellyfinApiCache.initialize(db); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); await db.close(); }); final paths = []; final userB = JellyfinClient.forTesting( connection: _jellyfinConnection('user-b'), httpClient: MockClient((request) async { paths.add('${request.method} ${request.url.path}?${request.url.query}'); return http.Response('not found', 404); }), ); addTearDown(userB.close); manager.debugRegisterJellyfinClientForTesting(userB); await db.insertSyncRule( profileId: 'profile-a', serverId: ServerId('jf-machine'), ratingKey: 'show-1', globalKey: 'profile-a|jf-machine:show-1', targetType: 'show', episodeCount: 1, ); final executor = SyncRuleExecutor(database: db); final results = await executor.executeSyncRules( profileId: 'profile-b', serverManager: manager, downloads: const {}, metadata: const {}, queueSingleDownload: (item, client, {int mediaIndex = 0}) async => true, isOffline: false, force: true, ); expect(results, isEmpty); expect(paths, isEmpty); }); test('profile-scoped Jellyfin sync rule counts shared public downloads as already present', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); JellyfinApiCache.initialize(db); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); await db.close(); }); final paths = []; final userB = JellyfinClient.forTesting( connection: _jellyfinConnection('user-b'), httpClient: MockClient((request) async { paths.add('${request.method} ${request.url.path}?${request.url.query}'); if (request.method == 'GET' && request.url.path == '/Users/user-b/Items/show-1') { return http.Response( '{"Id":"show-1","Type":"Series","Name":"Show","RecursiveItemCount":1}', 200, headers: {'content-type': 'application/json'}, ); } if (request.method == 'GET' && request.url.path == '/Items') { return http.Response( '{"Items":[{"Id":"ep-1","Type":"Episode","Name":"Episode","SeriesId":"show-1","UserData":{"PlayCount":0}}]}', 200, headers: {'content-type': 'application/json'}, ); } return http.Response('not found', 404); }), ); addTearDown(userB.close); manager.debugRegisterJellyfinClientForTesting(userB); await db.insertSyncRule( profileId: 'profile-b', serverId: ServerId('jf-machine'), ratingKey: 'show-1', globalKey: 'profile-b|jf-machine:show-1', targetType: 'show', episodeCount: 1, ); final queued = []; final executor = SyncRuleExecutor(database: db); final results = await executor.executeSyncRules( profileId: 'profile-b', serverManager: manager, downloads: const { 'jf-machine:ep-1': DownloadProgress(globalKey: 'jf-machine:ep-1', status: DownloadStatus.completed), }, metadata: const {}, queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queued.add(item); return true; }, isOffline: false, force: true, ); expect(results, isEmpty); expect(queued, isEmpty); expect(paths.where((p) => p.startsWith('GET /Items?')), isNotEmpty); }); test('show sync rule respects includeSpecials=false when expanding episodes', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); await db.close(); }); final client = _PlayableDescendantsClient([ _episode('s1e1', parentIndex: 1, index: 1, originallyAvailableAt: '2022-10-05'), _episode('s0e1', parentIndex: 0, index: 1, originallyAvailableAt: '2022-10-27'), _episode('s1e2', parentIndex: 1, index: 2, originallyAvailableAt: '2022-11-02'), ]); manager.debugRegisterClientForTesting(client); const ruleKey = 'profile-a|plex-machine:show-1'; final show = testMediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show'); await db.insertSyncRule( profileId: 'profile-a', serverId: ServerId('plex-machine'), ratingKey: 'show-1', globalKey: ruleKey, targetType: 'show', episodeCount: 0, includeSpecials: false, ); final queued = []; final executor = SyncRuleExecutor(database: db); final results = await executor.executeSyncRules( profileId: 'profile-a', serverManager: manager, downloads: const {}, metadata: {ruleKey: show}, queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queued.add(item); return true; }, isOffline: false, force: true, ); expect(results.single.queuedCount, 2); expect(queued.map((item) => item.id), ['s1e1', 's1e2']); expect(client.fetchPlayableDescendantsCalls, ['show-1']); }); test('collection sync rule pages through collection API instead of metadata children', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); await db.close(); }); final client = _CollectionPagingClient(); manager.debugRegisterClientForTesting(client); const ruleKey = 'profile-a|plex-machine:collection-1'; final collection = testMediaItem( id: 'collection-1', backend: MediaBackend.plex, kind: MediaKind.collection, title: 'Collection', serverId: 'plex-machine', ); await db.insertSyncRule( profileId: 'profile-a', serverId: ServerId('plex-machine'), ratingKey: 'collection-1', globalKey: ruleKey, targetType: 'collection', episodeCount: 0, downloadFilter: SyncRuleFilter.all, ); final queued = []; final executor = SyncRuleExecutor(database: db); final results = await executor.executeSyncRules( profileId: 'profile-a', serverManager: manager, downloads: const {}, metadata: {ruleKey: collection}, queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queued.add(item); return true; }, isOffline: false, force: true, ); expect(results.single.queuedCount, 1); expect(queued.single.id, 'movie-1'); expect(client.collectionPageCalls, [(start: 0, size: 100)]); expect(client.fetchChildrenCalled, isFalse); }); test('collectItemsForList accepts tracks and expands albums/artists', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); addTearDown(db.close); final executor = SyncRuleExecutor(database: db); final albumTracks = [_track('album-track-1'), _track('album-track-2', played: true)]; final client = _PlayableDescendantsClient(albumTracks); final items = [ _track('loose-track'), testMediaItem(id: 'album-1', backend: MediaBackend.plex, kind: MediaKind.album, title: 'Album'), testMediaItem(id: 'artist-1', backend: MediaBackend.plex, kind: MediaKind.artist, title: 'Artist'), // Still skipped: nested lists / unplayable kinds. testMediaItem(id: 'photo-1', backend: MediaBackend.plex, kind: MediaKind.photo, title: 'Photo'), ]; final out = []; await executor.collectItemsForList(client, items, unwatchedOnly: false, out: out); expect(client.fetchPlayableDescendantsCalls, ['album-1', 'artist-1']); expect(out.map((i) => i.id), ['loose-track', 'album-track-1', 'album-track-2', 'album-track-1', 'album-track-2']); // unwatchedOnly applies the play-count filter to tracks too. final unwatched = []; await executor.collectItemsForList( client, [_track('played-track', played: true), items[1]], unwatchedOnly: true, out: unwatched, ); expect(unwatched.map((i) => i.id), ['album-track-1']); }); } MediaItem _track(String id, {bool played = false}) { return testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.track, title: id, viewCount: played ? 1 : 0); } MediaItem _episode(String id, {required int parentIndex, required int index, String? originallyAvailableAt}) { return testMediaItem( id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: id, parentIndex: parentIndex, index: index, originallyAvailableAt: originallyAvailableAt, ); } class _PlayableDescendantsClient implements MediaServerClient { _PlayableDescendantsClient(this.leaves); final List leaves; final fetchPlayableDescendantsCalls = []; @override ServerId get serverId => ServerId('plex-machine'); @override String? get serverName => 'Plex'; @override MediaBackend get backend => MediaBackend.plex; @override ServerCapabilities get capabilities => ServerCapabilities.plex; @override bool get isOfflineMode => false; @override void close() {} @override Future fetchItem(String id) async => null; @override Future> fetchPlayableDescendants(String parentId) async { fetchPlayableDescendantsCalls.add(parentId); return leaves; } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } class _CollectionPagingClient implements MediaServerClient { bool fetchChildrenCalled = false; final collectionPageCalls = <({int? start, int? size})>[]; @override ServerId get serverId => ServerId('plex-machine'); @override String? get serverName => 'Plex'; @override MediaBackend get backend => MediaBackend.plex; @override ServerCapabilities get capabilities => ServerCapabilities.plex; @override bool get isOfflineMode => false; @override void close() {} @override Future fetchItem(String id) async => null; @override Future> fetchChildren(String parentId) async { fetchChildrenCalled = true; throw StateError('collection rules must not use fetchChildren'); } @override Future> fetchCollectionPage( String collectionId, { int? start, int? size, abort, String? libraryId, String? libraryTitle, }) async { collectionPageCalls.add((start: start, size: size)); expect(collectionId, 'collection-1'); return LibraryPage( items: [testMediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie')], totalCount: 1, offset: start ?? 0, ); } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); }