import 'dart:convert'; import 'package:uuid/uuid.dart'; import '../utils/log_redaction_manager.dart'; import 'base_shared_preferences_service.dart'; class StorageService extends BaseSharedPreferencesService { static const String _keyServerUrl = 'server_url'; static const String _keyToken = 'token'; static const String _keyPlexToken = 'plex_token'; static const String _keyServerData = 'server_data'; static const String _keyClientId = 'client_identifier'; static const String _keySelectedLibraryIndex = 'selected_library_index'; static const String _keySelectedLibraryKey = 'selected_library_key'; static const String _keyLibraryFilters = 'library_filters'; static const String _keyLibraryOrder = 'library_order'; static const String _keyUserProfile = 'user_profile'; static const String _keyCurrentUserUUID = 'current_user_uuid'; static const String _keyHomeUsersCache = 'home_users_cache'; static const String _keyHomeUsersCacheExpiry = 'home_users_cache_expiry'; static const String _keyHiddenLibraries = 'hidden_libraries'; static const String _keyServersList = 'servers_list'; static const String _keyServerOrder = 'server_order'; // 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_'; // Key groups for bulk clearing static const List _credentialKeys = [ _keyServerUrl, _keyToken, _keyPlexToken, _keyServerData, _keyClientId, _keyUserProfile, _keyCurrentUserUUID, _keyHomeUsersCache, _keyHomeUsersCacheExpiry, ]; static const List _libraryPreferenceKeys = [ _keySelectedLibraryIndex, _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. LogRedactionManager.registerServerUrl(prefs.getString(_keyServerUrl)); LogRedactionManager.registerToken(prefs.getString(_keyToken)); LogRedactionManager.registerToken(getPlexToken()); } // User-scoped storage for per-profile library settings /// Returns `'user_{uuid}_'` for the current user, or `''` if no user is set. String get _userPrefix { final uuid = getCurrentUserUUID(); return uuid != null ? 'user_${uuid}_' : ''; } /// Read a string with user-scoped key, migrating from legacy key if needed. String? _getScopedString(String baseKey) { final scopedKey = '$_userPrefix$baseKey'; final value = prefs.getString(scopedKey); if (value != null || _userPrefix.isEmpty) return value; // One-time migration from legacy global key final legacy = prefs.getString(baseKey); if (legacy != null) prefs.setString(scopedKey, legacy); return legacy; } /// Read an int with user-scoped key, migrating from legacy key if needed. int? _getScopedInt(String baseKey) { final scopedKey = '$_userPrefix$baseKey'; final value = prefs.getInt(scopedKey); if (value != null || _userPrefix.isEmpty) return value; final legacy = prefs.getInt(baseKey); if (legacy != null) prefs.setInt(scopedKey, legacy); return legacy; } // Per-Server Endpoint URL (for multi-server connection caching) Future saveServerEndpoint(String serverId, String url) async { await prefs.setString('$_prefixServerEndpoint$serverId', url); LogRedactionManager.registerServerUrl(url); } String? getServerEndpoint(String serverId) { return prefs.getString('$_prefixServerEndpoint$serverId'); } Future clearServerEndpoint(String serverId) async { await prefs.remove('$_prefixServerEndpoint$serverId'); } // Plex.tv Token (for API access) Future savePlexToken(String token) async { await prefs.setString(_keyPlexToken, token); LogRedactionManager.registerToken(token); } String? getPlexToken() { return prefs.getString(_keyPlexToken); } // Client Identifier Future saveClientIdentifier(String clientId) async { await prefs.setString(_keyClientId, clientId); } String? getClientIdentifier() { return prefs.getString(_keyClientId); } /// Return the persisted client identifier, generating and saving a UUID on /// first call. Ensures Plex sees the same device across reconnects. Future getOrCreateClientIdentifier() async { final existing = getClientIdentifier(); if (existing != null && existing.isNotEmpty) return existing; final generated = const Uuid().v4(); await saveClientIdentifier(generated); return generated; } // Clear all credentials Future clearCredentials() async { await Future.wait([..._credentialKeys.map((k) => prefs.remove(k)), clearMultiServerData()]); LogRedactionManager.clearTrackedValues(); } int? getSelectedLibraryIndex() { return _getScopedInt(_keySelectedLibraryIndex); } // 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) { final baseKey = '$_prefixLibrarySort$sectionId'; final scopedKey = '$_userPrefix$baseKey'; var result = _readJsonMap(scopedKey, legacyStringOk: true); if (result != null || _userPrefix.isEmpty) return result; // One-time migration from legacy key result = _readJsonMap(baseKey, legacyStringOk: true); if (result != null) _setJsonMap(scopedKey, result); return result; } // 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) { final key = '$_userPrefix$_prefixLibraryTab$sectionId'; // Handle migration from old int storage: try string first, fall back to removing stale int try { return prefs.getString(key); } catch (_) { prefs.remove(key); return null; } } // Hidden Libraries (stored as JSON array of library section IDs) Future saveHiddenLibraries(Set libraryKeys) async { await _setStringList('$_userPrefix$_keyHiddenLibraries', libraryKeys.toList()); } Set getHiddenLibraries() { final jsonString = _getScopedString(_keyHiddenLibraries); 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'), ]); } // Library Order (stored as JSON list of library keys) Future saveLibraryOrder(List libraryKeys) async { await _setStringList('$_userPrefix$_keyLibraryOrder', libraryKeys); } List? getLibraryOrder() { final baseKey = _keyLibraryOrder; final scopedKey = '$_userPrefix$baseKey'; final value = _getStringList(scopedKey); if (value != null || _userPrefix.isEmpty) return value; // One-time migration from legacy key final legacy = _getStringList(baseKey); if (legacy != null) _setStringList(scopedKey, legacy); return legacy; } // User Profile (stored as JSON string) Future saveUserProfile(Map profileJson) async { await _setJsonMap(_keyUserProfile, profileJson); } Map? getUserProfile() { return _readJsonMap(_keyUserProfile); } // Current User UUID Future saveCurrentUserUUID(String uuid) async { await prefs.setString(_keyCurrentUserUUID, uuid); } String? getCurrentUserUUID() { return prefs.getString(_keyCurrentUserUUID); } // Home Users Cache (stored as JSON string with expiry) Future saveHomeUsersCache(Map homeData) async { await _setJsonMap(_keyHomeUsersCache, homeData); // Set cache expiry to 1 hour from now final expiry = DateTime.now().add(const Duration(hours: 1)).millisecondsSinceEpoch; await prefs.setInt(_keyHomeUsersCacheExpiry, expiry); } Map? getHomeUsersCache() { final expiry = prefs.getInt(_keyHomeUsersCacheExpiry); if (expiry == null || DateTime.now().millisecondsSinceEpoch > expiry) { // Cache expired, clear it clearHomeUsersCache(); return null; } return _readJsonMap(_keyHomeUsersCache); } Future clearHomeUsersCache() async { await Future.wait([prefs.remove(_keyHomeUsersCache), prefs.remove(_keyHomeUsersCacheExpiry)]); } // Clear current user UUID (for server switching) 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 /// Get servers list as JSON string String? getServersListJson() { return prefs.getString(_keyServersList); } /// Save servers list as JSON string Future saveServersListJson(String serversJson) async { await prefs.setString(_keyServersList, serversJson); } /// Clear servers list Future clearServersList() async { await prefs.remove(_keyServersList); } /// Clear all multi-server data Future clearMultiServerData() async { await Future.wait([clearServersList(), clearServerOrder(), _clearKeysWithPrefix(_prefixServerEndpoint)]); } /// Server Order (stored as JSON list of server IDs) Future saveServerOrder(List serverIds) async { await _setStringList(_keyServerOrder, serverIds); } List? getServerOrder() => _getStringList(_keyServerOrder); /// Clear server order Future clearServerOrder() async { await prefs.remove(_keyServerOrder); } // Episode Count Persistence (for partial download detection) static const String _prefixEpisodeCount = 'episode_count_'; /// Save the total episode count for a show/season Future saveTotalEpisodeCount(String globalKey, int count) async { await prefs.setInt('$_prefixEpisodeCount$globalKey', count); } /// Get the total episode count for a show/season int? getTotalEpisodeCount(String globalKey) { return prefs.getInt('$_prefixEpisodeCount$globalKey'); } /// Load all persisted episode counts Map loadAllEpisodeCounts() { final counts = {}; final keys = prefs.getKeys().where((k) => k.startsWith(_prefixEpisodeCount)); for (final key in keys) { final globalKey = key.replaceFirst(_prefixEpisodeCount, ''); final count = prefs.getInt(key); if (count != null) { counts[globalKey] = count; } } return counts; } /// Remove the episode count for a specific show/season Future removeEpisodeCount(String globalKey) async { await prefs.remove('$_prefixEpisodeCount$globalKey'); } // Private helper methods /// Helper to read and decode JSON `List` from preferences List? _getStringList(String key) { final jsonString = prefs.getString(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 = prefs.getString(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.getKeys().where((k) => k.startsWith(prefix)); await Future.wait(keys.map((k) => prefs.remove(k))); } // 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); } }