fix: select first playable Plex Part instead of always Part[0]
close #1201
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -12,11 +12,17 @@ class PlexVideoPlaybackData {
|
||||
|
||||
final List<MediaMarker> 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;
|
||||
|
||||
@@ -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<String, String> _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<String, String> 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!,
|
||||
|
||||
@@ -48,10 +48,17 @@ Map<String, dynamic> _obfuscatePlaylistJson(Map<String, dynamic> json) {
|
||||
int _flexibleIntOrZero(Object? v) => flexibleInt(v) ?? 0;
|
||||
|
||||
Map? _firstPartMap(Object? raw) {
|
||||
final parts = _partMaps(raw);
|
||||
return parts.isEmpty ? null : parts.first;
|
||||
}
|
||||
|
||||
List<Map> _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<MediaPart> _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<String, dynamic> 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<String, dynamic>? media, Map<String, dynamic>? videoStream) {
|
||||
|
||||
@@ -9,6 +9,22 @@ import 'plex_mappers.dart';
|
||||
|
||||
const _streamReader = PlexFileInfoStreamReader();
|
||||
|
||||
List<Map> _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<String, dynamic>? metadataJson, {
|
||||
required String baseUrl,
|
||||
@@ -19,14 +35,15 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson(
|
||||
String? videoUrl;
|
||||
MediaSourceInfo? mediaInfo;
|
||||
List<MediaVersion> 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<String, dynamic>))
|
||||
.map((media) => PlexMappers.mediaVersionFromJson(Map<String, dynamic>.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<dynamic>?, _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<String, dynamic>?, streams.videoStream),
|
||||
videoAspectRatio: ((media as Map?)?['aspectRatio'] as num?)?.toDouble(),
|
||||
partId: flexibleInt(part['id']),
|
||||
displayCriteria: PlexMappers.displayCriteriaFromJson(Map<String, dynamic>.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<String, dynamic>? 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<String, dynamic>.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<dynamic>?, _streamReader);
|
||||
final parsedTracks = walkStreams(flexibleList(part?['Stream']), _streamReader);
|
||||
final videoStream = parsedTracks.videoStream;
|
||||
final audioStream = parsedTracks.audioStream;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user