fix(plex): tolerate scalar schema drift
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>))
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user