diff --git a/lib/main.dart b/lib/main.dart index 4591be92..4c1f007d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -18,6 +18,7 @@ import 'connection/connection_registry.dart'; import 'profiles/active_profile_binder.dart'; import 'profiles/active_profile_provider.dart'; import 'profiles/profile.dart'; +import 'profiles/profile_connection_cleanup.dart'; import 'profiles/profile_connection_registry.dart'; import 'profiles/profile_registry.dart'; import 'mixins/mounted_set_state_mixin.dart'; @@ -1004,6 +1005,8 @@ class _AppShell extends StatelessWidget { ); }, ); + }, + ); } } @@ -1125,6 +1128,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { if (mounted) { try { final connRegistry = context.read(); + final profileConnections = context.read(); final profileRegistry = context.read(); final activeProfiles = context.read(); final bootstrap = ConnectionBootstrap( @@ -1134,6 +1138,15 @@ class _SetupScreenState extends State with MountedSetStateMixin { profileRegistry: profileRegistry, ); await bootstrap.run(); + final pruned = await pruneUnreferencedJellyfinConnections( + profileConnections: profileConnections, + connections: connRegistry, + storage: storage, + serverManager: context.read().serverManager, + ); + if (pruned > 0) { + appLogger.i('Setup: pruned $pruned unreferenced Jellyfin connection${pruned == 1 ? '' : 's'}'); + } // Provider initialization starts before this screen runs the legacy // migration. Reload after bootstrap so copied Plex Home users and the // selected active profile are visible before setup decides binding is diff --git a/lib/profiles/profile_connection_cleanup.dart b/lib/profiles/profile_connection_cleanup.dart new file mode 100644 index 00000000..eacfd463 --- /dev/null +++ b/lib/profiles/profile_connection_cleanup.dart @@ -0,0 +1,203 @@ +import '../connection/connection.dart'; +import '../connection/connection_registry.dart'; +import '../media/ids.dart'; +import '../services/multi_server_manager.dart'; +import '../services/storage_service.dart'; +import 'profile_connection_registry.dart'; + +Future removeProfileConnectionAndCleanup({ + required String profileId, + required Connection connection, + required ProfileConnectionRegistry profileConnections, + required ConnectionRegistry connections, + required StorageService storage, + MultiServerManager? serverManager, +}) async { + final removedServerIds = _serverIdsForConnection(connection); + await profileConnections.remove(profileId, connection.id); + await _clearProfileServerPrefsNoLongerReferenced( + profileId: profileId, + removedServerIds: removedServerIds, + profileConnections: profileConnections, + connections: connections, + storage: storage, + clearEverywhereWhenUnreferenced: connection is JellyfinConnection, + ); + + if (connection is JellyfinConnection) { + await _removeUnreferencedJellyfinConnection( + connection, + profileConnections: profileConnections, + connections: connections, + storage: storage, + serverManager: serverManager, + ); + } +} + +Future removeAllProfileConnectionsAndCleanup({ + required String profileId, + required ProfileConnectionRegistry profileConnections, + required ConnectionRegistry connections, + required StorageService storage, + MultiServerManager? serverManager, +}) async { + final rows = await profileConnections.listForProfile(profileId); + if (rows.isEmpty) return; + + final all = await connections.list(); + final byId = {for (final connection in all) connection.id: connection}; + for (final row in rows) { + final connection = byId[row.connectionId]; + if (connection == null) { + await profileConnections.remove(profileId, row.connectionId); + continue; + } + await removeProfileConnectionAndCleanup( + profileId: profileId, + connection: connection, + profileConnections: profileConnections, + connections: connections, + storage: storage, + serverManager: serverManager, + ); + } +} + +Future pruneUnreferencedJellyfinConnections({ + required ProfileConnectionRegistry profileConnections, + required ConnectionRegistry connections, + required StorageService storage, + MultiServerManager? serverManager, +}) async { + final all = await connections.list(); + final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet(); + var removed = 0; + + for (final connection in all.whereType()) { + if (referencedConnectionIds.contains(connection.id)) continue; + await _removeJellyfinConnection( + connection, + profileConnections: profileConnections, + connections: connections, + storage: storage, + serverManager: serverManager, + ); + removed++; + } + + return removed; +} + +Future _removeUnreferencedJellyfinConnection( + JellyfinConnection connection, { + required ProfileConnectionRegistry profileConnections, + required ConnectionRegistry connections, + required StorageService storage, + MultiServerManager? serverManager, +}) async { + if ((await profileConnections.listForConnection(connection.id)).isNotEmpty) return; + await _removeJellyfinConnection( + connection, + profileConnections: profileConnections, + connections: connections, + storage: storage, + serverManager: serverManager, + ); +} + +Future _removeJellyfinConnection( + JellyfinConnection connection, { + required ProfileConnectionRegistry profileConnections, + required ConnectionRegistry connections, + required StorageService storage, + MultiServerManager? serverManager, +}) async { + await connections.remove(connection.id); + serverManager?.removeJellyfinConnection(connection); + final serverId = ServerId.tryParse(connection.serverMachineId); + if (serverId != null && + !await _isServerReferenced(serverId, profileConnections: profileConnections, connections: connections)) { + await storage.clearLibraryPreferencesForServerEverywhere(serverId); + } +} + +Future _clearProfileServerPrefsNoLongerReferenced({ + required String profileId, + required Set removedServerIds, + required ProfileConnectionRegistry profileConnections, + required ConnectionRegistry connections, + required StorageService storage, + required bool clearEverywhereWhenUnreferenced, +}) async { + if (removedServerIds.isEmpty) return; + final remainingProfileServerIds = await _serverIdsForProfile( + profileId, + profileConnections: profileConnections, + connections: connections, + ); + final activeProfileId = storage.getActiveProfileId(); + + for (final serverId in removedServerIds) { + if (remainingProfileServerIds.contains(serverId)) continue; + final serverStillReferenced = await _isServerReferenced( + serverId, + profileConnections: profileConnections, + connections: connections, + ); + if (serverStillReferenced || !clearEverywhereWhenUnreferenced) { + await storage.clearLibraryPreferencesForServer( + serverId, + profileId: profileId, + includeLegacy: activeProfileId == profileId, + ); + } else { + await storage.clearLibraryPreferencesForServerEverywhere(serverId); + } + } +} + +Future> _serverIdsForProfile( + String profileId, { + required ProfileConnectionRegistry profileConnections, + required ConnectionRegistry connections, +}) async { + final rows = await profileConnections.listForProfile(profileId); + if (rows.isEmpty) return const {}; + + final all = await connections.list(); + final byId = {for (final connection in all) connection.id: connection}; + return { + for (final row in rows) + if (byId[row.connectionId] case final connection?) ..._serverIdsForConnection(connection), + }; +} + +Future _isServerReferenced( + ServerId serverId, { + required ProfileConnectionRegistry profileConnections, + required ConnectionRegistry connections, +}) async { + final rows = await profileConnections.listAll(); + if (rows.isEmpty) return false; + + final all = await connections.list(); + final byId = {for (final connection in all) connection.id: connection}; + for (final row in rows) { + final connection = byId[row.connectionId]; + if (connection != null && _serverIdsForConnection(connection).contains(serverId)) return true; + } + return false; +} + +Set _serverIdsForConnection(Connection connection) { + return switch (connection) { + PlexAccountConnection(:final servers) => { + for (final server in servers) + if (ServerId.tryParse(server.clientIdentifier) case final serverId?) serverId, + }, + JellyfinConnection(:final serverMachineId) => { + if (ServerId.tryParse(serverMachineId) case final serverId?) serverId, + }, + }; +} diff --git a/lib/screens/profile/profile_delete_flow.dart b/lib/screens/profile/profile_delete_flow.dart index cbcbf44d..ff40bb98 100644 --- a/lib/screens/profile/profile_delete_flow.dart +++ b/lib/screens/profile/profile_delete_flow.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../connection/connection_registry.dart'; import '../../i18n/strings.g.dart'; import '../../profiles/active_profile_provider.dart'; import '../../profiles/profile.dart'; +import '../../profiles/profile_connection_cleanup.dart'; import '../../profiles/profile_connection_registry.dart'; import '../../profiles/profile_registry.dart'; import '../../providers/download_provider.dart'; +import '../../providers/multi_server_provider.dart'; +import '../../services/storage_service.dart'; import '../../utils/app_logger.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; @@ -35,13 +39,20 @@ Future confirmAndDeleteProfile( Future deleteProfile(BuildContext context, Profile profile) async { final pcRegistry = context.read(); + final connRegistry = context.read(); final profileRegistry = context.read(); final downloadProvider = context.read(); final active = context.read(); final wasActive = active.activeId == profile.id; await downloadProvider.deleteDownloadsForProfile(profile.id); - await pcRegistry.removeAllForProfile(profile.id); + await removeAllProfileConnectionsAndCleanup( + profileId: profile.id, + profileConnections: pcRegistry, + connections: connRegistry, + storage: context.read(), + serverManager: context.read().serverManager, + ); await profileRegistry.remove(profile.id); if (!wasActive) return; diff --git a/lib/screens/profile/profile_detail_screen.dart b/lib/screens/profile/profile_detail_screen.dart index 7a3c22a8..34db2be8 100644 --- a/lib/screens/profile/profile_detail_screen.dart +++ b/lib/screens/profile/profile_detail_screen.dart @@ -13,11 +13,15 @@ import '../../profiles/active_profile_binder.dart'; import '../../profiles/plex_home_service.dart'; import '../../profiles/profile.dart'; import '../../profiles/profile_avatar.dart'; +import '../../profiles/profile_connection_cleanup.dart'; import '../../profiles/profile_connection.dart'; import '../../profiles/profile_connection_registry.dart'; import '../../profiles/profile_registry.dart'; import '../../profiles/profiles_view.dart'; import '../../providers/download_provider.dart'; +import '../../providers/hidden_libraries_provider.dart'; +import '../../providers/multi_server_provider.dart'; +import '../../services/storage_service.dart'; import '../../utils/snackbar_helper.dart'; import '../../focus/focusable_button.dart'; import '../../widgets/app_icon.dart'; @@ -127,7 +131,16 @@ class _ProfileDetailScreenState extends State with Controll _serverIdsForConnection(conn), ); if (!mounted) return; - await context.read().remove(_profile.id, pc.connectionId); + await removeProfileConnectionAndCleanup( + profileId: _profile.id, + connection: conn, + profileConnections: context.read(), + connections: context.read(), + storage: context.read(), + serverManager: context.read().serverManager, + ); + if (!mounted) return; + await Provider.maybeOf(context, listen: false)?.refresh(); if (!mounted) return; unawaited(context.read().rebindIfActive(_profile.id)); } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 52111636..600efa60 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -59,6 +59,8 @@ class StorageService extends BaseSharedPreferencesService { /// full profile id is the scope. String? activeUserScope() => _activeUserScope(); + String userScopeForProfileId(String profileId) => parsePlexHomeProfileId(profileId)?.homeUserUuid ?? profileId; + String? _activeUserScope() { final id = getActiveProfileId(); if (id == null) return null; @@ -72,6 +74,8 @@ class StorageService extends BaseSharedPreferencesService { return scope != null ? 'user_${scope}_' : ''; } + String _userPrefixForProfileId(String profileId) => 'user_${userScopeForProfileId(profileId)}_'; + /// Read a string with user-scoped key, migrating from legacy key if needed. String? _getScopedString(String baseKey) { final scopedKey = '$_userPrefix$baseKey'; @@ -253,6 +257,48 @@ class StorageService extends BaseSharedPreferencesService { ]); } + /// Clear library preferences for [serverId] within [profileId]'s user scope. + /// + /// Library-specific preferences are keyed by `serverId:libraryId`, so when a + /// profile loses access to a server those entries must go too. Otherwise a + /// later re-add of the same physical server revives old hidden/order/filter + /// choices. + Future clearLibraryPreferencesForServer( + ServerId serverId, { + required String profileId, + bool includeLegacy = false, + }) async { + final prefixes = {_userPrefixForProfileId(profileId), if (includeLegacy) ''}; + await Future.wait(prefixes.map((prefix) => _clearLibraryPreferencesForServerPrefix(prefix, serverId))); + } + + /// Clear [serverId] library preferences from every user scope and legacy + /// unscoped storage. Used when no remaining profile has access to the server. + Future clearLibraryPreferencesForServerEverywhere(ServerId serverId) async { + await Future.wait([ + _clearLibraryPreferencesForServerPrefix('', serverId), + _filterServerEntriesFromAllStringListKeys(_keyLibraryOrder, serverId), + _filterServerEntriesFromAllStringListKeys(_keyHiddenLibraries, serverId), + _clearServerSelectedLibraryKeysEverywhere(serverId), + _clearServerPerLibraryKeysEverywhere(_prefixLibrarySort, serverId), + _clearServerPerLibraryKeysEverywhere(_prefixLibraryFilters, serverId), + _clearServerPerLibraryKeysEverywhere(_prefixLibraryGrouping, serverId), + _clearServerPerLibraryKeysEverywhere(_prefixLibraryTab, serverId), + ]); + } + + Future _clearLibraryPreferencesForServerPrefix(String prefix, ServerId serverId) async { + await Future.wait([ + _filterServerEntriesFromStringList('$prefix$_keyLibraryOrder', serverId), + _filterServerEntriesFromStringList('$prefix$_keyHiddenLibraries', serverId), + _clearSelectedLibraryForServer('$prefix$_keySelectedLibraryKey', serverId), + _clearKeysWithPrefixForServer('$prefix$_prefixLibrarySort', serverId), + _clearKeysWithPrefixForServer('$prefix$_prefixLibraryFilters', serverId), + _clearKeysWithPrefixForServer('$prefix$_prefixLibraryGrouping', serverId), + _clearKeysWithPrefixForServer('$prefix$_prefixLibraryTab', serverId), + ]); + } + // Library Order (stored as JSON list of library keys) Future saveLibraryOrder(List libraryKeys) async { await _setStringList('$_userPrefix$_keyLibraryOrder', libraryKeys); @@ -403,10 +449,74 @@ class StorageService extends BaseSharedPreferencesService { /// Remove all keys matching a prefix Future _clearKeysWithPrefix(String prefix) async { - final keys = prefs.keys.where((k) => k.startsWith(prefix)); + final keys = prefs.keys.where((k) => k.startsWith(prefix)).toList(growable: false); await Future.wait(keys.map((k) => prefs.remove(k))); } + bool _belongsToServer(String value, ServerId serverId) => value.startsWith('$serverId:'); + + Future _filterServerEntriesFromStringList(String key, ServerId serverId) async { + final values = _getStringList(key); + if (values == null || values.isEmpty) return; + final filtered = values.where((value) => !_belongsToServer(value, serverId)).toList(growable: false); + if (filtered.length == values.length) return; + if (filtered.isEmpty) { + await prefs.remove(key); + } else { + await _setStringList(key, filtered); + } + } + + Future _filterServerEntriesFromAllStringListKeys(String baseKey, ServerId serverId) async { + final keys = prefs.keys + .where((key) => key == baseKey || (key.startsWith('user_') && key.endsWith('_$baseKey'))) + .toList(growable: false); + await Future.wait(keys.map((key) => _filterServerEntriesFromStringList(key, serverId))); + } + + Future _clearSelectedLibraryForServer(String key, ServerId serverId) async { + final selected = prefs.getString(key); + if (selected != null && _belongsToServer(selected, serverId)) { + await prefs.remove(key); + } + } + + Future _clearServerSelectedLibraryKeysEverywhere(ServerId serverId) async { + final keys = prefs.keys + .where( + (key) => + key == _keySelectedLibraryKey || (key.startsWith('user_') && key.endsWith('_$_keySelectedLibraryKey')), + ) + .toList(growable: false); + await Future.wait(keys.map((key) => _clearSelectedLibraryForServer(key, serverId))); + } + + Future _clearKeysWithPrefixForServer(String keyPrefix, ServerId serverId) async { + final serverPrefix = '$serverId:'; + final keys = prefs.keys + .where((key) => key.startsWith(keyPrefix) && key.substring(keyPrefix.length).startsWith(serverPrefix)) + .toList(growable: false); + await Future.wait(keys.map((key) => prefs.remove(key))); + } + + Future _clearServerPerLibraryKeysEverywhere(String basePrefix, ServerId serverId) async { + final serverPrefix = '$serverId:'; + final scopedMarker = '_$basePrefix'; + final keys = prefs.keys + .where((key) { + if (key.startsWith(basePrefix)) { + return key.substring(basePrefix.length).startsWith(serverPrefix); + } + if (!key.startsWith('user_')) return false; + final markerIndex = key.lastIndexOf(scopedMarker); + if (markerIndex == -1) return false; + final suffix = key.substring(markerIndex + scopedMarker.length); + return suffix.startsWith(serverPrefix); + }) + .toList(growable: false); + await Future.wait(keys.map((key) => prefs.remove(key))); + } + // Public JSON helpers for reducing boilerplate /// Save a JSON-encodable map to storage diff --git a/test/profiles/profile_connection_cleanup_test.dart b/test/profiles/profile_connection_cleanup_test.dart new file mode 100644 index 00000000..429c2277 --- /dev/null +++ b/test/profiles/profile_connection_cleanup_test.dart @@ -0,0 +1,226 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/profiles/profile_connection.dart'; +import 'package:plezy/profiles/profile_connection_cleanup.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/services/plex_auth_service.dart'; +import 'package:plezy/services/storage_service.dart'; + +import '../test_helpers/prefs.dart'; + +JellyfinConnection _jellyfin({String machineId = 'jf-machine', String userId = 'user-a'}) { + return JellyfinConnection( + id: '$machineId/$userId', + baseUrl: 'https://jellyfin.local', + serverName: 'Jellyfin', + serverMachineId: machineId, + userId: userId, + userName: userId, + accessToken: 'token-$userId', + deviceId: 'device-1', + createdAt: DateTime.fromMillisecondsSinceEpoch(1_000_000), + lastAuthenticatedAt: DateTime.fromMillisecondsSinceEpoch(1_000_000), + ); +} + +PlexAccountConnection _plex() { + return PlexAccountConnection( + id: 'plex-account', + accountToken: 'account-token', + clientIdentifier: 'client-1', + accountLabel: 'Plex', + servers: [ + PlexServer( + name: 'Plex Server', + clientIdentifier: 'plex-machine', + accessToken: 'server-token', + connections: [ + PlexConnection( + protocol: 'https', + address: 'plex.example.test', + port: 443, + uri: 'https://plex.example.test', + local: false, + relay: false, + ipv6: false, + ), + ], + owned: true, + ), + ], + createdAt: DateTime.fromMillisecondsSinceEpoch(1_000_000), + lastAuthenticatedAt: DateTime.fromMillisecondsSinceEpoch(1_000_000), + ); +} + +void main() { + late AppDatabase db; + late ConnectionRegistry connections; + late ProfileConnectionRegistry profileConnections; + late StorageService storage; + + setUp(() async { + resetSharedPreferencesForTest(); + db = AppDatabase.forTesting(NativeDatabase.memory()); + connections = ConnectionRegistry(db); + profileConnections = ProfileConnectionRegistry(db); + storage = await StorageService.getInstance(); + }); + + tearDown(() async { + await db.close(); + }); + + group('profile connection cleanup', () { + test('removing the last Jellyfin profile link deletes the connection and profile prefs', () async { + final conn = _jellyfin(); + await connections.upsert(conn); + await profileConnections.upsert( + ProfileConnection( + profileId: 'p1', + connectionId: conn.id, + userToken: conn.accessToken, + userIdentifier: conn.userId, + ), + ); + await storage.setActiveProfileId('p1'); + await storage.saveHiddenLibraries({'jf-machine:movies'}); + await storage.saveLibraryOrder(['jf-machine:movies']); + + await removeProfileConnectionAndCleanup( + profileId: 'p1', + connection: conn, + profileConnections: profileConnections, + connections: connections, + storage: storage, + ); + + expect(await profileConnections.listForConnection(conn.id), isEmpty); + expect(await connections.get(conn.id), isNull); + expect(storage.getHiddenLibraries(), isEmpty); + expect(storage.getLibraryOrder(), isNull); + }); + + test('removing one profile link keeps a shared Jellyfin connection and other profile prefs', () async { + final conn = _jellyfin(); + await connections.upsert(conn); + await profileConnections.upsert( + ProfileConnection( + profileId: 'p1', + connectionId: conn.id, + userToken: conn.accessToken, + userIdentifier: conn.userId, + ), + ); + await profileConnections.upsert( + ProfileConnection( + profileId: 'p2', + connectionId: conn.id, + userToken: conn.accessToken, + userIdentifier: conn.userId, + ), + ); + + await storage.setActiveProfileId('p1'); + await storage.saveHiddenLibraries({'jf-machine:movies'}); + await storage.setActiveProfileId('p2'); + await storage.saveHiddenLibraries({'jf-machine:movies'}); + + await removeProfileConnectionAndCleanup( + profileId: 'p1', + connection: conn, + profileConnections: profileConnections, + connections: connections, + storage: storage, + ); + + expect(await connections.get(conn.id), isNotNull); + final remaining = await profileConnections.listForConnection(conn.id); + expect(remaining, hasLength(1)); + expect(remaining.single.profileId, 'p2'); + + await storage.setActiveProfileId('p1'); + expect(storage.getHiddenLibraries(), isEmpty); + await storage.setActiveProfileId('p2'); + expect(storage.getHiddenLibraries(), {'jf-machine:movies'}); + }); + + test('startup prune removes unreferenced Jellyfin rows and stale prefs', () async { + final conn = _jellyfin(); + await connections.upsert(conn); + await storage.setActiveProfileId('p1'); + await storage.saveHiddenLibraries({'jf-machine:movies'}); + await storage.saveLibrarySort('jf-machine:movies', 'titleSort'); + + final removed = await pruneUnreferencedJellyfinConnections( + profileConnections: profileConnections, + connections: connections, + storage: storage, + ); + + expect(removed, 1); + expect(await connections.get(conn.id), isNull); + expect(storage.getHiddenLibraries(), isEmpty); + expect(storage.getLibrarySort('jf-machine:movies'), isNull); + }); + + test('startup prune does not clear prefs when another user on the same server is still referenced', () async { + final orphan = _jellyfin(userId: 'user-a'); + final sharedServer = _jellyfin(userId: 'user-b'); + await connections.upsert(orphan); + await connections.upsert(sharedServer); + await profileConnections.upsert( + ProfileConnection( + profileId: 'p2', + connectionId: sharedServer.id, + userToken: sharedServer.accessToken, + userIdentifier: sharedServer.userId, + ), + ); + await storage.setActiveProfileId('p2'); + await storage.saveHiddenLibraries({'jf-machine:movies'}); + + final removed = await pruneUnreferencedJellyfinConnections( + profileConnections: profileConnections, + connections: connections, + storage: storage, + ); + + expect(removed, 1); + expect(await connections.get(orphan.id), isNull); + expect(await connections.get(sharedServer.id), isNotNull); + expect(storage.getHiddenLibraries(), {'jf-machine:movies'}); + }); + + test('Plex profile unlink clears only that profile because Plex Home access can be implicit', () async { + final conn = _plex(); + await connections.upsert(conn); + await profileConnections.upsert( + ProfileConnection(profileId: 'p1', connectionId: conn.id, userToken: 'user-token', userIdentifier: 'home-user'), + ); + + await storage.setActiveProfileId('p1'); + await storage.saveHiddenLibraries({'plex-machine:movies'}); + await storage.setActiveProfileId('p2'); + await storage.saveHiddenLibraries({'plex-machine:movies'}); + + await removeProfileConnectionAndCleanup( + profileId: 'p1', + connection: conn, + profileConnections: profileConnections, + connections: connections, + storage: storage, + ); + + expect(await connections.get(conn.id), isNotNull); + expect(await profileConnections.listForConnection(conn.id), isEmpty); + await storage.setActiveProfileId('p1'); + expect(storage.getHiddenLibraries(), isEmpty); + await storage.setActiveProfileId('p2'); + expect(storage.getHiddenLibraries(), {'plex-machine:movies'}); + }); + }); +} diff --git a/test/services/storage_service_test.dart b/test/services/storage_service_test.dart index 1fcb4670..6d58863b 100644 --- a/test/services/storage_service_test.dart +++ b/test/services/storage_service_test.dart @@ -461,6 +461,84 @@ void main() { expect(s.prefs.getString('library_order'), isNull); expect(s.prefs.getString('library_filters_sec-1'), isNull); }); + + test('clearLibraryPreferencesForServer clears only the target profile server keys', () async { + final s = await StorageService.getInstance(); + final serverA = ServerId('srv-a'); + final serverB = ServerId('srv-b'); + + await s.setActiveProfileId('local-user-1'); + await s.saveLibraryOrder(['srv-a:movies', 'srv-b:shows']); + await s.saveSelectedLibraryKey('srv-a:movies'); + await s.saveHiddenLibraries({'srv-a:movies', 'srv-b:shows'}); + await s.saveLibraryFilters({'genre': 'sci-fi'}, sectionId: 'srv-a:movies'); + await s.saveLibraryFilters({'genre': 'drama'}, sectionId: 'srv-b:shows'); + await s.saveLibrarySort('srv-a:movies', 'titleSort'); + await s.saveLibraryGrouping('srv-a:movies', 'movies'); + await s.saveLibraryTab('srv-a:movies', 'recommended'); + + await s.setActiveProfileId('local-user-2'); + await s.saveLibraryOrder(['srv-a:movies']); + await s.saveHiddenLibraries({'srv-a:movies'}); + + await s.clearLibraryPreferencesForServer(serverA, profileId: 'local-user-1'); + + await s.setActiveProfileId('local-user-1'); + expect(s.getLibraryOrder(), ['srv-b:shows']); + expect(s.getSelectedLibraryKey(), isNull); + expect(s.getHiddenLibraries(), {'srv-b:shows'}); + expect(s.getLibraryFilters(sectionId: 'srv-a:movies'), isEmpty); + expect(s.getLibraryFilters(sectionId: 'srv-b:shows'), {'genre': 'drama'}); + expect(s.getLibrarySort('srv-a:movies'), isNull); + expect(s.getLibraryGrouping('srv-a:movies'), isNull); + expect(s.getLibraryTab('srv-a:movies'), isNull); + + await s.setActiveProfileId('local-user-2'); + expect(s.getLibraryOrder(), ['srv-a:movies']); + expect(s.getHiddenLibraries(), {'srv-a:movies'}); + + await s.clearLibraryPreferencesForServer(serverB, profileId: 'local-user-1'); + await s.setActiveProfileId('local-user-1'); + expect(s.getLibraryOrder(), isNull); + expect(s.getHiddenLibraries(), isEmpty); + }); + + test('clearLibraryPreferencesForServerEverywhere clears server keys from all scopes', () async { + final s = await StorageService.getInstance(); + final serverA = ServerId('srv-a'); + + await s.prefs.setString('library_order', json.encode(['srv-a:legacy', 'srv-b:legacy'])); + await s.prefs.setString('hidden_libraries', json.encode(['srv-a:legacy', 'srv-b:legacy'])); + await s.prefs.setString('selected_library_key', 'srv-a:legacy'); + await s.prefs.setString('library_sort_srv-a:legacy', json.encode({'key': 'titleSort', 'descending': false})); + await s.prefs.setString('library_grouping_srv-a:legacy', 'movies'); + + await s.setActiveProfileId('local-user-1'); + await s.saveLibraryOrder(['srv-a:movies', 'srv-b:shows']); + await s.saveHiddenLibraries({'srv-a:movies', 'srv-b:shows'}); + await s.saveLibrarySort('srv-a:movies', 'titleSort'); + + await s.setActiveProfileId('local-user-2'); + await s.saveLibraryOrder(['srv-a:movies']); + await s.saveHiddenLibraries({'srv-a:movies'}); + + await s.clearLibraryPreferencesForServerEverywhere(serverA); + + expect(s.prefs.getString('library_order'), json.encode(['srv-b:legacy'])); + expect(s.prefs.getString('hidden_libraries'), json.encode(['srv-b:legacy'])); + expect(s.prefs.getString('selected_library_key'), isNull); + expect(s.prefs.getString('library_sort_srv-a:legacy'), isNull); + expect(s.prefs.getString('library_grouping_srv-a:legacy'), isNull); + + await s.setActiveProfileId('local-user-1'); + expect(s.getLibraryOrder(), ['srv-b:shows']); + expect(s.getHiddenLibraries(), {'srv-b:shows'}); + expect(s.getLibrarySort('srv-a:movies'), isNull); + + await s.setActiveProfileId('local-user-2'); + expect(s.getLibraryOrder(), ['srv-b:legacy']); + expect(s.getHiddenLibraries(), {'srv-b:legacy'}); + }); }); // ============================================================