fix: select first playable Plex Part instead of always Part[0]

close #1201
This commit is contained in:
edde746
2026-05-30 21:37:01 +02:00
parent f5012b36ea
commit 9825ce3c7c
8 changed files with 182 additions and 22 deletions
+1 -1
View File
@@ -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;
+8 -2
View File
@@ -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 -4
View File
@@ -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) {
+42 -15
View File
@@ -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;
+21
View File
@@ -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', () {