diff --git a/lib/models/plex/plex_home.dart b/lib/models/plex/plex_home.dart index 16bf47fe..dcccfb8f 100644 --- a/lib/models/plex/plex_home.dart +++ b/lib/models/plex/plex_home.dart @@ -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: []) final List users; @@ -50,3 +52,5 @@ class PlexHome { bool get hasMultipleUsers => users.length > 1; } + +int _intOr0(Object? value) => flexibleInt(value) ?? 0; diff --git a/lib/models/plex/plex_home.g.dart b/lib/models/plex/plex_home.g.dart index 43d9690e..8bad821b 100644 --- a/lib/models/plex/plex_home.g.dart +++ b/lib/models/plex/plex_home.g.dart @@ -7,12 +7,12 @@ part of 'plex_home.dart'; // ************************************************************************** PlexHome _$PlexHomeFromJson(Map 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?) ?.map((e) => PlexHomeUser.fromJson(e as Map)) diff --git a/lib/services/file_info_parser.dart b/lib/services/file_info_parser.dart index ce8b5f35..6ce6186e 100644 --- a/lib/services/file_info_parser.dart +++ b/lib/services/file_info_parser.dart @@ -144,8 +144,7 @@ class PlexFileInfoStreamReader implements FileInfoStreamReader { @override FileInfoStreamType? typeOf(Map 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 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 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?, diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 094be8c1..6766b9b3 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -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; diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 66a46e64..e3509972 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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, ); } diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart index 98e33020..c882ea85 100644 --- a/lib/services/plex_mappers.dart +++ b/lib/services/plex_mappers.dart @@ -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; diff --git a/lib/services/plex_mappers.g.dart b/lib/services/plex_mappers.g.dart index 00394139..7495e63e 100644 --- a/lib/services/plex_mappers.g.dart +++ b/lib/services/plex_mappers.g.dart @@ -89,9 +89,9 @@ PlexMetadataDto _$PlexMetadataDtoFromJson(Map 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?, diff --git a/lib/services/plex_playback_mapper.dart b/lib/services/plex_playback_mapper.dart index d5c9569e..c1e878f3 100644 --- a/lib/services/plex_playback_mapper.dart +++ b/lib/services/plex_playback_mapper.dart @@ -132,7 +132,7 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson( chapters: chapters, partId: flexibleInt(part['id']), displayCriteria: PlexMappers.displayCriteriaFromJson(Map.from(media), streams.videoStream), - videoAspectRatio: (media['aspectRatio'] as num?)?.toDouble(), + videoAspectRatio: flexibleDouble(media['aspectRatio']), ); } } @@ -172,27 +172,27 @@ MediaFileInfo? parsePlexFileInfoFromJson(Map? 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 diff --git a/test/models/plex_home_test.dart b/test/models/plex_home_test.dart new file mode 100644 index 00000000..379c6c73 --- /dev/null +++ b/test/models/plex_home_test.dart @@ -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': [], + }); + + expect(home.id, 7); + expect(home.name, '42'); + expect(home.guestUserID, 9); + expect(home.guestUserUUID, '123'); + expect(home.guestEnabled, isTrue); + expect(home.subscription, isTrue); + }); +} diff --git a/test/services/file_info_parser_test.dart b/test/services/file_info_parser_test.dart index dbbe34cd..b9299501 100644 --- a/test/services/file_info_parser_test.dart +++ b/test/services/file_info_parser_test.dart @@ -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); diff --git a/test/services/plex_auth_service_test.dart b/test/services/plex_auth_service_test.dart index cda88442..ea1dd78c 100644 --- a/test/services/plex_auth_service_test.dart +++ b/test/services/plex_auth_service_test.dart @@ -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 + ..['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); + }); }); } diff --git a/test/services/plex_client_http_contract_test.dart b/test/services/plex_client_http_contract_test.dart index d941ffa6..ce092344 100644 --- a/test/services/plex_client_http_contract_test.dart +++ b/test/services/plex_client_http_contract_test.dart @@ -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': [], + }, + }), + 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); + }); } diff --git a/test/services/plex_mappers_test.dart b/test/services/plex_mappers_test.dart index 961d05f6..daa10c35 100644 --- a/test/services/plex_mappers_test.dart +++ b/test/services/plex_mappers_test.dart @@ -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 = { diff --git a/test/services/plex_playback_mapper_test.dart b/test/services/plex_playback_mapper_test.dart index c6f74faf..65415b6f 100644 --- a/test/services/plex_playback_mapper_test.dart +++ b/test/services/plex_playback_mapper_test.dart @@ -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', + }, ], }, ],