Files
plezy/lib/models/user_switch_response.dart
T
edde746 96510f8aac 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
2026-07-05 06:23:34 +02:00

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;
}