fix(profiles): parse real Plex Home uuids + migrate pref scopes

This commit is contained in:
edde746
2026-07-02 11:41:25 +02:00
parent bfcd11538e
commit 6e8320f782
4 changed files with 124 additions and 7 deletions
+9 -4
View File
@@ -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
+37
View File
@@ -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<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
+29 -3
View File
@@ -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);
});
});
+49
View File
@@ -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<StorageService> 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
// ============================================================