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
+40 -8
View File
@@ -1,36 +1,47 @@
import 'package:json_annotation/json_annotation.dart';
import '../../media/media_server_user_profile.dart';
import '../../utils/json_utils.dart';
part 'plex_user_profile.g.dart';
/// Represents a Plex user's profile preferences
/// Fetched from https://clients.plex.tv/api/v2/user
///
/// Every field parses tolerantly: the account API drifts (~July 2026 the
/// language-list fields switched from arrays to CSV strings, #1488), and a
/// profile blob must never fail to parse — token minting embeds it (see
/// UserSwitchResponse.fromJson).
@JsonSerializable()
class PlexUserProfile implements MediaServerUserProfile {
@JsonKey(defaultValue: true)
@JsonKey(fromJson: _boolOrTrue)
@override
final bool autoSelectAudio;
@JsonKey(defaultValue: 0)
@JsonKey(fromJson: _intOr0)
final int defaultAudioAccessibility;
@JsonKey(fromJson: _flexibleLanguage)
@override
final String? defaultAudioLanguage;
@JsonKey(fromJson: flexibleCsvStringList)
@override
final List<String>? defaultAudioLanguages;
@JsonKey(fromJson: _flexibleLanguage)
@override
final String? defaultSubtitleLanguage;
@JsonKey(fromJson: flexibleCsvStringList)
@override
final List<String>? defaultSubtitleLanguages;
@JsonKey(defaultValue: 0)
@JsonKey(fromJson: _intOr0)
final int autoSelectSubtitle;
@JsonKey(defaultValue: 0)
@JsonKey(fromJson: _intOr0)
final int defaultSubtitleAccessibility;
@JsonKey(defaultValue: 1)
@JsonKey(fromJson: _intOr1)
final int defaultSubtitleForced;
@JsonKey(defaultValue: 1)
@JsonKey(fromJson: _intOr1)
final int watchedIndicator;
@JsonKey(defaultValue: 0)
@JsonKey(fromJson: _intOr0)
final int mediaReviewsVisibility;
@JsonKey(fromJson: flexibleCsvStringList)
final List<String>? mediaReviewsLanguages;
@override
@@ -51,10 +62,31 @@ class PlexUserProfile implements MediaServerUserProfile {
this.mediaReviewsLanguages,
});
/// Neutral fallback matching the generated defaults — used when the account
/// API returns a profile blob that cannot be parsed at all (schema drift
/// must never break token minting, see UserSwitchResponse.fromJson).
factory PlexUserProfile.defaults() => PlexUserProfile(
autoSelectAudio: true,
defaultAudioAccessibility: 0,
autoSelectSubtitle: 0,
defaultSubtitleAccessibility: 0,
defaultSubtitleForced: 1,
watchedIndicator: 1,
mediaReviewsVisibility: 0,
);
factory PlexUserProfile.fromJson(Map<String, dynamic> json) {
final profile = json['profile'] as Map<String, dynamic>? ?? json;
final envelope = json['profile'];
final profile = envelope is Map<String, dynamic> ? envelope : json;
return _$PlexUserProfileFromJson(profile);
}
Map<String, dynamic> toJson() => {'profile': _$PlexUserProfileToJson(this)};
}
/// Singular language fields tolerate the inverse drift (array/CSV → first entry).
String? _flexibleLanguage(Object? v) => flexibleCsvStringList(v)?.first;
bool _boolOrTrue(Object? v) => flexibleBoolNullable(v) ?? true;
int _intOr0(Object? v) => flexibleInt(v) ?? 0;
int _intOr1(Object? v) => flexibleInt(v) ?? 1;
+14 -21
View File
@@ -9,27 +9,20 @@ part of 'plex_user_profile.dart';
PlexUserProfile _$PlexUserProfileFromJson(
Map<String, dynamic> json,
) => PlexUserProfile(
autoSelectAudio: json['autoSelectAudio'] as bool? ?? true,
defaultAudioAccessibility:
(json['defaultAudioAccessibility'] as num?)?.toInt() ?? 0,
defaultAudioLanguage: json['defaultAudioLanguage'] as String?,
defaultAudioLanguages: (json['defaultAudioLanguages'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
defaultSubtitleLanguage: json['defaultSubtitleLanguage'] as String?,
defaultSubtitleLanguages: (json['defaultSubtitleLanguages'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
autoSelectSubtitle: (json['autoSelectSubtitle'] as num?)?.toInt() ?? 0,
defaultSubtitleAccessibility:
(json['defaultSubtitleAccessibility'] as num?)?.toInt() ?? 0,
defaultSubtitleForced: (json['defaultSubtitleForced'] as num?)?.toInt() ?? 1,
watchedIndicator: (json['watchedIndicator'] as num?)?.toInt() ?? 1,
mediaReviewsVisibility:
(json['mediaReviewsVisibility'] as num?)?.toInt() ?? 0,
mediaReviewsLanguages: (json['mediaReviewsLanguages'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
autoSelectAudio: _boolOrTrue(json['autoSelectAudio']),
defaultAudioAccessibility: _intOr0(json['defaultAudioAccessibility']),
defaultAudioLanguage: _flexibleLanguage(json['defaultAudioLanguage']),
defaultAudioLanguages: flexibleCsvStringList(json['defaultAudioLanguages']),
defaultSubtitleLanguage: _flexibleLanguage(json['defaultSubtitleLanguage']),
defaultSubtitleLanguages: flexibleCsvStringList(
json['defaultSubtitleLanguages'],
),
autoSelectSubtitle: _intOr0(json['autoSelectSubtitle']),
defaultSubtitleAccessibility: _intOr0(json['defaultSubtitleAccessibility']),
defaultSubtitleForced: _intOr1(json['defaultSubtitleForced']),
watchedIndicator: _intOr1(json['watchedIndicator']),
mediaReviewsVisibility: _intOr0(json['mediaReviewsVisibility']),
mediaReviewsLanguages: flexibleCsvStringList(json['mediaReviewsLanguages']),
);
Map<String, dynamic> _$PlexUserProfileToJson(PlexUserProfile instance) =>