fix(profiles): forget removed Jellyfin connections

close #1355
This commit is contained in:
edde746
2026-06-16 20:08:47 +02:00
parent d49bd20fa0
commit 4cd289b4b8
7 changed files with 657 additions and 3 deletions
+13
View File
@@ -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<SetupScreen> with MountedSetStateMixin {
if (mounted) {
try {
final connRegistry = context.read<ConnectionRegistry>();
final profileConnections = context.read<ProfileConnectionRegistry>();
final profileRegistry = context.read<ProfileRegistry>();
final activeProfiles = context.read<ActiveProfileProvider>();
final bootstrap = ConnectionBootstrap(
@@ -1134,6 +1138,15 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
profileRegistry: profileRegistry,
);
await bootstrap.run();
final pruned = await pruneUnreferencedJellyfinConnections(
profileConnections: profileConnections,
connections: connRegistry,
storage: storage,
serverManager: context.read<MultiServerProvider>().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
@@ -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<void> 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<void> 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<int> 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<JellyfinConnection>()) {
if (referencedConnectionIds.contains(connection.id)) continue;
await _removeJellyfinConnection(
connection,
profileConnections: profileConnections,
connections: connections,
storage: storage,
serverManager: serverManager,
);
removed++;
}
return removed;
}
Future<void> _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<void> _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<void> _clearProfileServerPrefsNoLongerReferenced({
required String profileId,
required Set<ServerId> 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<Set<ServerId>> _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<bool> _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<ServerId> _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,
},
};
}
+12 -1
View File
@@ -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<bool> confirmAndDeleteProfile(
Future<void> deleteProfile(BuildContext context, Profile profile) async {
final pcRegistry = context.read<ProfileConnectionRegistry>();
final connRegistry = context.read<ConnectionRegistry>();
final profileRegistry = context.read<ProfileRegistry>();
final downloadProvider = context.read<DownloadProvider>();
final active = context.read<ActiveProfileProvider>();
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<StorageService>(),
serverManager: context.read<MultiServerProvider>().serverManager,
);
await profileRegistry.remove(profile.id);
if (!wasActive) return;
+14 -1
View File
@@ -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<ProfileDetailScreen> with Controll
_serverIdsForConnection(conn),
);
if (!mounted) return;
await context.read<ProfileConnectionRegistry>().remove(_profile.id, pc.connectionId);
await removeProfileConnectionAndCleanup(
profileId: _profile.id,
connection: conn,
profileConnections: context.read<ProfileConnectionRegistry>(),
connections: context.read<ConnectionRegistry>(),
storage: context.read<StorageService>(),
serverManager: context.read<MultiServerProvider>().serverManager,
);
if (!mounted) return;
await Provider.maybeOf<HiddenLibrariesProvider>(context, listen: false)?.refresh();
if (!mounted) return;
unawaited(context.read<ActiveProfileBinder>().rebindIfActive(_profile.id));
}
+111 -1
View File
@@ -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<void> clearLibraryPreferencesForServer(
ServerId serverId, {
required String profileId,
bool includeLegacy = false,
}) async {
final prefixes = <String>{_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<void> 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<void> _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<void> saveLibraryOrder(List<String> libraryKeys) async {
await _setStringList('$_userPrefix$_keyLibraryOrder', libraryKeys);
@@ -403,10 +449,74 @@ class StorageService extends BaseSharedPreferencesService {
/// Remove all keys matching a prefix
Future<void> _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<void> _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<void> _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<void> _clearSelectedLibraryForServer(String key, ServerId serverId) async {
final selected = prefs.getString(key);
if (selected != null && _belongsToServer(selected, serverId)) {
await prefs.remove(key);
}
}
Future<void> _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<void> _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<void> _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
@@ -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'});
});
});
}
+78
View File
@@ -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'});
});
});
// ============================================================