fix(plex): tolerate scalar schema drift

This commit is contained in:
edde746
2026-07-12 08:42:18 +02:00
parent a809cbbcef
commit 9c6fcbc841
14 changed files with 197 additions and 79 deletions
+9 -5
View File
@@ -1,21 +1,23 @@
import 'package:json_annotation/json_annotation.dart';
import '../../utils/json_utils.dart';
import 'plex_home_user.dart';
part 'plex_home.g.dart';
@JsonSerializable()
class PlexHome {
@JsonKey(defaultValue: 0)
@JsonKey(fromJson: _intOr0)
final int id;
@JsonKey(defaultValue: '')
@JsonKey(readValue: readStringField, defaultValue: '')
final String name;
@JsonKey(fromJson: flexibleInt)
final int? guestUserID;
@JsonKey(defaultValue: '')
@JsonKey(readValue: readStringField, defaultValue: '')
final String guestUserUUID;
@JsonKey(defaultValue: false)
@JsonKey(fromJson: flexibleBool)
final bool guestEnabled;
@JsonKey(defaultValue: false)
@JsonKey(fromJson: flexibleBool)
final bool subscription;
@JsonKey(defaultValue: <PlexHomeUser>[])
final List<PlexHomeUser> users;
@@ -50,3 +52,5 @@ class PlexHome {
bool get hasMultipleUsers => users.length > 1;
}
int _intOr0(Object? value) => flexibleInt(value) ?? 0;
+6 -6
View File
@@ -7,12 +7,12 @@ part of 'plex_home.dart';
// **************************************************************************
PlexHome _$PlexHomeFromJson(Map<String, dynamic> json) => PlexHome(
id: (json['id'] as num?)?.toInt() ?? 0,
name: json['name'] as String? ?? '',
guestUserID: (json['guestUserID'] as num?)?.toInt(),
guestUserUUID: json['guestUserUUID'] as String? ?? '',
guestEnabled: json['guestEnabled'] as bool? ?? false,
subscription: json['subscription'] as bool? ?? false,
id: _intOr0(json['id']),
name: readStringField(json, 'name') as String? ?? '',
guestUserID: flexibleInt(json['guestUserID']),
guestUserUUID: readStringField(json, 'guestUserUUID') as String? ?? '',
guestEnabled: flexibleBool(json['guestEnabled']),
subscription: flexibleBool(json['subscription']),
users:
(json['users'] as List<dynamic>?)
?.map((e) => PlexHomeUser.fromJson(e as Map<String, dynamic>))
+6 -7
View File
@@ -144,8 +144,7 @@ class PlexFileInfoStreamReader implements FileInfoStreamReader {
@override
FileInfoStreamType? typeOf(Map<String, dynamic> stream) {
final t = stream['streamType'];
if (t is! int) return null;
final t = flexibleInt(stream['streamType']);
return switch (t) {
PlexStreamType.video => FileInfoStreamType.video,
PlexStreamType.audio => FileInfoStreamType.audio,
@@ -157,14 +156,14 @@ class PlexFileInfoStreamReader implements FileInfoStreamReader {
@override
MediaAudioTrack toAudioTrack(Map<String, dynamic> stream, int _) {
return MediaAudioTrack(
id: stream['id'] as int,
index: stream['index'] as int?,
id: flexibleInt(stream['id']) ?? (throw const FormatException('Plex audio stream is missing a numeric id')),
index: flexibleInt(stream['index']),
codec: stream['codec'] as String?,
language: stream['language'] as String?,
languageCode: stream['languageCode'] as String?,
title: stream['title'] as String?,
displayTitle: stream['displayTitle'] as String?,
channels: stream['channels'] as int?,
channels: flexibleInt(stream['channels']),
selected: flexibleBool(stream['selected']),
);
}
@@ -172,8 +171,8 @@ class PlexFileInfoStreamReader implements FileInfoStreamReader {
@override
MediaSubtitleTrack toSubtitleTrack(Map<String, dynamic> stream, int _) {
return MediaSubtitleTrack(
id: stream['id'] as int,
index: stream['index'] as int?,
id: flexibleInt(stream['id']) ?? (throw const FormatException('Plex subtitle stream is missing a numeric id')),
index: flexibleInt(stream['index']),
codec: stream['codec'] as String?,
language: stream['language'] as String?,
languageCode: stream['languageCode'] as String?,
+19 -18
View File
@@ -11,6 +11,7 @@ import '../models/user_switch_response.dart';
import '../utils/app_logger.dart';
import '../utils/device_identity.dart';
import '../utils/endpoint_race.dart';
import '../utils/json_utils.dart';
import '../utils/media_server_timeouts.dart';
import '../utils/media_server_http_client.dart';
import '../utils/poll_with_backoff.dart';
@@ -359,25 +360,18 @@ class PlexServer {
throw const FormatException('Server has no valid connections');
}
DateTime? lastSeenAt;
if (json['lastSeenAt'] != null) {
try {
lastSeenAt = DateTime.parse(json['lastSeenAt'] as String);
} catch (e) {
lastSeenAt = null;
}
}
final lastSeenAt = DateTime.tryParse(json['lastSeenAt']?.toString() ?? '');
return PlexServer(
name: json['name'] as String, // Safe because validated above
clientIdentifier: json['clientIdentifier'] as String, // Safe because validated above
accessToken: json['accessToken'] as String, // Safe because validated above
connections: connections,
owned: json['owned'] as bool? ?? false,
product: json['product'] as String?,
platform: json['platform'] as String?,
owned: flexibleBool(json['owned']),
product: _optionalScalarString(json['product']),
platform: _optionalScalarString(json['platform']),
lastSeenAt: lastSeenAt,
presence: json['presence'] as bool? ?? false,
presence: flexibleBool(json['presence']),
);
}
@@ -992,11 +986,11 @@ class PlexConnection {
return PlexConnection(
protocol: json['protocol'] as String, // Safe because validated above
address: json['address'] as String, // Safe because validated above
port: json['port'] as int, // Safe because validated above
port: flexibleInt(json['port'])!, // Safe because validated above
uri: json['uri'] as String, // Safe because validated above
local: json['local'] as bool? ?? false,
relay: json['relay'] as bool? ?? false,
ipv6: json['IPv6'] as bool? ?? false,
local: flexibleBool(json['local']),
relay: flexibleBool(json['relay']),
ipv6: flexibleBool(json['IPv6']),
);
}
@@ -1013,8 +1007,7 @@ class PlexConnection {
return false;
}
// Check for required port (integer)
if (json['port'] is! int) {
if (flexibleInt(json['port']) == null) {
return false;
}
@@ -1073,6 +1066,14 @@ class PlexConnection {
}
}
String? _optionalScalarString(Object? value) => switch (value) {
null => null,
final String value => value,
final num value => value.toString(),
final bool value => value.toString(),
_ => null,
};
/// Custom exception for server parsing errors that includes debug data
class ServerParsingException implements Exception {
final String message;
+11 -6
View File
@@ -2670,16 +2670,21 @@ class PlexClient
),
];
}
final playQueueID = flexibleInt(container['playQueueID']);
final playQueueVersion = flexibleInt(container['playQueueVersion']);
if (playQueueID == null || playQueueVersion == null) {
throw const FormatException('Plex play queue response is missing its numeric id or version');
}
return PlayQueueResponse(
playQueueID: (container['playQueueID'] as num).toInt(),
playQueueSelectedItemID: (container['playQueueSelectedItemID'] as num?)?.toInt(),
playQueueSelectedItemOffset: (container['playQueueSelectedItemOffset'] as num?)?.toInt(),
playQueueID: playQueueID,
playQueueSelectedItemID: flexibleInt(container['playQueueSelectedItemID']),
playQueueSelectedItemOffset: flexibleInt(container['playQueueSelectedItemOffset']),
playQueueSelectedMetadataItemID: container['playQueueSelectedMetadataItemID'] as String?,
playQueueShuffled: flexibleBool(container['playQueueShuffled']),
playQueueSourceURI: container['playQueueSourceURI'] as String?,
playQueueTotalCount: (container['playQueueTotalCount'] as num?)?.toInt(),
playQueueVersion: (container['playQueueVersion'] as num).toInt(),
size: (container['size'] as num?)?.toInt(),
playQueueTotalCount: flexibleInt(container['playQueueTotalCount']),
playQueueVersion: playQueueVersion,
size: flexibleInt(container['size']),
items: items,
);
}
+3
View File
@@ -561,8 +561,11 @@ class PlexMetadataDto {
final String? titleSort;
final String? contentRating;
final String? summary;
@JsonKey(fromJson: flexibleDouble)
final double? rating;
@JsonKey(fromJson: flexibleDouble)
final double? audienceRating;
@JsonKey(fromJson: flexibleDouble)
final double? userRating;
@JsonKey(fromJson: flexibleInt)
final int? year;
+3 -3
View File
@@ -89,9 +89,9 @@ PlexMetadataDto _$PlexMetadataDtoFromJson(Map<String, dynamic> json) =>
titleSort: json['titleSort'] as String?,
contentRating: json['contentRating'] as String?,
summary: json['summary'] as String?,
rating: (json['rating'] as num?)?.toDouble(),
audienceRating: (json['audienceRating'] as num?)?.toDouble(),
userRating: (json['userRating'] as num?)?.toDouble(),
rating: flexibleDouble(json['rating']),
audienceRating: flexibleDouble(json['audienceRating']),
userRating: flexibleDouble(json['userRating']),
year: flexibleInt(json['year']),
originallyAvailableAt: json['originallyAvailableAt'] as String?,
thumb: json['thumb'] as String?,
+11 -11
View File
@@ -132,7 +132,7 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson(
chapters: chapters,
partId: flexibleInt(part['id']),
displayCriteria: PlexMappers.displayCriteriaFromJson(Map<String, dynamic>.from(media), streams.videoStream),
videoAspectRatio: (media['aspectRatio'] as num?)?.toDouble(),
videoAspectRatio: flexibleDouble(media['aspectRatio']),
);
}
}
@@ -172,27 +172,27 @@ MediaFileInfo? parsePlexFileInfoFromJson(Map<String, dynamic>? metadataJson) {
videoResolution: media['videoResolution'] as String?,
videoFrameRate: media['videoFrameRate'] as String?,
videoProfile: media['videoProfile'] as String?,
width: media['width'] as int?,
height: media['height'] as int?,
aspectRatio: (media['aspectRatio'] as num?)?.toDouble(),
bitrate: media['bitrate'] as int?,
duration: media['duration'] as int?,
width: flexibleInt(media['width']),
height: flexibleInt(media['height']),
aspectRatio: flexibleDouble(media['aspectRatio']),
bitrate: flexibleInt(media['bitrate']),
duration: flexibleInt(media['duration']),
audioCodec: media['audioCodec'] as String?,
audioProfile: media['audioProfile'] as String?,
audioChannels: media['audioChannels'] as int?,
audioChannels: flexibleInt(media['audioChannels']),
optimizedForStreaming: flexibleBool(media['optimizedForStreaming']),
has64bitOffsets: flexibleBool(media['has64bitOffsets']),
// Part level properties (file)
filePath: part?['file'] as String?,
fileSize: part?['size'] as int?,
fileSize: flexibleInt(part?['size']),
// Video stream details
colorSpace: videoStream?['colorSpace'] as String?,
colorRange: videoStream?['colorRange'] as String?,
colorPrimaries: videoStream?['colorPrimaries'] as String?,
chromaSubsampling: videoStream?['chromaSubsampling'] as String?,
frameRate: (videoStream?['frameRate'] as num?)?.toDouble(),
bitDepth: videoStream?['bitDepth'] as int?,
videoBitrate: videoStream?['bitrate'] as int?,
frameRate: flexibleDouble(videoStream?['frameRate']),
bitDepth: flexibleInt(videoStream?['bitDepth']),
videoBitrate: flexibleInt(videoStream?['bitrate']),
// Audio stream details
audioChannelLayout: audioStream?['audioChannelLayout'] as String?,
// All audio and subtitle tracks
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/plex/plex_home.dart';
void main() {
test('PlexHome tolerates scalar drift from the account API', () {
final home = PlexHome.fromJson({
'id': '7',
'name': 42,
'guestUserID': '9',
'guestUserUUID': 123,
'guestEnabled': 1,
'subscription': 'true',
'users': <dynamic>[],
});
expect(home.id, 7);
expect(home.name, '42');
expect(home.guestUserID, 9);
expect(home.guestUserUUID, '123');
expect(home.guestEnabled, isTrue);
expect(home.subscription, isTrue);
});
}
+10 -10
View File
@@ -14,14 +14,14 @@ void main() {
test('captures the first video stream and accumulates audio + subs', () {
final streams = [
// streamType 1=video, 2=audio, 3=subtitle
{'streamType': 1, 'id': 100, 'frameRate': 23.976, 'colorSpace': 'bt709'},
{'streamType': '1', 'id': '100', 'frameRate': 23.976, 'colorSpace': 'bt709'},
{
'streamType': 2,
'id': 101,
'index': 1,
'streamType': '2',
'id': '101',
'index': '1',
'codec': 'eac3',
'language': 'English',
'channels': 6,
'channels': '6',
'selected': true,
'displayTitle': 'English (EAC3 5.1)',
},
@@ -35,9 +35,9 @@ void main() {
'selected': false,
},
{
'streamType': 3,
'id': 200,
'index': 3,
'streamType': '3',
'id': '200',
'index': '3',
'codec': 'srt',
'language': 'English',
'forced': false,
@@ -48,8 +48,8 @@ void main() {
final out = walkStreams(streams, reader);
expect(out.videoStream?['id'], 100);
expect(out.audioStream?['id'], 101);
expect(out.videoStream?['id'], '100');
expect(out.audioStream?['id'], '101');
expect(out.videoStream?['frameRate'], closeTo(23.976, 1e-6));
expect(out.audioTracks.map((t) => t.id), [101, 102]);
expect(out.audioTracks[0].channels, 6);
+31
View File
@@ -79,6 +79,37 @@ void main() {
expect(response.profile.defaultAudioLanguages, ['en', 'sv']);
expect(response.profile.defaultSubtitleLanguages, ['en', 'sv']);
});
test('fetchServers tolerates scalar drift in server and connection fields', () async {
final server = _serverJson()
..['owned'] = '1'
..['presence'] = 1
..['product'] = 42
..['lastSeenAt'] = 123;
final connection = (server['connections'] as List).single as Map<String, dynamic>
..['port'] = '32400'
..['local'] = '1'
..['relay'] = 0
..['IPv6'] = 'false';
final client = MediaServerHttpClient(
client: MockClient(
(_) async => http.Response(jsonEncode([server]), 200, headers: {'content-type': 'application/json'}),
),
);
addTearDown(client.close);
final servers = await PlexAuthService.forTesting(http: client).fetchServers('token');
expect(servers.single.owned, isTrue);
expect(servers.single.presence, isTrue);
expect(servers.single.product, '42');
expect(servers.single.lastSeenAt, isNull);
expect(connection['port'], '32400');
expect(servers.single.connections.first.port, 32400);
expect(servers.single.connections.first.local, isTrue);
expect(servers.single.connections.first.relay, isFalse);
expect(servers.single.connections.first.ipv6, isFalse);
});
});
}
@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
@@ -53,4 +55,34 @@ void main() {
expect(await client.createCollectionFromUri(sectionId: '1', title: 'Collection', uri: 'server://items'), isNull);
expect(await client.createPlayQueue(uri: 'server://items', type: 'video'), isNull);
});
test('play queue accepts numeric strings from Plex', () async {
final client = makeClient(
(_) async => http.Response(
jsonEncode({
'MediaContainer': {
'playQueueID': '42',
'playQueueSelectedItemID': '7',
'playQueueSelectedItemOffset': '1',
'playQueueTotalCount': '3',
'playQueueVersion': '5',
'size': '3',
'Metadata': <dynamic>[],
},
}),
200,
headers: {'content-type': 'application/json'},
),
);
addTearDown(client.close);
final queue = await client.createPlayQueue(uri: 'server://items', type: 'video');
expect(queue?.playQueueID, 42);
expect(queue?.playQueueSelectedItemID, 7);
expect(queue?.playQueueSelectedItemOffset, 1);
expect(queue?.playQueueTotalCount, 3);
expect(queue?.playQueueVersion, 5);
expect(queue?.size, 3);
});
}
+13
View File
@@ -9,6 +9,19 @@ const _serverId = 'plex-machine-1';
const _serverName = 'Home';
void main() {
test('PlexMetadataDto accepts string ratings', () {
final dto = PlexMetadataDto.fromJson({
'ratingKey': '1',
'rating': '8.8',
'audienceRating': '9.1',
'userRating': '9.5',
});
expect(dto.rating, 8.8);
expect(dto.audienceRating, 9.1);
expect(dto.userRating, 9.5);
});
group('PlexMappers.mediaItem (movie)', () {
test('maps a Plex movie with watch state, ratings, genres, and people', () {
final json = {
+20 -13
View File
@@ -343,32 +343,39 @@ void main() {
'container': 'mkv',
'videoCodec': 'h264',
'videoResolution': '1080',
'width': 1920,
'height': 1080,
'aspectRatio': 1.78,
'bitrate': 8000,
'duration': 120000,
'width': '1920',
'height': '1080',
'aspectRatio': '1.78',
'bitrate': '8000',
'duration': '120000',
'audioCodec': 'aac',
'audioChannels': 2,
'audioChannels': '2',
'optimizedForStreaming': '1',
'has64bitOffsets': 0,
'Part': [
{
'file': '/media/movie.mkv',
'size': 123456,
'size': '123456',
'Stream': [
{'streamType': 1, 'frameRate': 24, 'colorSpace': 'bt709', 'bitDepth': 8, 'bitrate': 7000},
{'streamType': '1', 'frameRate': '24', 'colorSpace': 'bt709', 'bitDepth': '8', 'bitrate': '7000'},
{
'streamType': 2,
'id': 301,
'index': 0,
'streamType': '2',
'id': '301',
'index': '0',
'language': 'English',
'languageCode': 'eng',
'channels': 2,
'channels': '2',
'selected': true,
'audioChannelLayout': 'stereo',
},
{'streamType': 3, 'id': 401, 'index': 0, 'languageCode': 'eng', 'forced': 0, 'key': '/subtitles/401'},
{
'streamType': '3',
'id': '401',
'index': '0',
'languageCode': 'eng',
'forced': 0,
'key': '/subtitles/401',
},
],
},
],