2026-05-02 06:33:05 +02:00
|
|
|
import '../media/media_file_info.dart';
|
|
|
|
|
import '../media/media_source_info.dart';
|
|
|
|
|
import '../media/media_version.dart';
|
|
|
|
|
import '../models/plex/plex_video_playback_data.dart';
|
2026-05-31 02:21:15 +02:00
|
|
|
import '../utils/app_logger.dart';
|
2026-05-02 06:33:05 +02:00
|
|
|
import '../utils/json_utils.dart';
|
|
|
|
|
import '../utils/plex_url_helper.dart';
|
|
|
|
|
import 'file_info_parser.dart';
|
|
|
|
|
import 'plex_mappers.dart';
|
|
|
|
|
|
|
|
|
|
const _streamReader = PlexFileInfoStreamReader();
|
|
|
|
|
|
2026-07-19 20:29:59 +02:00
|
|
|
void _logMalformedStream(Object error, StackTrace stackTrace, Map<String, dynamic> _) {
|
|
|
|
|
appLogger.w('Skipping malformed Plex stream metadata', error: error, stackTrace: stackTrace);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 21:37:01 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 02:21:15 +02:00
|
|
|
void _logPartSelection(
|
|
|
|
|
List<Map> mediaList,
|
|
|
|
|
List<MediaVersion> versions,
|
|
|
|
|
int selectedMediaIndex,
|
|
|
|
|
int selectedPartIndex,
|
|
|
|
|
) {
|
|
|
|
|
final candidateCount = mediaList.fold<int>(0, (count, media) => count + _mapList(media['Part']).length);
|
|
|
|
|
if (candidateCount <= 1) return;
|
|
|
|
|
|
|
|
|
|
final entries = <String>[];
|
|
|
|
|
for (var mediaIndex = 0; mediaIndex < mediaList.length; mediaIndex++) {
|
|
|
|
|
final partList = _mapList(mediaList[mediaIndex]['Part']);
|
|
|
|
|
for (var partIndex = 0; partIndex < partList.length; partIndex++) {
|
|
|
|
|
final part = partList[partIndex];
|
|
|
|
|
final versionPart = mediaIndex < versions.length && partIndex < versions[mediaIndex].parts.length
|
|
|
|
|
? versions[mediaIndex].parts[partIndex]
|
|
|
|
|
: null;
|
|
|
|
|
final selected = mediaIndex == selectedMediaIndex && partIndex == selectedPartIndex ? ' selected' : '';
|
|
|
|
|
entries.add(
|
|
|
|
|
'Media[$mediaIndex].Part[$partIndex] '
|
|
|
|
|
'id=${part['id']} key=${part['key']} '
|
|
|
|
|
'exists=${versionPart?.exists} accessible=${versionPart?.accessible} '
|
|
|
|
|
'playable=${versionPart?.isPlayable}$selected',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
appLogger.d('Plex playback part selection: ${entries.join('; ')}');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 06:33:05 +02:00
|
|
|
PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson(
|
|
|
|
|
Map<String, dynamic>? metadataJson, {
|
|
|
|
|
required String baseUrl,
|
|
|
|
|
required String? token,
|
|
|
|
|
int mediaIndex = 0,
|
2026-07-05 13:11:18 +02:00
|
|
|
String? selectedMediaSourceId,
|
|
|
|
|
String? preferredVersionSignature,
|
2026-05-02 06:33:05 +02:00
|
|
|
void Function(int requestedIndex, int fallbackIndex)? onVersionFallback,
|
|
|
|
|
}) {
|
|
|
|
|
String? videoUrl;
|
|
|
|
|
MediaSourceInfo? mediaInfo;
|
|
|
|
|
List<MediaVersion> availableVersions = [];
|
2026-05-30 21:37:01 +02:00
|
|
|
var selectedMediaIndex = 0;
|
|
|
|
|
var selectedPartIndex = 0;
|
2026-05-02 06:33:05 +02:00
|
|
|
|
|
|
|
|
if (metadataJson != null) {
|
2026-05-30 21:37:01 +02:00
|
|
|
final mediaList = _mapList(metadataJson['Media']);
|
|
|
|
|
if (mediaList.isNotEmpty) {
|
2026-05-02 06:33:05 +02:00
|
|
|
availableVersions = mediaList
|
2026-05-30 21:37:01 +02:00
|
|
|
.map((media) => PlexMappers.mediaVersionFromJson(Map<String, dynamic>.from(media)))
|
2026-05-02 06:33:05 +02:00
|
|
|
.toList();
|
|
|
|
|
|
2026-07-05 13:11:18 +02:00
|
|
|
// Re-resolve version evidence against this (authoritative) Media list:
|
|
|
|
|
// stable id first, then signature. The positional index is the last
|
|
|
|
|
// resort — and all an explicit user pick carries besides its id, so a
|
|
|
|
|
// saved-preference signature can never override one.
|
|
|
|
|
final requestedSourceId = selectedMediaSourceId?.trim();
|
|
|
|
|
var resolvedBySourceId = false;
|
|
|
|
|
if (requestedSourceId != null && requestedSourceId.isNotEmpty) {
|
|
|
|
|
final byId = availableVersions.indexWhere((v) => v.id == requestedSourceId);
|
|
|
|
|
if (byId >= 0) {
|
|
|
|
|
mediaIndex = byId;
|
|
|
|
|
resolvedBySourceId = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!resolvedBySourceId && preferredVersionSignature != null && preferredVersionSignature.isNotEmpty) {
|
|
|
|
|
final bySignature = MediaVersion.findMatchingIndex(availableVersions, {preferredVersionSignature});
|
|
|
|
|
if (bySignature != null) mediaIndex = bySignature;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 06:33:05 +02:00
|
|
|
if (mediaIndex < 0 || mediaIndex >= mediaList.length) {
|
|
|
|
|
mediaIndex = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!availableVersions[mediaIndex].isPlayable) {
|
|
|
|
|
final fallback = availableVersions.indexWhere((v) => v.isPlayable);
|
|
|
|
|
if (fallback >= 0) {
|
|
|
|
|
onVersionFallback?.call(mediaIndex, fallback);
|
|
|
|
|
mediaIndex = fallback;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 21:37:01 +02:00
|
|
|
selectedMediaIndex = mediaIndex;
|
2026-05-02 06:33:05 +02:00
|
|
|
final media = mediaList[mediaIndex];
|
2026-05-30 21:37:01 +02:00
|
|
|
final partList = _mapList(media['Part']);
|
|
|
|
|
if (partList.isNotEmpty) {
|
|
|
|
|
selectedPartIndex = _firstPlayablePartIndex(availableVersions[mediaIndex]);
|
|
|
|
|
if (selectedPartIndex < 0 || selectedPartIndex >= partList.length) selectedPartIndex = 0;
|
2026-05-31 02:21:15 +02:00
|
|
|
_logPartSelection(mediaList, availableVersions, selectedMediaIndex, selectedPartIndex);
|
2026-05-30 21:37:01 +02:00
|
|
|
final part = partList[selectedPartIndex];
|
|
|
|
|
final partKey = part['key']?.toString();
|
2026-05-02 06:33:05 +02:00
|
|
|
|
|
|
|
|
if (partKey != null) {
|
|
|
|
|
videoUrl = '$baseUrl$partKey'.withPlexToken(token);
|
|
|
|
|
|
2026-07-19 20:29:59 +02:00
|
|
|
final streams = walkStreams(flexibleList(part['Stream']), _streamReader, onMalformed: _logMalformedStream);
|
2026-05-02 06:33:05 +02:00
|
|
|
final chapters = plexChaptersFromCacheJson(metadataJson);
|
|
|
|
|
|
|
|
|
|
mediaInfo = MediaSourceInfo(
|
|
|
|
|
videoUrl: videoUrl,
|
|
|
|
|
audioTracks: streams.audioTracks,
|
|
|
|
|
subtitleTracks: streams.subtitleTracks,
|
|
|
|
|
chapters: chapters,
|
2026-05-30 21:37:01 +02:00
|
|
|
partId: flexibleInt(part['id']),
|
2026-07-23 02:18:03 +02:00
|
|
|
mediaSourceId: availableVersions[mediaIndex].id,
|
2026-05-30 21:37:01 +02:00
|
|
|
displayCriteria: PlexMappers.displayCriteriaFromJson(Map<String, dynamic>.from(media), streams.videoStream),
|
2026-07-12 00:36:59 +02:00
|
|
|
videoAspectRatio: flexibleDouble(media['aspectRatio']),
|
2026-05-02 06:33:05 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return PlexVideoPlaybackData(
|
|
|
|
|
videoUrl: videoUrl,
|
|
|
|
|
mediaInfo: mediaInfo,
|
|
|
|
|
availableVersions: availableVersions,
|
2026-05-30 21:37:01 +02:00
|
|
|
selectedMediaIndex: selectedMediaIndex,
|
|
|
|
|
selectedPartIndex: selectedPartIndex,
|
2026-05-02 06:33:05 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 05:18:56 +02:00
|
|
|
/// Build the file-info payload from a `/library/metadata/{id}` response.
|
|
|
|
|
///
|
|
|
|
|
/// Every `Media` (version) and every `Part` (file) is mapped — split files and
|
|
|
|
|
/// multi-version libraries both produce more than one, and the sheet shows all
|
|
|
|
|
/// of them.
|
2026-05-02 06:33:05 +02:00
|
|
|
MediaFileInfo? parsePlexFileInfoFromJson(Map<String, dynamic>? metadataJson) {
|
2026-05-30 21:37:01 +02:00
|
|
|
final mediaList = _mapList(metadataJson?['Media']);
|
2026-07-29 05:18:56 +02:00
|
|
|
if (mediaList.isEmpty) return null;
|
|
|
|
|
|
|
|
|
|
final versions = <MediaFileVersion>[
|
|
|
|
|
for (final media in mediaList) _plexFileVersion(Map<String, dynamic>.from(media)),
|
|
|
|
|
];
|
|
|
|
|
return MediaFileInfo(versions: versions);
|
|
|
|
|
}
|
2026-05-02 06:33:05 +02:00
|
|
|
|
2026-07-29 05:18:56 +02:00
|
|
|
/// `Media.proxyType` value Plex uses for an optimized version.
|
|
|
|
|
const _plexOptimizedProxyType = 42;
|
|
|
|
|
|
|
|
|
|
DateTime? _plexEpochSeconds(Object? value) {
|
|
|
|
|
final seconds = flexibleInt(value);
|
|
|
|
|
return seconds == null ? null : DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
MediaFileVersion _plexFileVersion(Map<String, dynamic> media) {
|
|
|
|
|
return MediaFileVersion(
|
|
|
|
|
id: media['id']?.toString(),
|
|
|
|
|
title: media['title'] as String?,
|
|
|
|
|
container: media['container'] as String?,
|
|
|
|
|
bitrateKbps: flexibleInt(media['bitrate']),
|
|
|
|
|
durationMs: flexibleInt(media['duration']),
|
|
|
|
|
width: flexibleInt(media['width']),
|
|
|
|
|
height: flexibleInt(media['height']),
|
|
|
|
|
aspectRatio: flexibleDouble(media['aspectRatio']),
|
|
|
|
|
videoResolutionLabel: media['videoResolution'] as String?,
|
|
|
|
|
videoCodec: media['videoCodec'] as String?,
|
|
|
|
|
videoProfile: media['videoProfile'] as String?,
|
|
|
|
|
videoFrameRateLabel: media['videoFrameRate'] as String?,
|
|
|
|
|
audioCodec: media['audioCodec'] as String?,
|
|
|
|
|
audioProfile: media['audioProfile'] as String?,
|
|
|
|
|
audioChannels: flexibleInt(media['audioChannels']),
|
|
|
|
|
optimizedForStreaming: flexibleBoolNullable(media['optimizedForStreaming']),
|
|
|
|
|
has64bitOffsets: flexibleBoolNullable(media['has64bitOffsets']),
|
|
|
|
|
// Plex marks a server-generated optimized copy with proxyType 42 and
|
|
|
|
|
// names the profile that produced it in `target`.
|
|
|
|
|
isOptimizedVersion: media['proxyType'] == null ? null : flexibleInt(media['proxyType']) == _plexOptimizedProxyType,
|
|
|
|
|
optimizationTarget: media['target'] as String?,
|
|
|
|
|
deletedAt: _plexEpochSeconds(media['deletedAt']),
|
|
|
|
|
displayOffsetPercent: flexibleInt(media['displayOffset']),
|
|
|
|
|
parts: [for (final part in _mapList(media['Part'])) _plexFilePart(Map<String, dynamic>.from(part))],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
MediaFilePart _plexFilePart(Map<String, dynamic> part) {
|
|
|
|
|
return MediaFilePart(
|
|
|
|
|
id: part['id']?.toString(),
|
|
|
|
|
filePath: part['file'] as String?,
|
|
|
|
|
fileSize: flexibleInt(part['size']),
|
|
|
|
|
container: part['container'] as String?,
|
|
|
|
|
durationMs: flexibleInt(part['duration']),
|
|
|
|
|
optimizedForStreaming: flexibleBoolNullable(part['optimizedForStreaming']),
|
|
|
|
|
has64bitOffsets: flexibleBoolNullable(part['has64bitOffsets']),
|
|
|
|
|
hasThumbnail: flexibleBoolNullable(part['hasThumbnail']),
|
|
|
|
|
indexes: part['indexes'] as String?,
|
|
|
|
|
packetLength: flexibleInt(part['packetLength']),
|
|
|
|
|
previewFailureCode: flexibleInt(part['failureBIFResultCode']),
|
|
|
|
|
previewRetryCount: flexibleInt(part['failureBIFRetryCount']),
|
|
|
|
|
exists: flexibleBoolNullable(part['exists']),
|
|
|
|
|
accessible: flexibleBoolNullable(part['accessible']),
|
|
|
|
|
streamKey: part['key'] as String?,
|
|
|
|
|
streams: _plexStreamDetails(part['Stream']),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One malformed stream must not discard the rest of the table, so each entry
|
|
|
|
|
/// is projected independently.
|
|
|
|
|
List<MediaStreamDetails> _plexStreamDetails(Object? rawStreams) {
|
|
|
|
|
final streams = <MediaStreamDetails>[];
|
|
|
|
|
final ordinals = <MediaStreamKind, int>{};
|
|
|
|
|
for (final raw in _mapList(rawStreams)) {
|
|
|
|
|
try {
|
|
|
|
|
final stream = Map<String, dynamic>.from(raw);
|
|
|
|
|
final kind = plexStreamKind(stream);
|
|
|
|
|
final ordinal = (ordinals[kind] ?? 0) + 1;
|
|
|
|
|
ordinals[kind] = ordinal;
|
|
|
|
|
streams.add(plexStreamDetails(stream, ordinal));
|
|
|
|
|
} catch (error, stackTrace) {
|
|
|
|
|
_logMalformedStream(error, stackTrace, const <String, dynamic>{});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return streams;
|
2026-05-02 06:33:05 +02:00
|
|
|
}
|