import 'dart:convert'; import '../media/ids.dart'; import 'package:uuid/uuid.dart'; import '../profiles/profile.dart'; import '../utils/log_redaction_manager.dart'; import 'base_shared_preferences_service.dart'; class StorageService extends BaseSharedPreferencesService { static const String _keyPlexToken = 'plex_token'; static const String _keyClientId = 'client_identifier'; static const String _keySelectedLibraryKey = 'selected_library_key'; static const String _keyLibraryFilters = 'library_filters'; static const String _keyLibraryOrder = 'library_order'; static const String _keyCurrentUserUUID = 'current_user_uuid'; static const String _keyHiddenLibraries = 'hidden_libraries'; static const String _keyServersList = 'servers_list'; static const String _keyServerOrder = 'server_order'; static const String _keyActiveProfileId = 'active_app_profile_id'; // Key prefixes for per-id storage static const String _prefixServerEndpoint = 'server_endpoint_'; static const String _prefixLibraryFilters = 'library_filters_'; static const String _prefixLibrarySort = 'library_sort_'; static const String _prefixLibraryGrouping = 'library_grouping_'; static const String _prefixLibraryTab = 'library_tab_'; static const String _prefixPlexHomeUsers = 'plex_home_users_'; static const String _prefixProfileLastUsed = 'profile_last_used_'; // Key groups for bulk clearing static const List _credentialKeys = [_keyPlexToken, _keyClientId, _keyCurrentUserUUID]; static const List _libraryPreferenceKeys = [_keyLibraryFilters, _keyLibraryOrder, _keyHiddenLibraries]; StorageService._(); static Future getInstance() { return BaseSharedPreferencesService.initializeInstance(() => StorageService._()); } @override Future onInit() async { // Seed known values so logs can redact immediately on startup. Reading // the legacy plex_token slot here is acceptable: it's a one-shot // redaction-priming read for any tokens lingering from before the // migration ran (after migration the slot is empty so this is a no-op). // ignore: deprecated_member_use_from_same_package LogRedactionManager.registerToken(getPlexToken()); await _migratePlexHomeUserScopes(); } /// One-time repair for prefs scoped by a full `plex-home-…` profile id. /// [parsePlexHomeProfileId] historically rejected real 16-hex home-user /// uuids, so `_userPrefix` fell back to the full profile id and wrote /// `user_plex-home-{acct}-{uuid}_*` keys instead of the intended /// `user_{uuid}_*`. Move them onto the uuid scope. On conflict the /// full-id value wins: it is the more recently written one (uuid-scoped /// keys can only predate the profiles migration). Future _migratePlexHomeUserScopes() async { const scopePrefix = 'user_plex-home-'; final keys = prefs.keys.where((k) => k.startsWith(scopePrefix)).toList(growable: false); for (final key in keys) { final withoutUserPrefix = key.substring('user_'.length); // Profile ids contain no underscores, so the first `_` ends the scope. final sep = withoutUserPrefix.indexOf('_'); if (sep <= 0) continue; final parsed = parsePlexHomeProfileId(withoutUserPrefix.substring(0, sep)); if (parsed == null) continue; final newKey = 'user_${parsed.homeUserUuid}_${withoutUserPrefix.substring(sep + 1)}'; switch (prefs.get(key)) { case final List v: await prefs.setStringList(newKey, v.cast()); case final String v: await prefs.setString(newKey, v); case final bool v: await prefs.setBool(newKey, v); case final int v: await prefs.setInt(newKey, v); case final double v: await prefs.setDouble(newKey, v); default: continue; // Unknown shape — leave the old key untouched. } await prefs.remove(key); } } // User-scoped storage for per-profile library settings /// Returns the scope identifier for the active profile, or `null` if no /// profile is active. /// /// For Plex Home profiles (id format `plex-home-{connId}-{homeUserUuid}`) /// the scope is the home-user UUID — keeps per-user library prefs working /// the same way the legacy `currentUserUUID` did. For local profiles, the /// full profile id is the scope. String? activeUserScope() => _activeUserScope(); String userScopeForProfileId(String profileId) => profileUserScope(profileId); String? _activeUserScope() { final id = getActiveProfileId(); if (id == null) return null; return profileUserScope(id); } /// Returns `'user_{scope}_'` for the active profile, or `''` if no /// profile is active. String get _userPrefix { final scope = _activeUserScope(); return scope != null ? 'user_${scope}_' : ''; } String _userPrefixForProfileId(String profileId) => 'user_${userScopeForProfileId(profileId)}_'; /// Read [baseKey] from the [prefix]-scoped slot, adopting the legacy /// unscoped value once: the scoped key wins; otherwise the unscoped value is /// copied into it and the unscoped key removed. [read]/[write] carry the /// per-type codec. Adoption is skipped when [prefix] is empty (no scope to /// migrate into) or [allowLegacyAdoption] is false (a scope other than the /// active profile's, which must not steal legacy prefs). T? _readScopedWithLegacyMigration( String baseKey, { required String prefix, required T? Function(String key) read, required void Function(String key, T value) write, bool allowLegacyAdoption = true, }) { final scopedKey = '$prefix$baseKey'; final value = read(scopedKey); if (value != null || prefix.isEmpty || !allowLegacyAdoption) return value; final legacy = read(baseKey); if (legacy != null) { write(scopedKey, legacy); prefs.remove(baseKey); } return legacy; } /// Read a string with user-scoped key, migrating from legacy key if needed. String? _getScopedString(String baseKey) => _readScopedWithLegacyMigration( baseKey, prefix: _userPrefix, read: readNullableString, write: prefs.setString, ); // Per-Server Endpoint URL (for multi-server connection caching) Future saveServerEndpoint(ServerId serverId, String url) async { await prefs.setString('$_prefixServerEndpoint$serverId', url); LogRedactionManager.registerServerUrl(url); } String? getServerEndpoint(ServerId serverId) { return readNullableString('$_prefixServerEndpoint$serverId'); } Future clearServerEndpoint(ServerId serverId) async { await prefs.remove('$_prefixServerEndpoint$serverId'); } // Plex.tv Token — read once by [ConnectionBootstrap.migrateLegacyPlexAccount] // on the upgrade run. The new pipeline stores Plex account tokens on // [PlexAccountConnection.accountToken] in [ConnectionRegistry]. @Deprecated( 'Read PlexAccountConnection.accountToken from ConnectionRegistry instead. ' 'Only ConnectionBootstrap.migrateLegacyPlexAccount may use this.', ) String? getPlexToken() { return readNullableString(_keyPlexToken); } /// Drop the legacy `plex_token` slot. Called by /// [ConnectionBootstrap.migrateLegacyPlexAccount] after the token has /// been moved into a [PlexAccountConnection] row, so a later sign-out /// doesn't get resurrected on next launch (the migration would /// otherwise see the orphaned token and re-create the connection). Future clearLegacyPlexToken() async { await prefs.remove(_keyPlexToken); } /// Return the persisted device identifier, generating and saving a UUID on /// first call. Used by Plex's `X-Plex-Client-Identifier` header so plex.tv /// sees the same device across launches; not Plex-specific in itself — /// Jellyfin's `DeviceId` header reuses the same value too. Future getOrCreateClientIdentifier() async { final existing = readNullableString(_keyClientId); if (existing != null && existing.isNotEmpty) return existing; final generated = const Uuid().v4(); await prefs.setString(_keyClientId, generated); return generated; } // Clear all credentials Future clearCredentials() async { await Future.wait([..._credentialKeys.map((k) => prefs.remove(k)), clearMultiServerData()]); LogRedactionManager.clearTrackedValues(); } // Selected Library Key (replaces index-based selection) Future saveSelectedLibraryKey(String key) async { await prefs.setString('$_userPrefix$_keySelectedLibraryKey', key); } String? getSelectedLibraryKey() { return _getScopedString(_keySelectedLibraryKey); } // Library Filters (stored as JSON string) Future saveLibraryFilters(Map filters, {String? sectionId}) async { final baseKey = sectionId != null ? '$_prefixLibraryFilters$sectionId' : _keyLibraryFilters; // Note: using Map which json.encode handles correctly final jsonString = json.encode(filters); await prefs.setString('$_userPrefix$baseKey', jsonString); } Map getLibraryFilters({String? sectionId}) { final baseKey = sectionId != null ? '$_prefixLibraryFilters$sectionId' : _keyLibraryFilters; // Prefer per-library filters when available var jsonString = _getScopedString(baseKey); if (jsonString == null && sectionId != null) { // Legacy support: fall back to global filters if present jsonString = _getScopedString(_keyLibraryFilters); } if (jsonString == null) return {}; final decoded = decodeJsonStringToMap(jsonString); return decoded.map((key, value) => MapEntry(key, value.toString())); } // Library Sort (per-library, stored individually with descending flag) Future saveLibrarySort(String sectionId, String sortKey, {bool descending = false}) async { final sortData = {'key': sortKey, 'descending': descending}; await _setJsonMap('$_userPrefix$_prefixLibrarySort$sectionId', sortData); } Map? getLibrarySort(String sectionId) => _readScopedWithLegacyMigration>( '$_prefixLibrarySort$sectionId', prefix: _userPrefix, read: (key) => _readJsonMap(key, legacyStringOk: true), write: _setJsonMap, ); // Library Grouping (per-library, e.g., 'movies', 'shows', 'seasons', 'episodes') Future saveLibraryGrouping(String sectionId, String grouping) async { await prefs.setString('$_userPrefix$_prefixLibraryGrouping$sectionId', grouping); } String? getLibraryGrouping(String sectionId) { return _getScopedString('$_prefixLibraryGrouping$sectionId'); } // Library Tab (per-library, saves last selected tab name) Future saveLibraryTab(String sectionId, String tabName) async { await prefs.setString('$_userPrefix$_prefixLibraryTab$sectionId', tabName); } String? getLibraryTab(String sectionId) { // Older builds stored this as an int; `readNullableString` drops a value it // cannot read and falls back to null, which is the correct behaviour here. return readNullableString('$_userPrefix$_prefixLibraryTab$sectionId'); } // Hidden Libraries (stored as JSON array of library section IDs) Future saveHiddenLibraries(Set libraryKeys) async { await _setStringList('$_userPrefix$_keyHiddenLibraries', libraryKeys.toList()); } Future saveHiddenLibrariesForProfile(String profileId, Set libraryKeys) async { await _setStringList('${_userPrefixForProfileId(profileId)}$_keyHiddenLibraries', libraryKeys.toList()); } Set getHiddenLibraries() { final jsonString = _getScopedString(_keyHiddenLibraries); return _decodeStringSet(jsonString); } Set getHiddenLibrariesForProfile(String profileId) => _decodeStringSet( _readScopedWithLegacyMigration( _keyHiddenLibraries, prefix: _userPrefixForProfileId(profileId), read: readNullableString, write: prefs.setString, // Only the active profile may adopt the legacy unscoped value. Otherwise // merely opening another profile's scoped provider could steal legacy // preferences into the wrong scope. allowLegacyAdoption: getActiveProfileId() == profileId, ), ); Set _decodeStringSet(String? jsonString) { if (jsonString == null) return {}; try { final list = json.decode(jsonString) as List; return list.map((e) => e.toString()).toSet(); } catch (e) { return {}; } } // Clear library preferences (scoped to current user) Future clearLibraryPreferences() async { final prefix = _userPrefix; await Future.wait([ ..._libraryPreferenceKeys.map((k) => prefs.remove('$prefix$k')), prefs.remove('$prefix$_keySelectedLibraryKey'), _clearKeysWithPrefix('$prefix$_prefixLibrarySort'), _clearKeysWithPrefix('$prefix$_prefixLibraryFilters'), _clearKeysWithPrefix('$prefix$_prefixLibraryGrouping'), _clearKeysWithPrefix('$prefix$_prefixLibraryTab'), if (prefix.isNotEmpty) ...[ ..._libraryPreferenceKeys.map(prefs.remove), prefs.remove(_keySelectedLibraryKey), _clearKeysWithPrefix(_prefixLibrarySort), _clearKeysWithPrefix(_prefixLibraryFilters), _clearKeysWithPrefix(_prefixLibraryGrouping), _clearKeysWithPrefix(_prefixLibraryTab), ], ]); } /// 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); } List? getLibraryOrder() => _readScopedWithLegacyMigration>( _keyLibraryOrder, prefix: _userPrefix, read: _getStringList, write: _setStringList, ); // Current User UUID — read once by [ConnectionBootstrap._promoteActiveProfileFromLegacy] // on the upgrade run, then cleared. Replaced by // [getActiveProfileId] / [setActiveProfileId]. @Deprecated( 'Use setActiveProfileId / getActiveProfileId. ' 'Only ConnectionBootstrap._promoteActiveProfileFromLegacy may read this.', ) String? getCurrentUserUUID() { return readNullableString(_keyCurrentUserUUID); } /// Clears the legacy `currentUserUUID` slot. Used by the upgrade migration. Future clearCurrentUserUUID() async { await prefs.remove(_keyCurrentUserUUID); } // Clear all user-related data (for logout) Future clearUserData() async { await Future.wait([clearCredentials(), clearLibraryPreferences()]); } // Multi-Server Support Methods // // Servers now live on [PlexAccountConnection.servers] in // [ConnectionRegistry]. The legacy `servers_list` JSON slot is read once // by [ConnectionBootstrap.migrateLegacyPlexAccount] and then dropped. /// Get legacy servers list as JSON string. Use [ConnectionRegistry] for /// fresh data; this exists only for the boot-time migration. @Deprecated( 'Read PlexAccountConnection.servers from ConnectionRegistry. ' 'Only ConnectionBootstrap.migrateLegacyPlexAccount may use this.', ) String? getServersListJson() { return readNullableString(_keyServersList); } /// Clear the legacy servers list. Future clearServersList() async { await prefs.remove(_keyServersList); } /// Clear all multi-server data Future clearMultiServerData() async { await Future.wait([clearServersList(), clearServerOrder(), _clearKeysWithPrefix(_prefixServerEndpoint)]); } /// Clear legacy server order. Future clearServerOrder() async { await prefs.remove(_keyServerOrder); } // Active app-level profile (kids mode / multi-user gating) String? getActiveProfileId() => readNullableString(_keyActiveProfileId); Future setActiveProfileId(String id) async { await prefs.setString(_keyActiveProfileId, id); } Future clearActiveProfileId() async { await prefs.remove(_keyActiveProfileId); } // Per-connection Plex Home users cache. Plex Home profiles are not // persisted as Profile rows — they're fetched live by [PlexHomeService] // and cached here so the picker can paint immediately on cold start. // Stored as a JSON list of [PlexHomeUser] payloads, no TTL — the service // refreshes in the background via stale-while-revalidate. Future savePlexHomeUsersCache(String connectionId, List> users) async { await prefs.setString('$_prefixPlexHomeUsers$connectionId', json.encode(users)); } String? getPlexHomeUsersCacheJson(String connectionId) { return readNullableString('$_prefixPlexHomeUsers$connectionId'); } Future clearPlexHomeUsersCache(String connectionId) async { await prefs.remove('$_prefixPlexHomeUsers$connectionId'); } Future clearAllPlexHomeUsersCache() async { await _clearKeysWithPrefix(_prefixPlexHomeUsers); } // `lastUsedAt` for ordering and future filtering of profiles by recency // (currently surfaced via `Profile.lastUsedAt`). Stored separately so it // works for both DB-backed local profiles and virtual Plex Home profiles // (which don't have a Profile row to update). Future markProfileUsed(String profileId, DateTime at) async { await prefs.setInt('$_prefixProfileLastUsed$profileId', at.millisecondsSinceEpoch); } DateTime? getProfileLastUsed(String profileId) { final ms = readNullableInt('$_prefixProfileLastUsed$profileId'); return ms == null ? null : DateTime.fromMillisecondsSinceEpoch(ms); } Future clearAllProfileLastUsed() async { await _clearKeysWithPrefix(_prefixProfileLastUsed); } Future clearProfileLastUsed(String profileId) async { await prefs.remove('$_prefixProfileLastUsed$profileId'); } /// Remove every user-scoped pref under [profileId]'s scope. For Plex Home /// profiles the scope is the home-user uuid, which is shared by any borrow /// of the same home user — only call when that user's access is being torn /// down entirely (profile delete / account sign-out). Future clearUserScopedPreferencesForProfile(String profileId) async { await _clearKeysWithPrefix(_userPrefixForProfileId(profileId)); } /// Remove user-scoped prefs for every scope (full logout). Future clearAllUserScopedPreferences() async { await _clearKeysWithPrefix('user_'); } // Private helper methods /// Helper to read and decode JSON `List` from preferences List? _getStringList(String key) { final jsonString = readNullableString(key); if (jsonString == null) return null; try { final decoded = json.decode(jsonString) as List; return decoded.map((e) => e.toString()).toList(); } catch (e) { return null; } } /// Helper to read and decode JSON Map from preferences /// /// [key] - The preference key to read /// [legacyStringOk] - If true, returns {'key': value, 'descending': false} /// when value is a plain string (for legacy library sort) Map? _readJsonMap(String key, {bool legacyStringOk = false}) { final jsonString = readNullableString(key); if (jsonString == null) return null; return decodeJsonStringToMap(jsonString, legacyStringOk: legacyStringOk); } /// Remove all keys matching a prefix Future _clearKeysWithPrefix(String prefix) async { 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); } } /// Run [op] over every slot holding [baseKey]: the legacy unscoped key plus /// each `user_{scope}_{baseKey}` variant. Future _forEachScopedKey(String baseKey, Future Function(String key) op) async { final keys = prefs.keys .where((key) => key == baseKey || (key.startsWith('user_') && key.endsWith('_$baseKey'))) .toList(growable: false); await Future.wait(keys.map(op)); } Future _filterServerEntriesFromAllStringListKeys(String baseKey, ServerId serverId) => _forEachScopedKey(baseKey, (key) => _filterServerEntriesFromStringList(key, serverId)); Future _clearSelectedLibraryForServer(String key, ServerId serverId) async { final selected = readNullableString(key); if (selected != null && _belongsToServer(selected, serverId)) { await prefs.remove(key); } } Future _clearServerSelectedLibraryKeysEverywhere(ServerId serverId) => _forEachScopedKey(_keySelectedLibraryKey, (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 Future _setJsonMap(String key, Map data) async { final jsonString = json.encode(data); await prefs.setString(key, jsonString); } /// Save a string list as JSON array Future _setStringList(String key, List list) async { final jsonString = json.encode(list); await prefs.setString(key, jsonString); } }