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
161 lines
5.1 KiB
Dart
161 lines
5.1 KiB
Dart
import '../utils/app_logger.dart';
|
|
import '../utils/json_utils.dart';
|
|
import 'plex/plex_user_profile.dart';
|
|
|
|
class UserSwitchResponse {
|
|
final int id;
|
|
final String uuid;
|
|
final String username;
|
|
final String title;
|
|
final String email;
|
|
final String? friendlyName;
|
|
final String? locale;
|
|
final bool confirmed;
|
|
final int joinedAt;
|
|
final bool emailOnlyAuth;
|
|
final bool hasPassword;
|
|
final bool protected;
|
|
final String thumb;
|
|
final String authToken;
|
|
final bool? mailingListActive;
|
|
final String scrobbleTypes;
|
|
final String country;
|
|
final bool restricted;
|
|
final bool? anonymous;
|
|
final bool home;
|
|
final bool guest;
|
|
final int homeSize;
|
|
final bool homeAdmin;
|
|
final int maxHomeSize;
|
|
final PlexUserProfile profile;
|
|
final bool twoFactorEnabled;
|
|
final bool backupCodesCreated;
|
|
final String? attributionPartner;
|
|
|
|
UserSwitchResponse({
|
|
required this.id,
|
|
required this.uuid,
|
|
required this.username,
|
|
required this.title,
|
|
required this.email,
|
|
this.friendlyName,
|
|
this.locale,
|
|
required this.confirmed,
|
|
required this.joinedAt,
|
|
required this.emailOnlyAuth,
|
|
required this.hasPassword,
|
|
required this.protected,
|
|
required this.thumb,
|
|
required this.authToken,
|
|
this.mailingListActive,
|
|
required this.scrobbleTypes,
|
|
required this.country,
|
|
required this.restricted,
|
|
this.anonymous,
|
|
required this.home,
|
|
required this.guest,
|
|
required this.homeSize,
|
|
required this.homeAdmin,
|
|
required this.maxHomeSize,
|
|
required this.profile,
|
|
required this.twoFactorEnabled,
|
|
required this.backupCodesCreated,
|
|
this.attributionPartner,
|
|
});
|
|
|
|
/// INVARIANT (#1488): a successful token mint must never be lost to parsing
|
|
/// of decorative fields. `authToken` is the only field any caller consumes
|
|
/// (see plex_home_switch.dart) — it alone parses strictly; every other
|
|
/// field tolerates missing/wrong-typed values with sane defaults. Plex has
|
|
/// changed field shapes on this endpoint before (July 2026: profile
|
|
/// language lists became CSV strings), and each drift used to brick token
|
|
/// minting outright.
|
|
factory UserSwitchResponse.fromJson(Map<String, dynamic> json) {
|
|
final authToken = json['authToken'];
|
|
if (authToken is! String || authToken.isEmpty) {
|
|
throw const FormatException('Plex /switch response has no usable authToken');
|
|
}
|
|
|
|
PlexUserProfile profile;
|
|
try {
|
|
profile = PlexUserProfile.fromJson(json);
|
|
} catch (e, st) {
|
|
appLogger.w('UserSwitchResponse: profile blob failed to parse; using defaults', error: e, stackTrace: st);
|
|
profile = PlexUserProfile.defaults();
|
|
}
|
|
|
|
String? optString(String key) => json[key]?.toString();
|
|
|
|
return UserSwitchResponse(
|
|
id: flexibleInt(json['id']) ?? 0,
|
|
uuid: optString('uuid') ?? '',
|
|
username: optString('username') ?? '',
|
|
title: optString('title') ?? '',
|
|
email: optString('email') ?? '',
|
|
friendlyName: optString('friendlyName'),
|
|
locale: optString('locale'),
|
|
confirmed: flexibleBool(json['confirmed']),
|
|
joinedAt: flexibleInt(json['joinedAt']) ?? 0,
|
|
emailOnlyAuth: flexibleBool(json['emailOnlyAuth']),
|
|
hasPassword: flexibleBool(json['hasPassword']),
|
|
protected: flexibleBool(json['protected']),
|
|
thumb: optString('thumb') ?? '',
|
|
authToken: authToken,
|
|
mailingListActive: flexibleBoolNullable(json['mailingListActive']),
|
|
scrobbleTypes: optString('scrobbleTypes') ?? '',
|
|
country: optString('country') ?? '',
|
|
restricted: flexibleBool(json['restricted']),
|
|
anonymous: flexibleBoolNullable(json['anonymous']),
|
|
home: flexibleBool(json['home']),
|
|
guest: flexibleBool(json['guest']),
|
|
homeSize: flexibleInt(json['homeSize']) ?? 1,
|
|
homeAdmin: flexibleBool(json['homeAdmin']),
|
|
maxHomeSize: flexibleInt(json['maxHomeSize']) ?? 1,
|
|
profile: profile,
|
|
twoFactorEnabled: flexibleBool(json['twoFactorEnabled']),
|
|
backupCodesCreated: flexibleBool(json['backupCodesCreated']),
|
|
attributionPartner: optString('attributionPartner'),
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'id': id,
|
|
'uuid': uuid,
|
|
'username': username,
|
|
'title': title,
|
|
'email': email,
|
|
'friendlyName': friendlyName,
|
|
'locale': locale,
|
|
'confirmed': confirmed,
|
|
'joinedAt': joinedAt,
|
|
'emailOnlyAuth': emailOnlyAuth,
|
|
'hasPassword': hasPassword,
|
|
'protected': protected,
|
|
'thumb': thumb,
|
|
'authToken': authToken,
|
|
'mailingListActive': mailingListActive,
|
|
'scrobbleTypes': scrobbleTypes,
|
|
'country': country,
|
|
'restricted': restricted,
|
|
'anonymous': anonymous,
|
|
'home': home,
|
|
'guest': guest,
|
|
'homeSize': homeSize,
|
|
'homeAdmin': homeAdmin,
|
|
'maxHomeSize': maxHomeSize,
|
|
'profile': profile.toJson()['profile'],
|
|
'twoFactorEnabled': twoFactorEnabled,
|
|
'backupCodesCreated': backupCodesCreated,
|
|
'attributionPartner': attributionPartner,
|
|
};
|
|
}
|
|
|
|
String get displayName => friendlyName ?? title;
|
|
|
|
bool get isAdminUser => homeAdmin;
|
|
bool get isRestrictedUser => restricted;
|
|
bool get isGuestUser => guest;
|
|
bool get requiresPassword => hasPassword;
|
|
}
|