Since 2.10.0 the whole app sits behind one all-or-nothing initialization gate, and that gate discarded the only evidence of its own failure. It caught the error, logged nothing but `error.runtimeType`, rendered an icon plus the word "Error" plus Retry, and never reported the error because catching it kept the crash reporter from ever seeing it. There is no log file on any platform, the buffer is in memory only, a double-clicked Windows release build has no console, and the log viewer lives in Settings, behind the gate that just failed. #1732 is the result: a Windows 11 user whose app will not boot and who cannot produce a single byte of diagnostic detail. The gate now names its phases. Each step is wrapped so a throw carries the phase it came from, replacing a `Future.wait` that discarded every error but the first and could not attribute it to any of four concurrent steps. The failure screen renders the phase, the exception type, the message and an expandable stack, plus copy and upload actions that reuse the existing log-relay flow. The record is persisted next to the database so the next successful launch can surface it in Settings > Logs, and it is reported to the crash reporter explicitly. Only preferences and the database still gate the launch. Window chrome, locale, crash-reporting init, TV/performance detection, the image-cache budget and download storage are best-effort and time-bounded, so a stalled platform thread degrades instead of holding the splash forever. Sentry no longer receives the startup work as its `appRunner`: that made a startup failure indistinguishable from a Sentry failure, and the guard would then have re-run migrations and the database open a second time. The two remaining fatal steps become recoverable. Preference reads tolerate a value whose stored type no longer matches, dropping the key and defaulting instead of failing the boot. A store that cannot be parsed is detected before either desktop plugin backend can memoise it, which is what makes an in-process repair possible at all. Repair is never automatic: it states what it will cost, salvages the credential-vault key and every tracker and Seerr session it can validate out of the damaged bytes, reseeds them, and moves the original aside rather than deleting it. Servers and profiles survive a salvaged key because their tokens are ciphertext in the database; tracker and Seerr sessions are plaintext preference entries, so the copy says they may still need reconnecting. Nothing derived from the store reaches a diagnostic. `FormatException` prints an excerpt of whatever it failed to parse, and during startup that document holds the vault key, refresh tokens and session cookies while the redaction manager still has nothing registered, so the wrapper keeps only the cause's type and offset and the record is an allowlist of already-redacted fields. The quarantined copy is labelled as containing credentials, is never offered for upload, and can be deleted from the dialog. Also self-heals orphaned WAL/SHM sidecars on desktop rather than only tvOS, makes every `createTable` migration step idempotent, keeps MSVC link by-products out of the Windows bundle, and asserts bundle contents in CI. Refs #1732
606 lines
24 KiB
Dart
606 lines
24 KiB
Dart
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<String> _credentialKeys = [_keyPlexToken, _keyClientId, _keyCurrentUserUUID];
|
|
|
|
static const List<String> _libraryPreferenceKeys = [_keyLibraryFilters, _keyLibraryOrder, _keyHiddenLibraries];
|
|
|
|
StorageService._();
|
|
|
|
static Future<StorageService> getInstance() {
|
|
return BaseSharedPreferencesService.initializeInstance(() => StorageService._());
|
|
}
|
|
|
|
@override
|
|
Future<void> 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<void> _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<Object?> v:
|
|
await prefs.setStringList(newKey, v.cast<String>());
|
|
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<T extends Object>(
|
|
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<String>(
|
|
baseKey,
|
|
prefix: _userPrefix,
|
|
read: readNullableString,
|
|
write: prefs.setString,
|
|
);
|
|
|
|
// Per-Server Endpoint URL (for multi-server connection caching)
|
|
Future<void> saveServerEndpoint(ServerId serverId, String url) async {
|
|
await prefs.setString('$_prefixServerEndpoint$serverId', url);
|
|
LogRedactionManager.registerServerUrl(url);
|
|
}
|
|
|
|
String? getServerEndpoint(ServerId serverId) {
|
|
return prefs.getString('$_prefixServerEndpoint$serverId');
|
|
}
|
|
|
|
Future<void> 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<void> 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<String> getOrCreateClientIdentifier() async {
|
|
final existing = prefs.getString(_keyClientId);
|
|
if (existing != null && existing.isNotEmpty) return existing;
|
|
final generated = const Uuid().v4();
|
|
await prefs.setString(_keyClientId, generated);
|
|
return generated;
|
|
}
|
|
|
|
// Clear all credentials
|
|
Future<void> clearCredentials() async {
|
|
await Future.wait([..._credentialKeys.map((k) => prefs.remove(k)), clearMultiServerData()]);
|
|
LogRedactionManager.clearTrackedValues();
|
|
}
|
|
|
|
// Selected Library Key (replaces index-based selection)
|
|
Future<void> saveSelectedLibraryKey(String key) async {
|
|
await prefs.setString('$_userPrefix$_keySelectedLibraryKey', key);
|
|
}
|
|
|
|
String? getSelectedLibraryKey() {
|
|
return _getScopedString(_keySelectedLibraryKey);
|
|
}
|
|
|
|
// Library Filters (stored as JSON string)
|
|
Future<void> saveLibraryFilters(Map<String, String> filters, {String? sectionId}) async {
|
|
final baseKey = sectionId != null ? '$_prefixLibraryFilters$sectionId' : _keyLibraryFilters;
|
|
// Note: using Map<String, String> which json.encode handles correctly
|
|
final jsonString = json.encode(filters);
|
|
await prefs.setString('$_userPrefix$baseKey', jsonString);
|
|
}
|
|
|
|
Map<String, String> 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<void> saveLibrarySort(String sectionId, String sortKey, {bool descending = false}) async {
|
|
final sortData = {'key': sortKey, 'descending': descending};
|
|
await _setJsonMap('$_userPrefix$_prefixLibrarySort$sectionId', sortData);
|
|
}
|
|
|
|
Map<String, dynamic>? getLibrarySort(String sectionId) => _readScopedWithLegacyMigration<Map<String, dynamic>>(
|
|
'$_prefixLibrarySort$sectionId',
|
|
prefix: _userPrefix,
|
|
read: (key) => _readJsonMap(key, legacyStringOk: true),
|
|
write: _setJsonMap,
|
|
);
|
|
|
|
// Library Grouping (per-library, e.g., 'movies', 'shows', 'seasons', 'episodes')
|
|
Future<void> 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<void> 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<void> saveHiddenLibraries(Set<String> libraryKeys) async {
|
|
await _setStringList('$_userPrefix$_keyHiddenLibraries', libraryKeys.toList());
|
|
}
|
|
|
|
Future<void> saveHiddenLibrariesForProfile(String profileId, Set<String> libraryKeys) async {
|
|
await _setStringList('${_userPrefixForProfileId(profileId)}$_keyHiddenLibraries', libraryKeys.toList());
|
|
}
|
|
|
|
Set<String> getHiddenLibraries() {
|
|
final jsonString = _getScopedString(_keyHiddenLibraries);
|
|
return _decodeStringSet(jsonString);
|
|
}
|
|
|
|
Set<String> getHiddenLibrariesForProfile(String profileId) => _decodeStringSet(
|
|
_readScopedWithLegacyMigration<String>(
|
|
_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<String> _decodeStringSet(String? jsonString) {
|
|
if (jsonString == null) return {};
|
|
|
|
try {
|
|
final list = json.decode(jsonString) as List<dynamic>;
|
|
return list.map((e) => e.toString()).toSet();
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
// Clear library preferences (scoped to current user)
|
|
Future<void> 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<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);
|
|
}
|
|
|
|
List<String>? getLibraryOrder() => _readScopedWithLegacyMigration<List<String>>(
|
|
_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 prefs.getString(_keyCurrentUserUUID);
|
|
}
|
|
|
|
/// Clears the legacy `currentUserUUID` slot. Used by the upgrade migration.
|
|
Future<void> clearCurrentUserUUID() async {
|
|
await prefs.remove(_keyCurrentUserUUID);
|
|
}
|
|
|
|
// Clear all user-related data (for logout)
|
|
Future<void> 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 prefs.getString(_keyServersList);
|
|
}
|
|
|
|
/// Clear the legacy servers list.
|
|
Future<void> clearServersList() async {
|
|
await prefs.remove(_keyServersList);
|
|
}
|
|
|
|
/// Clear all multi-server data
|
|
Future<void> clearMultiServerData() async {
|
|
await Future.wait([clearServersList(), clearServerOrder(), _clearKeysWithPrefix(_prefixServerEndpoint)]);
|
|
}
|
|
|
|
/// Clear legacy server order.
|
|
Future<void> clearServerOrder() async {
|
|
await prefs.remove(_keyServerOrder);
|
|
}
|
|
|
|
// Active app-level profile (kids mode / multi-user gating)
|
|
|
|
String? getActiveProfileId() => prefs.getString(_keyActiveProfileId);
|
|
|
|
Future<void> setActiveProfileId(String id) async {
|
|
await prefs.setString(_keyActiveProfileId, id);
|
|
}
|
|
|
|
Future<void> 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<void> savePlexHomeUsersCache(String connectionId, List<Map<String, dynamic>> users) async {
|
|
await prefs.setString('$_prefixPlexHomeUsers$connectionId', json.encode(users));
|
|
}
|
|
|
|
String? getPlexHomeUsersCacheJson(String connectionId) {
|
|
return prefs.getString('$_prefixPlexHomeUsers$connectionId');
|
|
}
|
|
|
|
Future<void> clearPlexHomeUsersCache(String connectionId) async {
|
|
await prefs.remove('$_prefixPlexHomeUsers$connectionId');
|
|
}
|
|
|
|
Future<void> 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<void> markProfileUsed(String profileId, DateTime at) async {
|
|
await prefs.setInt('$_prefixProfileLastUsed$profileId', at.millisecondsSinceEpoch);
|
|
}
|
|
|
|
DateTime? getProfileLastUsed(String profileId) {
|
|
final ms = prefs.getInt('$_prefixProfileLastUsed$profileId');
|
|
return ms == null ? null : DateTime.fromMillisecondsSinceEpoch(ms);
|
|
}
|
|
|
|
Future<void> clearAllProfileLastUsed() async {
|
|
await _clearKeysWithPrefix(_prefixProfileLastUsed);
|
|
}
|
|
|
|
Future<void> 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<void> clearUserScopedPreferencesForProfile(String profileId) async {
|
|
await _clearKeysWithPrefix(_userPrefixForProfileId(profileId));
|
|
}
|
|
|
|
/// Remove user-scoped prefs for every scope (full logout).
|
|
Future<void> clearAllUserScopedPreferences() async {
|
|
await _clearKeysWithPrefix('user_');
|
|
}
|
|
|
|
// Private helper methods
|
|
|
|
/// Helper to read and decode JSON `List<String>` from preferences
|
|
List<String>? _getStringList(String key) {
|
|
final jsonString = prefs.getString(key);
|
|
if (jsonString == null) return null;
|
|
|
|
try {
|
|
final decoded = json.decode(jsonString) as List<dynamic>;
|
|
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<String, dynamic>? _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<void> _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<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);
|
|
}
|
|
}
|
|
|
|
/// Run [op] over every slot holding [baseKey]: the legacy unscoped key plus
|
|
/// each `user_{scope}_{baseKey}` variant.
|
|
Future<void> _forEachScopedKey(String baseKey, Future<void> 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<void> _filterServerEntriesFromAllStringListKeys(String baseKey, ServerId serverId) =>
|
|
_forEachScopedKey(baseKey, (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) =>
|
|
_forEachScopedKey(_keySelectedLibraryKey, (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
|
|
Future<void> _setJsonMap(String key, Map<String, dynamic> data) async {
|
|
final jsonString = json.encode(data);
|
|
await prefs.setString(key, jsonString);
|
|
}
|
|
|
|
/// Save a string list as JSON array
|
|
Future<void> _setStringList(String key, List<String> list) async {
|
|
final jsonString = json.encode(list);
|
|
await prefs.setString(key, jsonString);
|
|
}
|
|
}
|