fix(plex): tolerate account-API schema drift in profile and switch parsing

Around July 3 plex.tv started returning the profile language-list fields
(defaultAudioLanguages, defaultSubtitleLanguages, mediaReviewsLanguages)
as comma-separated strings instead of arrays. The generated cast threw on
the successful 201 /switch response, dropping the freshly minted Home
user token: every rebind failed, the binder retried a /switch mint every
1-2s, and the app sat permanently in offline mode even after re-signing
in. Accounts without language prefs set were unaffected, which is why
the breakage looked sporadic.

Parse the language lists with a CSV-aware coercion, and make
UserSwitchResponse.fromJson strict only about authToken: decorative
fields now coerce tolerantly and a broken profile blob falls back to
defaults, so account-API drift can never brick token minting again.

close #1488
This commit is contained in:
edde746
2026-07-05 06:23:34 +02:00
parent 6e83aeda88
commit 96510f8aac
9 changed files with 546 additions and 57 deletions
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/plex/plex_user_profile.dart';
void main() {
group('PlexUserProfile.fromJson', () {
test('parses legacy array shape for language lists', () {
final profile = PlexUserProfile.fromJson({
'defaultAudioLanguages': ['en', 'sv'],
'defaultSubtitleLanguages': ['sv'],
'mediaReviewsLanguages': ['en'],
});
expect(profile.defaultAudioLanguages, ['en', 'sv']);
expect(profile.defaultSubtitleLanguages, ['sv']);
expect(profile.mediaReviewsLanguages, ['en']);
});
test('parses the July 2026 CSV string shape for language lists (#1488)', () {
final profile = PlexUserProfile.fromJson({
'defaultAudioLanguages': 'en,sv',
'defaultSubtitleLanguages': 'en,sv',
'mediaReviewsLanguages': 'en',
});
expect(profile.defaultAudioLanguages, ['en', 'sv']);
expect(profile.defaultSubtitleLanguages, ['en', 'sv']);
expect(profile.mediaReviewsLanguages, ['en']);
});
test('parses absent and null language lists as null', () {
final profile = PlexUserProfile.fromJson({'defaultAudioLanguages': null});
expect(profile.defaultAudioLanguages, isNull);
expect(profile.defaultSubtitleLanguages, isNull);
expect(profile.mediaReviewsLanguages, isNull);
});
test('unwraps the profile envelope and tolerates a non-map envelope', () {
final flat = PlexUserProfile.fromJson({'defaultAudioLanguage': 'en'});
final wrapped = PlexUserProfile.fromJson({
'profile': {'defaultAudioLanguage': 'en'},
});
final garbage = PlexUserProfile.fromJson({'profile': 'garbage'});
expect(flat.defaultAudioLanguage, 'en');
expect(wrapped.defaultAudioLanguage, 'en');
expect(garbage.defaultAudioLanguage, isNull);
});
test('singular language fields take the first entry of array/CSV drift', () {
final csv = PlexUserProfile.fromJson({'defaultAudioLanguage': 'en,sv'});
final array = PlexUserProfile.fromJson({
'defaultSubtitleLanguage': ['sv', 'en'],
});
expect(csv.defaultAudioLanguage, 'en');
expect(array.defaultSubtitleLanguage, 'sv');
});
test('scalar fields coerce from drifted types', () {
final profile = PlexUserProfile.fromJson({
'autoSelectAudio': 0,
'autoSelectSubtitle': '1',
'watchedIndicator': '2',
'defaultSubtitleForced': {},
});
expect(profile.autoSelectAudio, isFalse);
expect(profile.autoSelectSubtitle, 1);
expect(profile.watchedIndicator, 2);
expect(profile.defaultSubtitleForced, 1);
});
test('defaults() matches parsing an empty map', () {
final parsed = PlexUserProfile.fromJson(const {});
final defaults = PlexUserProfile.defaults();
expect(defaults.autoSelectAudio, parsed.autoSelectAudio);
expect(defaults.defaultAudioAccessibility, parsed.defaultAudioAccessibility);
expect(defaults.defaultAudioLanguage, parsed.defaultAudioLanguage);
expect(defaults.defaultAudioLanguages, parsed.defaultAudioLanguages);
expect(defaults.defaultSubtitleLanguage, parsed.defaultSubtitleLanguage);
expect(defaults.defaultSubtitleLanguages, parsed.defaultSubtitleLanguages);
expect(defaults.autoSelectSubtitle, parsed.autoSelectSubtitle);
expect(defaults.defaultSubtitleAccessibility, parsed.defaultSubtitleAccessibility);
expect(defaults.defaultSubtitleForced, parsed.defaultSubtitleForced);
expect(defaults.watchedIndicator, parsed.watchedIndicator);
expect(defaults.mediaReviewsVisibility, parsed.mediaReviewsVisibility);
expect(defaults.mediaReviewsLanguages, parsed.mediaReviewsLanguages);
});
});
}
+111
View File
@@ -0,0 +1,111 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/user_switch_response.dart';
/// A realistic `/api/v2/home/users/{uuid}/switch` 201 body using the
/// July 2026 wire shape where profile language lists are CSV strings (#1488).
Map<String, dynamic> driftedSwitchJson() => {
'id': 312174832,
'uuid': 'e443d57860076fc3',
'username': 'pl1624',
'title': 'pl1624',
'email': 'user@example.com',
'friendlyName': '',
'locale': null,
'confirmed': true,
'joinedAt': 1703877982,
'emailOnlyAuth': false,
'hasPassword': true,
'protected': true,
'thumb': 'https://plex.tv/users/e443d57860076fc3/avatar',
'authToken': 'minted-user-token',
'mailingListActive': false,
'scrobbleTypes': '',
'country': 'SE',
'restricted': false,
'anonymous': false,
'home': true,
'guest': false,
'homeSize': 2,
'homeAdmin': true,
'maxHomeSize': 15,
'profile': {
'autoSelectAudio': true,
'defaultAudioAccessibility': 0,
'defaultAudioLanguage': 'en',
'defaultAudioLanguages': 'en,sv',
'defaultSubtitleLanguage': 'en',
'defaultSubtitleLanguages': 'en,sv',
'autoSelectSubtitle': 1,
'defaultSubtitleAccessibility': 0,
'defaultSubtitleForced': 1,
'watchedIndicator': 1,
'mediaReviewsVisibility': 0,
'mediaReviewsLanguages': null,
'mediaPostsVisibility': true,
},
'twoFactorEnabled': false,
'backupCodesCreated': false,
'attributionPartner': null,
};
void main() {
group('UserSwitchResponse.fromJson', () {
test('parses a realistic drifted 201 body, preserving the token', () {
final response = UserSwitchResponse.fromJson(driftedSwitchJson());
expect(response.authToken, 'minted-user-token');
expect(response.uuid, 'e443d57860076fc3');
expect(response.protected, isTrue);
expect(response.homeAdmin, isTrue);
expect(response.profile.defaultAudioLanguages, ['en', 'sv']);
expect(response.profile.defaultSubtitleLanguages, ['en', 'sv']);
});
test('parses a token-only body with defaults everywhere else', () {
final response = UserSwitchResponse.fromJson({'authToken': 'tok'});
expect(response.authToken, 'tok');
expect(response.id, 0);
expect(response.uuid, '');
expect(response.title, '');
expect(response.confirmed, isFalse);
expect(response.homeSize, 1);
expect(response.maxHomeSize, 1);
expect(response.profile.autoSelectAudio, isTrue);
expect(response.profile.defaultAudioLanguages, isNull);
});
test('never loses the token to wrong-typed decorative fields', () {
final response = UserSwitchResponse.fromJson({
'authToken': 'tok',
'id': {},
'uuid': 42,
'title': 7,
'confirmed': 'yes',
'joinedAt': {},
'hasPassword': 'nope',
'protected': [],
'thumb': 1.5,
'homeSize': 'many',
'maxHomeSize': null,
'profile': 'garbage',
'twoFactorEnabled': {},
});
expect(response.authToken, 'tok');
expect(response.id, 0);
expect(response.uuid, '42');
expect(response.title, '7');
expect(response.confirmed, isFalse);
expect(response.homeSize, 1);
expect(response.profile.autoSelectAudio, isTrue);
expect(response.profile.defaultAudioLanguages, isNull);
});
test('throws when authToken is missing, empty, or not a string', () {
expect(() => UserSwitchResponse.fromJson(const {}), throwsFormatException);
expect(() => UserSwitchResponse.fromJson({'authToken': ''}), throwsFormatException);
expect(() => UserSwitchResponse.fromJson({'authToken': 12345}), throwsFormatException);
});
});
}