diff --git a/lib/media/media_version.dart b/lib/media/media_version.dart index 0c36daa0..2993f0fa 100644 --- a/lib/media/media_version.dart +++ b/lib/media/media_version.dart @@ -65,7 +65,7 @@ class MediaVersion { /// Defaults to true when file-access fields are absent. Plex only populates /// them when metadata is fetched with `checkFiles=1`. - bool get isPlayable => parts.isEmpty || parts.first.isPlayable; + bool get isPlayable => parts.isEmpty || parts.any((part) => part.isPlayable); /// Display label with detailed information: "1080p H.264 MKV (8.5 Mbps)". /// When [name] is set, it prefixes the technical label so a user can tell diff --git a/lib/models/plex/plex_video_playback_data.dart b/lib/models/plex/plex_video_playback_data.dart index 24fc7742..23febc2d 100644 --- a/lib/models/plex/plex_video_playback_data.dart +++ b/lib/models/plex/plex_video_playback_data.dart @@ -12,11 +12,17 @@ class PlexVideoPlaybackData { final List markers; + final int selectedMediaIndex; + + final int selectedPartIndex; + PlexVideoPlaybackData({ required this.videoUrl, required this.mediaInfo, required this.availableVersions, this.markers = const [], + this.selectedMediaIndex = 0, + this.selectedPartIndex = 0, }); bool get hasValidVideoUrl => videoUrl != null && videoUrl!.isNotEmpty; diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 46aa6a76..161e6a46 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -2877,6 +2877,7 @@ class PlexClient Future<({String? startPath, TranscodeDecisionOutcome outcome})> buildTranscodeStartPath({ required String ratingKey, required int mediaIndex, + int partIndex = 0, required TranscodeQualityPreset preset, required String sessionIdentifier, required String transcodeSessionId, @@ -2888,6 +2889,7 @@ class PlexClient final allParams = _buildTranscodeParams( ratingKey: ratingKey, mediaIndex: mediaIndex, + partIndex: partIndex, preset: preset, sessionIdentifier: sessionIdentifier, transcodeSessionId: transcodeSessionId, @@ -2947,6 +2949,7 @@ class PlexClient Map _buildTranscodeParams({ required String ratingKey, required int mediaIndex, + int partIndex = 0, required TranscodeQualityPreset preset, required String sessionIdentifier, required String transcodeSessionId, @@ -2997,7 +3000,7 @@ class PlexClient 'hasMDE': '1', 'path': '/library/metadata/$ratingKey', 'mediaIndex': mediaIndex.toString(), - 'partIndex': '0', + 'partIndex': partIndex.toString(), 'protocol': 'http', 'fastSeek': '1', 'directPlay': isOriginal ? '1' : '0', @@ -3047,6 +3050,7 @@ class PlexClient Map buildTranscodeParamsForTesting({ required String ratingKey, required int mediaIndex, + int partIndex = 0, required TranscodeQualityPreset preset, required String sessionIdentifier, required String transcodeSessionId, @@ -3057,6 +3061,7 @@ class PlexClient return _buildTranscodeParams( ratingKey: ratingKey, mediaIndex: mediaIndex, + partIndex: partIndex, preset: preset, sessionIdentifier: sessionIdentifier, transcodeSessionId: transcodeSessionId, @@ -3257,7 +3262,8 @@ class PlexClient final selectedSubtitleTrack = _selectedSubtitleTrack(data.mediaInfo); final result = await buildTranscodeStartPath( ratingKey: options.metadata.id, - mediaIndex: options.selectedMediaIndex, + mediaIndex: data.selectedMediaIndex, + partIndex: data.selectedPartIndex, preset: options.qualityPreset, sessionIdentifier: options.sessionIdentifier!, transcodeSessionId: options.transcodeSessionId!, diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart index 46345b8d..bc801127 100644 --- a/lib/services/plex_mappers.dart +++ b/lib/services/plex_mappers.dart @@ -48,10 +48,17 @@ Map _obfuscatePlaylistJson(Map json) { int _flexibleIntOrZero(Object? v) => flexibleInt(v) ?? 0; Map? _firstPartMap(Object? raw) { + final parts = _partMaps(raw); + return parts.isEmpty ? null : parts.first; +} + +List _partMaps(Object? raw) { final parts = flexibleList(raw); - if (parts == null || parts.isEmpty) return null; - final part = parts.first; - return part is Map ? part : null; + if (parts == null || parts.isEmpty) return const []; + return [ + for (final part in parts) + if (part is Map) part, + ]; } String _partKeyFromJson(Object? raw) => _firstPartMap(raw)?['key']?.toString() ?? ''; @@ -60,6 +67,31 @@ bool? _partAccessibleFromJson(Object? raw) => flexibleBoolNullable(_firstPartMap bool? _partExistsFromJson(Object? raw) => flexibleBoolNullable(_firstPartMap(raw)?['exists']); +MediaPart _mediaPartFromMap(Map json, {required String fallbackId, String? fallbackContainer}) { + return MediaPart( + id: (json['id'] ?? fallbackId).toString(), + streamPath: json['key']?.toString(), + sizeBytes: flexibleInt(json['size']), + container: json['container']?.toString() ?? fallbackContainer, + durationMs: flexibleInt(json['duration']), + accessible: flexibleBoolNullable(json['accessible']), + exists: flexibleBoolNullable(json['exists']), + ); +} + +List _mediaPartsFromJson(Object? raw, {required String fallbackId, String? fallbackContainer}) { + final partMaps = _partMaps(raw); + if (partMaps.isEmpty) return const []; + return [ + for (var i = 0; i < partMaps.length; i++) + _mediaPartFromMap( + partMaps[i], + fallbackId: i == 0 ? fallbackId : '$fallbackId:$i', + fallbackContainer: fallbackContainer, + ), + ]; +} + Object? _readPartKey(Map json, String _) => _partKeyFromJson(json['Part']); Object? _readPartAccessible(Map json, String _) => _partAccessibleFromJson(json['Part']); @@ -886,7 +918,19 @@ class PlexMappers { /// Map a Plex Media JSON entry directly into a [MediaVersion]. static MediaVersion mediaVersionFromJson(Map json) { - return mediaVersion(PlexMediaVersionDto.fromJson(json)); + final dto = PlexMediaVersionDto.fromJson(json); + final parts = _mediaPartsFromJson(json['Part'], fallbackId: dto.id.toString(), fallbackContainer: dto.container); + if (parts.isEmpty) return mediaVersion(dto); + return MediaVersion( + id: dto.id.toString(), + width: dto.width, + height: dto.height, + videoResolution: dto.videoResolution, + videoCodec: dto.videoCodec, + bitrate: dto.bitrate, + container: dto.container, + parts: parts, + ); } static MediaDisplayCriteria? displayCriteriaFromJson(Map? media, Map? videoStream) { diff --git a/lib/services/plex_playback_mapper.dart b/lib/services/plex_playback_mapper.dart index d6888038..c66b4d05 100644 --- a/lib/services/plex_playback_mapper.dart +++ b/lib/services/plex_playback_mapper.dart @@ -9,6 +9,22 @@ import 'plex_mappers.dart'; const _streamReader = PlexFileInfoStreamReader(); +List _mapList(Object? raw) { + final values = flexibleList(raw); + if (values == null || values.isEmpty) return const []; + return [ + for (final value in values) + if (value is Map) value, + ]; +} + +int _firstPlayablePartIndex(MediaVersion version) { + final parts = version.parts; + if (parts.isEmpty) return 0; + final playable = parts.indexWhere((part) => part.isPlayable); + return playable >= 0 ? playable : 0; +} + PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson( Map? metadataJson, { required String baseUrl, @@ -19,14 +35,15 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson( String? videoUrl; MediaSourceInfo? mediaInfo; List availableVersions = []; + var selectedMediaIndex = 0; + var selectedPartIndex = 0; final markers = plexMarkersFromCacheJson(metadataJson); if (metadataJson != null) { - if (metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { - final mediaList = metadataJson['Media'] as List; - + final mediaList = _mapList(metadataJson['Media']); + if (mediaList.isNotEmpty) { availableVersions = mediaList - .map((media) => PlexMappers.mediaVersionFromJson(media as Map)) + .map((media) => PlexMappers.mediaVersionFromJson(Map.from(media))) .toList(); if (mediaIndex < 0 || mediaIndex >= mediaList.length) { @@ -41,15 +58,19 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson( } } + selectedMediaIndex = mediaIndex; final media = mediaList[mediaIndex]; - if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { - final part = media['Part'][0]; - final partKey = part['key'] as String?; + final partList = _mapList(media['Part']); + if (partList.isNotEmpty) { + selectedPartIndex = _firstPlayablePartIndex(availableVersions[mediaIndex]); + if (selectedPartIndex < 0 || selectedPartIndex >= partList.length) selectedPartIndex = 0; + final part = partList[selectedPartIndex]; + final partKey = part['key']?.toString(); if (partKey != null) { videoUrl = '$baseUrl$partKey'.withPlexToken(token); - final streams = walkStreams(part['Stream'] as List?, _streamReader); + final streams = walkStreams(flexibleList(part['Stream']), _streamReader); final chapters = plexChaptersFromCacheJson(metadataJson); mediaInfo = MediaSourceInfo( @@ -57,9 +78,9 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson( audioTracks: streams.audioTracks, subtitleTracks: streams.subtitleTracks, chapters: chapters, - partId: part['id'] as int?, - displayCriteria: PlexMappers.displayCriteriaFromJson(media as Map?, streams.videoStream), - videoAspectRatio: ((media as Map?)?['aspectRatio'] as num?)?.toDouble(), + partId: flexibleInt(part['id']), + displayCriteria: PlexMappers.displayCriteriaFromJson(Map.from(media), streams.videoStream), + videoAspectRatio: (media['aspectRatio'] as num?)?.toDouble(), ); } } @@ -71,18 +92,24 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson( mediaInfo: mediaInfo, availableVersions: availableVersions, markers: markers, + selectedMediaIndex: selectedMediaIndex, + selectedPartIndex: selectedPartIndex, ); } MediaFileInfo? parsePlexFileInfoFromJson(Map? metadataJson) { - if (metadataJson != null && metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { - final media = metadataJson['Media'][0]; - final part = media['Part'] != null && (media['Part'] as List).isNotEmpty ? media['Part'][0] : null; + final mediaList = _mapList(metadataJson?['Media']); + if (mediaList.isNotEmpty) { + final media = mediaList[0]; + final partList = _mapList(media['Part']); + final version = PlexMappers.mediaVersionFromJson(Map.from(media)); + final partIndex = partList.isEmpty ? 0 : _firstPlayablePartIndex(version).clamp(0, partList.length - 1).toInt(); + final part = partList.isNotEmpty ? partList[partIndex] : null; // One pass over the streams array, capturing both the raw video / audio // map pointers (for fields the parsed track classes don't carry — // colorSpace, bitDepth, …) and the parsed track lists. - final parsedTracks = walkStreams(part?['Stream'] as List?, _streamReader); + final parsedTracks = walkStreams(flexibleList(part?['Stream']), _streamReader); final videoStream = parsedTracks.videoStream; final audioStream = parsedTracks.audioStream; diff --git a/test/models/plex_media_version_test.dart b/test/models/plex_media_version_test.dart index a401cac4..08476556 100644 --- a/test/models/plex_media_version_test.dart +++ b/test/models/plex_media_version_test.dart @@ -62,6 +62,27 @@ void main() { expect(v.parts.single.accessible, isTrue); }); + test('maps multiple parts and treats later playable parts as playable', () { + final v = PlexMappers.mediaVersionFromJson({ + 'id': 1, + 'videoResolution': '1080', + 'videoCodec': 'h264', + 'container': 'mkv', + 'Part': [ + {'id': 101, 'key': '/library/parts/101/file.mkv', 'exists': 0, 'accessible': 1}, + {'id': 102, 'key': '/library/parts/102/file.mkv', 'exists': 1, 'accessible': 1, 'size': '123'}, + ], + }); + + expect(v.parts, hasLength(2)); + expect(v.parts.first.streamPath, '/library/parts/101/file.mkv'); + expect(v.parts.last.streamPath, '/library/parts/102/file.mkv'); + expect(v.parts.last.sizeBytes, 123); + expect(v.parts.first.isPlayable, isFalse); + expect(v.parts.last.isPlayable, isTrue); + expect(v.isPlayable, isTrue); + }); + test('isPlayable truth table mirrors Plex web semantics', () { // Mirrors plex-web.js:28926: !1 !== e.exists && !1 !== e.accessible // Anything but explicit `false` for both fields → playable. diff --git a/test/services/plex_playback_data_request_test.dart b/test/services/plex_playback_data_request_test.dart index 0561a74b..3960ee12 100644 --- a/test/services/plex_playback_data_request_test.dart +++ b/test/services/plex_playback_data_request_test.dart @@ -220,6 +220,23 @@ void main() { expect(startPath, isNot(contains('X-Plex-Token'))); }); + test('transcode params preserve resolved media and part indices', () { + final client = makeClient((_) async => http.Response('not used', 500)); + addTearDown(client.close); + + final params = client.buildTranscodeParamsForTesting( + ratingKey: '42', + mediaIndex: 1, + partIndex: 2, + preset: TranscodeQualityPreset.p720_3mbps, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + ); + + expect(params['mediaIndex'], '1'); + expect(params['partIndex'], '2'); + }); + test('unsupported embedded subtitles keep main transcode subtitles disabled', () { final client = makeClient((_) async => http.Response('not used', 500)); addTearDown(client.close); diff --git a/test/services/plex_playback_mapper_test.dart b/test/services/plex_playback_mapper_test.dart index 029eb213..1fda8e42 100644 --- a/test/services/plex_playback_mapper_test.dart +++ b/test/services/plex_playback_mapper_test.dart @@ -46,6 +46,45 @@ void main() { expect(result.mediaInfo?.partId, 20); expect(result.mediaInfo?.displayCriteria?.fps, 23.976); expect(result.mediaInfo?.audioTracks.single.languageCode, 'eng'); + expect(result.selectedMediaIndex, 1); + expect(result.selectedPartIndex, 0); + }); + + test('uses playable part when the first part is unavailable', () { + final result = parsePlexVideoPlaybackDataFromJson( + { + 'Media': [ + { + 'id': 1, + 'videoResolution': '1080', + 'Part': [ + {'id': 10, 'key': '/library/parts/10/file.mkv', 'accessible': 0, 'exists': 1}, + { + 'id': 20, + 'key': '/library/parts/20/file.mkv', + 'accessible': 1, + 'exists': 1, + 'Stream': [ + {'streamType': 1, 'frameRate': 24}, + {'streamType': 2, 'id': 201, 'index': 0, 'languageCode': 'eng', 'selected': 1}, + ], + }, + ], + }, + ], + }, + baseUrl: 'http://plex:32400', + token: 'tok', + ); + + expect(result.videoUrl, 'http://plex:32400/library/parts/20/file.mkv?X-Plex-Token=tok'); + expect(result.selectedMediaIndex, 0); + expect(result.selectedPartIndex, 1); + expect(result.mediaInfo?.partId, 20); + expect(result.mediaInfo?.displayCriteria?.fps, 24); + expect(result.availableVersions.single.parts, hasLength(2)); + expect(result.availableVersions.single.parts.first.isPlayable, isFalse); + expect(result.availableVersions.single.parts.last.isPlayable, isTrue); }); test('maps server display criteria from selected video stream', () {