diff --git a/lib/services/file_info_parser.dart b/lib/services/file_info_parser.dart index 6b8e2998..fa56e1ca 100644 --- a/lib/services/file_info_parser.dart +++ b/lib/services/file_info_parser.dart @@ -10,6 +10,43 @@ import 'plex_constants.dart'; /// the four-tuple result. enum FileInfoStreamType { video, audio, subtitle } +/// Normalised projection of a single entry in Jellyfin's `MediaStreams` array. +/// Callers build their own typed output from this shared extraction so the +/// field-name parsing only lives in one place. +typedef JellyfinStreamFields = ({ + String? type, + int index, + String? codec, + String? language, + String? languageCode, + String? title, + String? displayTitle, + bool isDefault, + bool isForced, + bool isExternal, + String? deliveryUrl, + int? channels, + double? frameRate, +}); + +JellyfinStreamFields parseJellyfinStreamFields(Map s, {int fallbackIndex = 0}) { + return ( + type: (s['Type'] as String?)?.toLowerCase(), + index: flexibleInt(s['Index']) ?? fallbackIndex, + codec: s['Codec'] as String?, + language: s['DisplayLanguage'] as String? ?? s['Language'] as String?, + languageCode: s['Language'] as String?, + title: s['Title'] as String?, + displayTitle: s['DisplayTitle'] as String?, + isDefault: s['IsDefault'] as bool? ?? false, + isForced: s['IsForced'] as bool? ?? false, + isExternal: s['IsExternal'] as bool? ?? false, + deliveryUrl: s['DeliveryUrl'] as String?, + channels: flexibleInt(s['Channels']), + frameRate: flexibleDouble(s['RealFrameRate']) ?? flexibleDouble(s['AverageFrameRate']), + ); +} + /// Single-pass result of walking a streams array. Keeps both the raw /// `videoStream` / `audioStream` map pointers (for callers that need to dig /// out keys the parsed track classes don't carry — e.g. `colorSpace`, @@ -58,11 +95,17 @@ abstract class FileInfoStreamReader { double? frameRateOf(Map videoStream); } +typedef MalformedStreamHandler = void Function(Object error, StackTrace stackTrace, Map stream); + /// Walk [streams] in a single pass. Captures the first video / audio entries /// (later ones are ignored — both backends serve a single primary track per /// type), accumulates *all* audio / subtitle tracks for selection UIs, and /// extracts the frame rate from the video entry. -FileInfoStreams walkStreams(List? streams, FileInfoStreamReader reader) { +FileInfoStreams walkStreams( + List? streams, + FileInfoStreamReader reader, { + MalformedStreamHandler? onMalformed, +}) { if (streams == null || streams.isEmpty) return FileInfoStreams.empty; final audioTracks = []; final subtitleTracks = []; @@ -73,19 +116,24 @@ FileInfoStreams walkStreams(List? streams, FileInfoStreamReader reader) var subtitleIndex = 0; for (final raw in streams) { if (raw is! Map) continue; - final type = reader.typeOf(raw); - if (type == null) continue; - switch (type) { - case FileInfoStreamType.video: - videoStream ??= raw; - frameRate ??= reader.frameRateOf(raw); - case FileInfoStreamType.audio: - audioStream ??= raw; - audioIndex++; - audioTracks.add(reader.toAudioTrack(raw, audioIndex)); - case FileInfoStreamType.subtitle: - subtitleIndex++; - subtitleTracks.add(reader.toSubtitleTrack(raw, subtitleIndex)); + try { + final type = reader.typeOf(raw); + if (type == null) continue; + switch (type) { + case FileInfoStreamType.video: + videoStream ??= raw; + frameRate ??= reader.frameRateOf(raw); + case FileInfoStreamType.audio: + audioStream ??= raw; + audioIndex++; + audioTracks.add(reader.toAudioTrack(raw, audioIndex)); + case FileInfoStreamType.subtitle: + subtitleIndex++; + subtitleTracks.add(reader.toSubtitleTrack(raw, subtitleIndex)); + } + } catch (error, stackTrace) { + if (onMalformed == null) rethrow; + onMalformed(error, stackTrace, raw); } } return FileInfoStreams( @@ -174,32 +222,35 @@ class JellyfinFileInfoStreamReader implements FileInfoStreamReader { @override MediaAudioTrack toAudioTrack(Map s, int autoIndex) { + final f = parseJellyfinStreamFields(s, fallbackIndex: autoIndex); return MediaAudioTrack( - id: (s['Index'] as int?) ?? autoIndex, - index: s['Index'] as int?, - codec: s['Codec'] as String?, - language: s['Language'] as String?, - languageCode: s['Language'] as String?, - title: s['Title'] as String?, - displayTitle: s['DisplayTitle'] as String?, - channels: s['Channels'] as int?, - selected: s['IsDefault'] == true, + id: f.index, + index: f.index, + codec: f.codec, + language: f.language, + languageCode: f.languageCode, + title: f.title, + displayTitle: f.displayTitle, + channels: f.channels, + selected: f.isDefault, ); } @override MediaSubtitleTrack toSubtitleTrack(Map s, int autoIndex) { + final f = parseJellyfinStreamFields(s, fallbackIndex: autoIndex); return MediaSubtitleTrack( - id: (s['Index'] as int?) ?? autoIndex, - index: s['Index'] as int?, - codec: s['Codec'] as String?, - language: s['Language'] as String?, - languageCode: s['Language'] as String?, - title: s['Title'] as String?, - displayTitle: s['DisplayTitle'] as String?, - selected: s['IsDefault'] == true, - forced: s['IsForced'] == true, - key: null, + id: f.index, + index: f.index, + codec: f.codec, + language: f.language, + languageCode: f.languageCode, + title: f.title, + displayTitle: f.displayTitle, + selected: f.isDefault, + forced: f.isForced, + key: f.isExternal ? f.deliveryUrl : null, + external: f.isExternal, ); } diff --git a/lib/services/jellyfin_mappers.dart b/lib/services/jellyfin_mappers.dart index 9a032044..dfde435c 100644 --- a/lib/services/jellyfin_mappers.dart +++ b/lib/services/jellyfin_mappers.dart @@ -10,50 +10,12 @@ import '../media/media_version.dart'; import '../utils/jellyfin_time.dart'; import '../utils/json_utils.dart'; import '../utils/resolution_label.dart'; +import 'file_info_parser.dart'; // Re-export so existing callers that pulled `resolutionLabelFromHeight` // from this file keep compiling without a bulk import rewrite. export '../utils/resolution_label.dart' show resolutionLabelFromHeight; -/// Normalised projection of a single entry in Jellyfin's `MediaStreams` array. -/// Both [JellyfinMappers._mediaStreams] and `jellyfinMediaSourceToMediaSourceInfo` -/// build their own typed output (neutral [MediaStream] vs Plex-shaped -/// `MediaAudioTrack`/`MediaSubtitleTrack`) from this shared extraction so the -/// field-name parsing only lives in one place. -typedef JellyfinStreamFields = ({ - String? type, - int index, - String? codec, - String? language, - String? languageCode, - String? title, - String? displayTitle, - bool isDefault, - bool isForced, - bool isExternal, - String? deliveryUrl, - int? channels, - double? frameRate, -}); - -JellyfinStreamFields parseJellyfinStreamFields(Map s, {int fallbackIndex = 0}) { - return ( - type: (s['Type'] as String?)?.toLowerCase(), - index: flexibleInt(s['Index']) ?? fallbackIndex, - codec: s['Codec'] as String?, - language: s['DisplayLanguage'] as String? ?? s['Language'] as String?, - languageCode: s['Language'] as String?, - title: s['Title'] as String?, - displayTitle: s['DisplayTitle'] as String?, - isDefault: s['IsDefault'] as bool? ?? false, - isForced: s['IsForced'] as bool? ?? false, - isExternal: s['IsExternal'] as bool? ?? false, - deliveryUrl: s['DeliveryUrl'] as String?, - channels: flexibleInt(s['Channels']), - frameRate: flexibleDouble(s['RealFrameRate']) ?? flexibleDouble(s['AverageFrameRate']), - ); -} - Map? jellyfinFirstVideoStream(Object? streams) { if (streams is! List) return null; for (final stream in streams) { @@ -165,6 +127,12 @@ class JellyfinMappers { static String _query(String value) => Uri.encodeComponent(value); + static String _itemImagePath(String id, String type, {String? tag, int? imageIndex}) { + final indexPart = imageIndex != null ? '/$imageIndex' : ''; + final tagPart = tag != null ? '?tag=${_query(tag)}' : ''; + return '/Items/${_segment(id)}/Images/$type$indexPart$tagPart'; + } + /// Map a Jellyfin `BaseItemDto` (the `Items[]` shape returned by most /// browse endpoints) into a [MediaItem]. Returns `null` when the server /// payload is missing `Id` — the mapped item would otherwise carry an @@ -397,8 +365,7 @@ class JellyfinMappers { final id = person['Id'] as String?; final tag = person['PrimaryImageTag'] as String?; if (id == null) return null; - final tagPart = tag != null ? '?tag=${_query(tag)}' : ''; - return '/Items/${_segment(id)}/Images/Primary$tagPart'; + return _itemImagePath(id, 'Primary', tag: tag); } static List? _mediaVersions(Object? sources) { @@ -464,28 +431,27 @@ class JellyfinMappers { String? tag; if (type == 'Backdrop' && backdropTags is List && backdropTags.isNotEmpty) { tag = backdropTags.first as String?; - return tag != null ? '/Items/${_segment(id)}/Images/Backdrop/0?tag=${_query(tag)}' : null; + return tag != null ? _itemImagePath(id, 'Backdrop', tag: tag, imageIndex: 0) : null; } if (tags is Map) { final value = tags[type]; if (value is String) tag = value; } if (tag == null) return null; - return '/Items/${_segment(id)}/Images/$type?tag=${_query(tag)}'; + return _itemImagePath(id, type, tag: tag); } static String? _seriesPrimaryImage(Map item) { final seriesId = item['SeriesId'] as String?; if (seriesId == null) return null; final tag = item['SeriesPrimaryImageTag'] as String?; - final tagPart = tag != null ? '?tag=${_query(tag)}' : ''; - return '/Items/${_segment(seriesId)}/Images/Primary$tagPart'; + return _itemImagePath(seriesId, 'Primary', tag: tag); } static String? _seriesBackdropImage(Map item) { final seriesId = item['SeriesId'] as String?; if (seriesId == null) return null; - return '/Items/${_segment(seriesId)}/Images/Backdrop/0'; + return _itemImagePath(seriesId, 'Backdrop', imageIndex: 0); } /// Parent backdrop helper — works for episodes (parent = series) and @@ -499,9 +465,9 @@ class JellyfinMappers { final tags = item['ParentBackdropImageTags']; if (tags is List && tags.isNotEmpty) { final tag = tags.first as String?; - if (tag != null) return '/Items/${_segment(parentId)}/Images/Backdrop/0?tag=${_query(tag)}'; + if (tag != null) return _itemImagePath(parentId, 'Backdrop', tag: tag, imageIndex: 0); } - return '/Items/${_segment(parentId)}/Images/Backdrop/0'; + return _itemImagePath(parentId, 'Backdrop', imageIndex: 0); } /// Parent logo helper — episodes/seasons inherit the series' logo via @@ -511,15 +477,13 @@ class JellyfinMappers { final parentId = item['ParentLogoItemId'] as String?; if (parentId == null) return null; final tag = item['ParentLogoImageTag'] as String?; - final tagPart = tag != null ? '?tag=${_query(tag)}' : ''; - return '/Items/${_segment(parentId)}/Images/Logo$tagPart'; + return _itemImagePath(parentId, 'Logo', tag: tag); } static String? _imagePath(Map item, String idField, String tagField, String type) { final id = item[idField] as String?; if (id == null) return null; final tag = item[tagField] as String?; - final tagPart = tag != null ? '?tag=${_query(tag)}' : ''; - return '/Items/${_segment(id)}/Images/$type$tagPart'; + return _itemImagePath(id, type, tag: tag); } } diff --git a/lib/services/jellyfin_media_info_mapper.dart b/lib/services/jellyfin_media_info_mapper.dart index 101560a3..04c59f8b 100644 --- a/lib/services/jellyfin_media_info_mapper.dart +++ b/lib/services/jellyfin_media_info_mapper.dart @@ -2,6 +2,7 @@ import '../media/media_version.dart'; import '../media/media_source_info.dart'; import '../utils/jellyfin_time.dart'; import '../utils/json_utils.dart'; +import 'file_info_parser.dart'; import 'jellyfin_mappers.dart'; /// Translate a Jellyfin `MediaSource` JSON object into [MediaSourceInfo] so the @@ -24,62 +25,16 @@ MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo( Object? chapters, Object? trickplay, }) { - final streams = source['MediaStreams']; - final audioTracks = []; - final subtitleTracks = []; + final rawStreams = source['MediaStreams']; + final parsedStreams = walkStreams(rawStreams is List ? rawStreams : null, const JellyfinFileInfoStreamReader()); // partId stays null for Jellyfin because Plex's `/library/parts/{id}` // select-stream endpoint has no Jellyfin equivalent. Jellyfin track // persistence is driven by `/Sessions/Playing/Progress` stream indexes. const int? partId = null; final defaultAudioStreamIndex = flexibleInt(source['DefaultAudioStreamIndex']); final defaultSubtitleStreamIndex = flexibleInt(source['DefaultSubtitleStreamIndex']); - double? frameRate; - - if (streams is List) { - for (final s in streams) { - if (s is! Map) continue; - final f = parseJellyfinStreamFields(s); - switch (f.type) { - case 'video': - frameRate ??= f.frameRate; - break; - case 'audio': - audioTracks.add( - MediaAudioTrack( - id: f.index, - index: f.index, - codec: f.codec, - language: f.language, - languageCode: f.languageCode, - title: f.title, - displayTitle: f.displayTitle, - channels: f.channels, - selected: defaultAudioStreamIndex != null ? f.index == defaultAudioStreamIndex : f.isDefault, - ), - ); - break; - case 'subtitle': - subtitleTracks.add( - MediaSubtitleTrack( - id: f.index, - index: f.index, - codec: f.codec, - language: f.language, - languageCode: f.languageCode, - title: f.title, - displayTitle: f.displayTitle, - selected: defaultSubtitleStreamIndex != null - ? f.index == defaultSubtitleStreamIndex - : f.isDefault || f.isForced, - forced: f.isForced, - key: f.isExternal ? f.deliveryUrl : null, - external: f.isExternal, - ), - ); - break; - } - } - } + final audioTracks = _withDefaultAudioSelection(parsedStreams.audioTracks, defaultAudioStreamIndex); + final subtitleTracks = _withDefaultSubtitleSelection(parsedStreams.subtitleTracks, defaultSubtitleStreamIndex); final mappedChapters = []; if (chapters is List) { @@ -101,7 +56,7 @@ MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo( subtitleTracks: subtitleTracks, chapters: mappedChapters, partId: partId, - frameRate: frameRate, + frameRate: parsedStreams.frameRate, mediaSourceId: mediaSourceId, defaultAudioStreamIndex: defaultAudioStreamIndex, defaultSubtitleStreamIndex: defaultSubtitleStreamIndex, @@ -109,6 +64,43 @@ MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo( ); } +List _withDefaultAudioSelection(List tracks, int? defaultStreamIndex) { + if (defaultStreamIndex == null) return tracks; + return [ + for (final track in tracks) + MediaAudioTrack( + id: track.id, + index: track.index, + codec: track.codec, + language: track.language, + languageCode: track.languageCode, + title: track.title, + displayTitle: track.displayTitle, + channels: track.channels, + selected: track.index == defaultStreamIndex, + ), + ]; +} + +List _withDefaultSubtitleSelection(List tracks, int? defaultStreamIndex) { + return [ + for (final track in tracks) + MediaSubtitleTrack( + id: track.id, + index: track.index, + codec: track.codec, + language: track.language, + languageCode: track.languageCode, + title: track.title, + displayTitle: track.displayTitle, + selected: defaultStreamIndex != null ? track.index == defaultStreamIndex : track.selected || track.forced, + forced: track.forced, + key: track.key, + external: track.external, + ), + ]; +} + /// Parse Jellyfin chapters from the raw `BaseItemDto` payload into neutral /// playback extras. Markers are not exposed by Jellyfin, so the list is empty. PlaybackExtras jellyfinPlaybackExtrasFromRaw(dynamic raw, String itemId) { diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart index 696a208e..93ffb135 100644 --- a/lib/services/plex_mappers.dart +++ b/lib/services/plex_mappers.dart @@ -26,8 +26,8 @@ import '../media/media_version.dart'; import '../utils/app_logger.dart'; import '../utils/global_key_utils.dart'; import '../utils/json_utils.dart'; -import 'plex_constants.dart'; import '../utils/obfuscation_utils.dart'; +import 'file_info_parser.dart'; /// Shared suffix of both unmatched-agent URL schemes: legacy /// `com.plexapp.agents.none://` and new-style `tv.plex.agents.none://`. @@ -1004,60 +1004,18 @@ MediaSourceInfo? plexMediaSourceInfoFromCacheJson(Map metadata, final selectedMedia = mediaIndex >= 0 && mediaIndex < media.length ? media[mediaIndex] : media.first; final parts = flexibleList(selectedMedia['Part']); if (parts == null || parts.isEmpty) return null; - final streams = flexibleList(parts.first['Stream']); - - final audioTracks = []; - final subtitleTracks = []; - double? frameRate; - - if (streams != null) { - for (final s in streams) { - try { - final streamType = s['streamType'] as int?; - if (streamType == PlexStreamType.video) { - frameRate ??= (s['frameRate'] as num?)?.toDouble(); - } else if (streamType == PlexStreamType.audio) { - audioTracks.add( - MediaAudioTrack( - id: s['id'] as int, - index: s['index'] as int?, - codec: s['codec'] as String?, - language: s['language'] as String?, - languageCode: s['languageCode'] as String?, - title: s['title'] as String?, - displayTitle: s['displayTitle'] as String?, - channels: s['channels'] as int?, - selected: flexibleBool(s['selected']), - ), - ); - } else if (streamType == PlexStreamType.subtitle) { - subtitleTracks.add( - MediaSubtitleTrack( - id: s['id'] as int, - index: s['index'] as int?, - codec: s['codec'] as String?, - language: s['language'] as String?, - languageCode: s['languageCode'] as String?, - title: s['title'] as String?, - displayTitle: s['displayTitle'] as String?, - selected: flexibleBool(s['selected']), - forced: flexibleBool(s['forced']), - key: s['key'] as String?, - ), - ); - } - } catch (e) { - appLogger.d('Skipping malformed stream in cached metadata', error: e); - } - } - } + final streams = walkStreams( + flexibleList(parts.first['Stream']), + const PlexFileInfoStreamReader(), + onMalformed: (error, _, __) => appLogger.d('Skipping malformed stream in cached metadata', error: error), + ); return MediaSourceInfo( videoUrl: '', - audioTracks: audioTracks, - subtitleTracks: subtitleTracks, + audioTracks: streams.audioTracks, + subtitleTracks: streams.subtitleTracks, chapters: const [], - frameRate: frameRate, + frameRate: streams.frameRate, ); }