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'; import '../utils/app_logger.dart'; import '../utils/json_utils.dart'; import '../utils/plex_url_helper.dart'; import 'file_info_parser.dart'; import 'plex_mappers.dart'; const _streamReader = PlexFileInfoStreamReader(); void _logMalformedStream(Object error, StackTrace stackTrace, Map _) { appLogger.w('Skipping malformed Plex stream metadata', error: error, stackTrace: stackTrace); } 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; } void _logPartSelection( List mediaList, List versions, int selectedMediaIndex, int selectedPartIndex, ) { final candidateCount = mediaList.fold(0, (count, media) => count + _mapList(media['Part']).length); if (candidateCount <= 1) return; final entries = []; 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('; ')}'); } PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson( Map? metadataJson, { required String baseUrl, required String? token, int mediaIndex = 0, String? selectedMediaSourceId, String? preferredVersionSignature, void Function(int requestedIndex, int fallbackIndex)? onVersionFallback, }) { String? videoUrl; MediaSourceInfo? mediaInfo; List availableVersions = []; var selectedMediaIndex = 0; var selectedPartIndex = 0; final markers = plexMarkersFromCacheJson(metadataJson); if (metadataJson != null) { final mediaList = _mapList(metadataJson['Media']); if (mediaList.isNotEmpty) { availableVersions = mediaList .map((media) => PlexMappers.mediaVersionFromJson(Map.from(media))) .toList(); // 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; } 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; } } selectedMediaIndex = mediaIndex; final media = mediaList[mediaIndex]; final partList = _mapList(media['Part']); if (partList.isNotEmpty) { selectedPartIndex = _firstPlayablePartIndex(availableVersions[mediaIndex]); if (selectedPartIndex < 0 || selectedPartIndex >= partList.length) selectedPartIndex = 0; _logPartSelection(mediaList, availableVersions, selectedMediaIndex, selectedPartIndex); final part = partList[selectedPartIndex]; final partKey = part['key']?.toString(); if (partKey != null) { videoUrl = '$baseUrl$partKey'.withPlexToken(token); final streams = walkStreams(flexibleList(part['Stream']), _streamReader, onMalformed: _logMalformedStream); final chapters = plexChaptersFromCacheJson(metadataJson); mediaInfo = MediaSourceInfo( videoUrl: videoUrl, audioTracks: streams.audioTracks, subtitleTracks: streams.subtitleTracks, chapters: chapters, partId: flexibleInt(part['id']), mediaSourceId: availableVersions[mediaIndex].id, displayCriteria: PlexMappers.displayCriteriaFromJson(Map.from(media), streams.videoStream), videoAspectRatio: flexibleDouble(media['aspectRatio']), ); } } } } return PlexVideoPlaybackData( videoUrl: videoUrl, mediaInfo: mediaInfo, availableVersions: availableVersions, markers: markers, selectedMediaIndex: selectedMediaIndex, selectedPartIndex: selectedPartIndex, ); } MediaFileInfo? parsePlexFileInfoFromJson(Map? metadataJson) { final mediaList = _mapList(metadataJson?['Media']); if (mediaList.isNotEmpty) { final media = mediaList.first; 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(flexibleList(part?['Stream']), _streamReader, onMalformed: _logMalformedStream); final videoStream = parsedTracks.videoStream; final audioStream = parsedTracks.audioStream; return MediaFileInfo( // Media level properties container: media['container'] as String?, videoCodec: media['videoCodec'] as String?, videoResolution: media['videoResolution'] as String?, videoFrameRate: media['videoFrameRate'] as String?, videoProfile: media['videoProfile'] as String?, 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: flexibleInt(media['audioChannels']), optimizedForStreaming: flexibleBool(media['optimizedForStreaming']), has64bitOffsets: flexibleBool(media['has64bitOffsets']), // Part level properties (file) filePath: part?['file'] as String?, 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: flexibleDouble(videoStream?['frameRate']), bitDepth: flexibleInt(videoStream?['bitDepth']), videoBitrate: flexibleInt(videoStream?['bitrate']), // Audio stream details audioChannelLayout: audioStream?['audioChannelLayout'] as String?, // All audio and subtitle tracks audioTracks: parsedTracks.audioTracks, subtitleTracks: parsedTracks.subtitleTracks, ); } return null; }