From 6e8320f7827e5a76c00644db883d005b23a38c76 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:51:10 +0200 Subject: [PATCH] fix(profiles): parse real Plex Home uuids + migrate pref scopes --- lib/profiles/profile.dart | 13 +++++-- lib/services/storage_service.dart | 37 +++++++++++++++++++ test/profiles/profile_test.dart | 32 ++++++++++++++-- test/services/storage_service_test.dart | 49 +++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 7 deletions(-) diff --git a/lib/profiles/profile.dart b/lib/profiles/profile.dart index ad18e480..6138c0ca 100644 --- a/lib/profiles/profile.dart +++ b/lib/profiles/profile.dart @@ -211,11 +211,16 @@ String plexHomeProfileId({required String accountConnectionId, required String h return 'plex-home-$accountConnectionId-$homeUserUuid'; } -/// Anchor on the trailing 36-char UUID — both `accountConnectionId` and -/// `homeUserUuid` may contain hyphens, so a `lastIndexOf('-')` would slice -/// inside the UUID itself. +/// Anchor on the trailing home-user uuid — `accountConnectionId` may contain +/// hyphens, so a `lastIndexOf('-')` would slice inside a dashed uuid. The +/// `$` anchor resolves ambiguous hyphens: an earlier candidate split only +/// wins if the remainder is exactly one uuid. +/// +/// Real plex.tv `/api/v2/home/users` uuids are 16-char plain hex +/// (e.g. `7b4af7a9f26254bd`); the 36-char dashed RFC-4122 shape is kept as a +/// defensive alternate. final RegExp _trailingHomeUserUuidPattern = RegExp( - r'-([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$', + r'-([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16})$', ); /// Inverse of [plexHomeProfileId]. Returns `null` if [id] doesn't match the diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index cde69d8f..71fb497a 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -46,6 +46,43 @@ class StorageService extends BaseSharedPreferencesService { // 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 diff --git a/test/profiles/profile_test.dart b/test/profiles/profile_test.dart index 16ce1367..fe79871a 100644 --- a/test/profiles/profile_test.dart +++ b/test/profiles/profile_test.dart @@ -86,9 +86,21 @@ void main() { expect(plexHomeProfileId(accountConnectionId: 'plex.dev1', homeUserUuid: 'uuid-1'), 'plex-home-plex.dev1-uuid-1'); }); - test('parsePlexHomeProfileId round-trips a real hyphenated UUID', () { - // Real Plex Home UUIDs are 36-char standard UUIDs (4 internal hyphens), - // and accountConnectionId can carry hyphens too (e.g. plex.client-id). + test('parsePlexHomeProfileId round-trips a real 16-hex uuid', () { + // Real plex.tv home-user uuids are 16-char plain hex, and the account + // connection id itself usually ends in one (plex.{accountUuid}). + const acct = 'plex.e443d57860076fc3'; + const uuid = '379704d0c6601309'; + final id = plexHomeProfileId(accountConnectionId: acct, homeUserUuid: uuid); + final parsed = parsePlexHomeProfileId(id); + expect(parsed, isNotNull); + expect(parsed!.accountConnectionId, acct); + expect(parsed.homeUserUuid, uuid); + }); + + test('parsePlexHomeProfileId round-trips a hyphenated RFC-4122 uuid', () { + // Defensive alternate shape; accountConnectionId can carry hyphens too + // (e.g. plex.client-id). const acct = 'plex.client-id-123'; const uuid = 'a1b2c3d4-e5f6-7890-abcd-ef0123456789'; final id = plexHomeProfileId(accountConnectionId: acct, homeUserUuid: uuid); @@ -98,10 +110,24 @@ void main() { expect(parsed.homeUserUuid, uuid); }); + test('parsePlexHomeProfileId splits at the last uuid-shaped segment', () { + // A hex-ending account id must not steal the uuid slot: the $ anchor + // forces the trailing segment to be the home-user uuid. + const acct = 'acct-aaaaaaaaaaaaaaaa'; + const uuid = 'bbbbbbbbbbbbbbbb'; + final parsed = parsePlexHomeProfileId(plexHomeProfileId(accountConnectionId: acct, homeUserUuid: uuid)); + expect(parsed, isNotNull); + expect(parsed!.accountConnectionId, acct); + expect(parsed.homeUserUuid, uuid); + }); + test('parsePlexHomeProfileId rejects non-Plex-Home ids', () { expect(parsePlexHomeProfileId('local-1'), isNull); expect(parsePlexHomeProfileId('plex-home-only'), isNull); expect(parsePlexHomeProfileId('plex-home-acct-not-a-uuid'), isNull); + // 15 and 17 hex chars are not uuids. + expect(parsePlexHomeProfileId('plex-home-acct-aaaaaaaaaaaaaaa'), isNull); + expect(parsePlexHomeProfileId('plex-home-acct-aaaaaaaaaaaaaaaaa'), isNull); }); }); diff --git a/test/services/storage_service_test.dart b/test/services/storage_service_test.dart index b729fe2b..15467f99 100644 --- a/test/services/storage_service_test.dart +++ b/test/services/storage_service_test.dart @@ -369,6 +369,55 @@ void main() { }); }); + // ============================================================ + // Plex Home user-scope migration (full profile id → home-user uuid) + // ============================================================ + + group('migratePlexHomeUserScopes (onInit)', () { + const fullId = 'plex-home-plex.e443d57860076fc3-379704d0c6601309'; + const uuid = '379704d0c6601309'; + + Future reinitialize(StorageService s) async { + BaseSharedPreferencesService.resetForTesting(); + return StorageService.getInstance(); + } + + test('moves full-profile-id-scoped keys onto the uuid scope', () async { + var s = await StorageService.getInstance(); + await s.prefs.setString('user_${fullId}_selected_library_key', 'lib-1'); + await s.prefs.setBool('user_${fullId}_some_flag', true); + await s.prefs.setStringList('user_${fullId}_hidden_libraries', ['a', 'b']); + + s = await reinitialize(s); + + expect(s.prefs.getString('user_${uuid}_selected_library_key'), 'lib-1'); + expect(s.prefs.getBool('user_${uuid}_some_flag'), isTrue); + expect(s.prefs.getStringList('user_${uuid}_hidden_libraries'), ['a', 'b']); + expect(s.prefs.keys.where((k) => k.contains('plex-home-')), isEmpty); + }); + + test('full-id value wins over a stale pre-migration uuid-scoped value', () async { + var s = await StorageService.getInstance(); + await s.prefs.setString('user_${uuid}_selected_library_key', 'stale'); + await s.prefs.setString('user_${fullId}_selected_library_key', 'fresh'); + + s = await reinitialize(s); + + expect(s.prefs.getString('user_${uuid}_selected_library_key'), 'fresh'); + }); + + test('leaves local-profile scopes and unparseable plex-home scopes untouched', () async { + var s = await StorageService.getInstance(); + await s.prefs.setString('user_local-1_selected_library_key', 'keep'); + await s.prefs.setString('user_plex-home-acct-not-a-uuid_key', 'keep-too'); + + s = await reinitialize(s); + + expect(s.prefs.getString('user_local-1_selected_library_key'), 'keep'); + expect(s.prefs.getString('user_plex-home-acct-not-a-uuid_key'), 'keep-too'); + }); + }); + // ============================================================ // clearCredentials // ============================================================