diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 8efb35c3..1b4e2e6f 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -28,6 +28,11 @@ List> _itemsArray(Object? data) { /// they added seconds to large-library pages on small home servers. const _browseFields = 'RecursiveItemCount,ChildCount,UserData,PremiereDate,OriginalTitle,SortName,Overview'; +/// Existing episode-row requests can show Plex-style quality labels when the +/// response includes `MediaSources`. Keep this off broad library/search/latest +/// queries because it is the heaviest item field Jellyfin returns. +const _episodeRowFields = '$_browseFields,MediaSources'; + /// Even slimmer set used by [fetchClientSideEpisodeQueue]. Queue rows /// only need title, thumbnail (`ImageTags['Primary']`), season/episode /// index, and watched state. Title + indices come back without any @@ -389,7 +394,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { 'seriesId': id, 'userId': connection.userId, 'Limit': '1', - 'Fields': _browseFields, + 'Fields': _episodeRowFields, ...jellyfinImageQueryParameters, }); final onDeckEpisode = nextUp.isEmpty ? null : _mapItem(nextUp.first); @@ -493,7 +498,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { queryParameters: { 'userId': connection.userId, 'ParentId': parentId, - 'Fields': _browseFields, + 'Fields': _episodeRowFields, 'StartIndex': '$startIndex', 'Limit': '$_childrenPageSize', ...jellyfinImageQueryParameters, @@ -651,7 +656,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { 'IncludeItemTypes': includeItemTypes, 'StartIndex': offset.toString(), 'Limit': pageSize.toString(), - 'Fields': _browseFields, + 'Fields': _episodeRowFields, ...jellyfinImageQueryParameters, }, abort: abort, diff --git a/lib/services/jellyfin_display_metadata.dart b/lib/services/jellyfin_display_metadata.dart new file mode 100644 index 00000000..a61d9556 --- /dev/null +++ b/lib/services/jellyfin_display_metadata.dart @@ -0,0 +1,101 @@ +import '../media/media_display_criteria.dart'; +import '../utils/json_utils.dart'; + +MediaDisplayCriteria? jellyfinDisplayCriteriaFromStream( + Map source, + Map? videoStream, +) { + if (videoStream == null) return null; + + final doviProfile = flexibleInt(videoStream['DvProfile']); + final doviCompatibilityId = flexibleInt(videoStream['DvBlSignalCompatibilityId']); + final videoRangeType = videoStream['VideoRangeType']?.toString().toLowerCase(); + final videoRange = videoStream['VideoRange']?.toString().toLowerCase(); + final transfer = _stringOrNull(videoStream['ColorTransfer']); + final primaries = _stringOrNull(videoStream['ColorPrimaries']); + final matrix = _stringOrNull(videoStream['ColorSpace']); + final defaults = _jellyfinDefaultDisplayColorTags( + videoRangeType: videoRangeType, + videoRange: videoRange, + doviCompatibilityId: doviCompatibilityId, + transfer: transfer, + primaries: primaries, + matrix: matrix, + ); + final criteria = MediaDisplayCriteria.fromRaw( + fps: videoStream['RealFrameRate'] ?? videoStream['AverageFrameRate'], + width: videoStream['Width'] ?? source['Width'], + height: videoStream['Height'] ?? source['Height'], + doviProfile: doviProfile, + doviLevel: videoStream['DvLevel'], + doviCompatibilityId: doviCompatibilityId, + transfer: transfer ?? defaults.transfer, + primaries: primaries ?? defaults.primaries, + matrix: matrix ?? defaults.matrix, + ); + return criteria.isUsable ? criteria : null; +} + +bool jellyfinVideoStreamIsDolbyVision(Map videoStream) { + final profile = jellyfinDolbyVisionProfile(videoStream); + if (profile != null && profile > 0) return true; + if ((flexibleInt(videoStream['DvVersionMajor']) ?? 0) > 0) return true; + if ((flexibleInt(videoStream['DvVersionMinor']) ?? 0) > 0) return true; + + final text = [ + videoStream['VideoRangeType'], + videoStream['VideoRange'], + videoStream['VideoDoViTitle'], + ].whereType().map((value) => value.toString().toLowerCase()).join(' '); + return text.contains('dovi') || text.contains('dolby vision') || text.contains('dolbyvision'); +} + +int? jellyfinDolbyVisionProfile(Map videoStream) => flexibleInt(videoStream['DvProfile']); + +bool jellyfinVideoStreamIsHdr(Map source, Map videoStream) { + if (jellyfinVideoStreamIsDolbyVision(videoStream)) return true; + final criteria = jellyfinDisplayCriteriaFromStream(source, videoStream); + if (criteria?.isHdr == true) return true; + + final range = [ + videoStream['VideoRangeType'], + videoStream['VideoRange'], + ].whereType().map((value) => value.toString().toLowerCase()).join(' '); + return range.contains('hdr') || range.contains('hlg'); +} + +({String? transfer, String? primaries, String? matrix}) _jellyfinDefaultDisplayColorTags({ + required String? videoRangeType, + required String? videoRange, + int? doviCompatibilityId, + String? transfer, + String? primaries, + String? matrix, +}) { + final range = '${videoRangeType ?? ''} ${videoRange ?? ''}'; + final colorTags = _normalizedDisplayColorTags(transfer, primaries, matrix); + if (doviCompatibilityId == 4 || range.contains('hlg') || colorTags.contains('hlg') || colorTags.contains('arib')) { + return (transfer: 'arib-std-b67', primaries: 'bt2020', matrix: 'bt2020nc'); + } + if (doviCompatibilityId == 1 || + doviCompatibilityId == 6 || + range.contains('hdr') || + colorTags.contains('smpte2084') || + colorTags.contains('st2084') || + colorTags.contains('pq') || + colorTags.contains('bt2020')) { + return (transfer: 'smpte2084', primaries: 'bt2020', matrix: 'bt2020nc'); + } + if (doviCompatibilityId == 2 || range.trim().isEmpty || range.contains('sdr')) { + return (transfer: 'bt709', primaries: 'bt709', matrix: 'bt709'); + } + return (transfer: null, primaries: null, matrix: null); +} + +String? _stringOrNull(Object? value) { + final string = value?.toString().trim(); + return string == null || string.isEmpty ? null : string; +} + +String _normalizedDisplayColorTags(String? transfer, String? primaries, String? matrix) => + [transfer, primaries, matrix].whereType().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); diff --git a/lib/services/jellyfin_mappers.dart b/lib/services/jellyfin_mappers.dart index 66c8ad68..3f235891 100644 --- a/lib/services/jellyfin_mappers.dart +++ b/lib/services/jellyfin_mappers.dart @@ -12,6 +12,7 @@ import '../utils/jellyfin_time.dart'; import '../utils/json_utils.dart'; import '../utils/resolution_label.dart'; import 'file_info_parser.dart'; +import 'jellyfin_display_metadata.dart'; // Re-export so existing callers that pulled `resolutionLabelFromHeight` // from this file keep compiling without a bulk import rewrite. @@ -382,7 +383,7 @@ class JellyfinMappers { if (src is! Map) continue; final id = src['Id'] as String?; if (id == null || id.isEmpty) continue; - final streams = _mediaStreams(src['MediaStreams']); + final streams = _mediaStreams(src['MediaStreams'], source: src); result.add( jellyfinMediaSourceToVersion( src, @@ -399,9 +400,11 @@ class JellyfinMappers { return nullIfEmptyList(result); } - static List _mediaStreams(Object? raw) { + static List _mediaStreams(Object? raw, {Map? source}) { if (raw is! List) return const []; final result = []; + final defaultAudioStreamIndex = flexibleInt(source?['DefaultAudioStreamIndex']); + final defaultSubtitleStreamIndex = flexibleInt(source?['DefaultSubtitleStreamIndex']); for (final s in raw) { if (s is! Map) continue; final f = parseJellyfinStreamFields(s, fallbackIndex: result.length); @@ -411,6 +414,8 @@ class JellyfinMappers { 'subtitle' => MediaStreamKind.subtitle, _ => MediaStreamKind.unknown, }; + final isVideo = kind == MediaStreamKind.video; + final isDolbyVision = isVideo && jellyfinVideoStreamIsDolbyVision(s); result.add( MediaStream( id: '${f.index}', @@ -421,9 +426,17 @@ class JellyfinMappers { languageCode: f.languageCode, title: f.title, displayTitle: f.displayTitle, - selected: f.isDefault, + selected: _jellyfinStreamSelected( + kind, + f, + defaultAudioStreamIndex: defaultAudioStreamIndex, + defaultSubtitleStreamIndex: defaultSubtitleStreamIndex, + ), channels: f.channels, frameRate: f.frameRate, + hdr: isVideo && jellyfinVideoStreamIsHdr(source ?? const {}, s), + dolbyVision: isDolbyVision, + dolbyVisionProfile: isDolbyVision ? jellyfinDolbyVisionProfile(s) : null, forced: f.isForced, sidecarPath: f.isExternalFile ? f.deliveryUrl : null, ), @@ -432,6 +445,19 @@ class JellyfinMappers { return result; } + static bool _jellyfinStreamSelected( + MediaStreamKind kind, + JellyfinStreamFields stream, { + int? defaultAudioStreamIndex, + int? defaultSubtitleStreamIndex, + }) { + return switch (kind) { + MediaStreamKind.audio when defaultAudioStreamIndex != null => stream.index == defaultAudioStreamIndex, + MediaStreamKind.subtitle when defaultSubtitleStreamIndex != null => stream.index == defaultSubtitleStreamIndex, + _ => stream.isDefault, + }; + } + static String? _selfImagePath(String id, Map item, String type) { final tags = item['ImageTags']; final backdropTags = item['BackdropImageTags']; diff --git a/lib/services/jellyfin_media_info_mapper.dart b/lib/services/jellyfin_media_info_mapper.dart index 26ff9a08..78a15a17 100644 --- a/lib/services/jellyfin_media_info_mapper.dart +++ b/lib/services/jellyfin_media_info_mapper.dart @@ -1,11 +1,11 @@ import 'package:collection/collection.dart'; -import '../media/media_display_criteria.dart'; 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_display_metadata.dart'; import 'jellyfin_mappers.dart'; /// Translate a Jellyfin `MediaSource` JSON object into [MediaSourceInfo] so the @@ -59,7 +59,7 @@ MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo( subtitleTracks: subtitleTracks, chapters: mappedChapters, partId: partId, - displayCriteria: _jellyfinDisplayCriteria(source, parsedStreams.videoStream), + displayCriteria: jellyfinDisplayCriteriaFromStream(source, parsedStreams.videoStream), mediaSourceId: mediaSourceId, defaultAudioStreamIndex: defaultAudioStreamIndex, defaultSubtitleStreamIndex: defaultSubtitleStreamIndex, @@ -67,74 +67,6 @@ MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo( ); } -MediaDisplayCriteria? _jellyfinDisplayCriteria(Map source, Map? videoStream) { - if (videoStream == null) return null; - - final doviProfile = flexibleInt(videoStream['DvProfile']); - final doviCompatibilityId = flexibleInt(videoStream['DvBlSignalCompatibilityId']); - final videoRangeType = videoStream['VideoRangeType']?.toString().toLowerCase(); - final videoRange = videoStream['VideoRange']?.toString().toLowerCase(); - final transfer = _stringOrNull(videoStream['ColorTransfer']); - final primaries = _stringOrNull(videoStream['ColorPrimaries']); - final matrix = _stringOrNull(videoStream['ColorSpace']); - final defaults = _jellyfinDefaultDisplayColorTags( - videoRangeType: videoRangeType, - videoRange: videoRange, - doviCompatibilityId: doviCompatibilityId, - transfer: transfer, - primaries: primaries, - matrix: matrix, - ); - final criteria = MediaDisplayCriteria.fromRaw( - fps: videoStream['RealFrameRate'] ?? videoStream['AverageFrameRate'], - width: videoStream['Width'] ?? source['Width'], - height: videoStream['Height'] ?? source['Height'], - doviProfile: doviProfile, - doviLevel: videoStream['DvLevel'], - doviCompatibilityId: doviCompatibilityId, - transfer: transfer ?? defaults.transfer, - primaries: primaries ?? defaults.primaries, - matrix: matrix ?? defaults.matrix, - ); - return criteria.isUsable ? criteria : null; -} - -({String? transfer, String? primaries, String? matrix}) _jellyfinDefaultDisplayColorTags({ - required String? videoRangeType, - required String? videoRange, - int? doviCompatibilityId, - String? transfer, - String? primaries, - String? matrix, -}) { - final range = '${videoRangeType ?? ''} ${videoRange ?? ''}'; - final colorTags = _normalizedDisplayColorTags(transfer, primaries, matrix); - if (doviCompatibilityId == 4 || range.contains('hlg') || colorTags.contains('hlg') || colorTags.contains('arib')) { - return (transfer: 'arib-std-b67', primaries: 'bt2020', matrix: 'bt2020nc'); - } - if (doviCompatibilityId == 1 || - doviCompatibilityId == 6 || - range.contains('hdr') || - colorTags.contains('smpte2084') || - colorTags.contains('st2084') || - colorTags.contains('pq') || - colorTags.contains('bt2020')) { - return (transfer: 'smpte2084', primaries: 'bt2020', matrix: 'bt2020nc'); - } - if (doviCompatibilityId == 2 || range.trim().isEmpty || range.contains('sdr')) { - return (transfer: 'bt709', primaries: 'bt709', matrix: 'bt709'); - } - return (transfer: null, primaries: null, matrix: null); -} - -String? _stringOrNull(Object? value) { - final string = value?.toString().trim(); - return string == null || string.isEmpty ? null : string; -} - -String _normalizedDisplayColorTags(String? transfer, String? primaries, String? matrix) => - [transfer, primaries, matrix].whereType().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); - List _withDefaultAudioSelection(List tracks, int? defaultStreamIndex) { if (defaultStreamIndex == null) return tracks; return [ diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 79d3017a..637603cf 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -172,6 +172,46 @@ void main() { expect(extras[1].posterThumb(), extras[1].artPath); }); + test('fetchChildren requests media sources for episode-row quality labels', () async { + final requests = []; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + requests.add(request.url); + if (request.url.path == '/Shows/season-1/Seasons') { + return http.Response('not found', 404); + } + if (request.url.path == '/Items') { + return http.Response(jsonEncode({'Items': [], 'TotalRecordCount': 0}), 200); + } + return http.Response('unexpected ${request.url}', 500); + }), + ); + addTearDown(scoped.close); + + await scoped.fetchChildren('season-1'); + + final directChildrenRequest = requests.firstWhere((uri) => uri.path == '/Items'); + expect(directChildrenRequest.queryParameters['Fields']!.split(','), contains('MediaSources')); + }); + + test('fetchPlayableDescendantsPage requests media sources for episode-row quality labels', () async { + Uri? capturedUri; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + capturedUri = request.url; + return http.Response(jsonEncode({'Items': [], 'TotalRecordCount': 0}), 200); + }), + ); + addTearDown(scoped.close); + + await scoped.fetchPlayableDescendantsPage('show-1'); + + expect(capturedUri!.path, '/Items'); + expect(capturedUri!.queryParameters['Fields']!.split(','), contains('MediaSources')); + }); + test('reportPlaybackProgress sends media source and stream indexes', () async { Uri? capturedUri; String? capturedBody; diff --git a/test/services/jellyfin_mappers_test.dart b/test/services/jellyfin_mappers_test.dart index 45956e40..158af93c 100644 --- a/test/services/jellyfin_mappers_test.dart +++ b/test/services/jellyfin_mappers_test.dart @@ -313,6 +313,73 @@ void main() { expect(subtitle.isExternal, isTrue); expect(subtitle.sidecarPath, '/Videos/movie-1/movie-1/Subtitles/2/Stream.srt'); }); + + test('media streams map Jellyfin Dolby Vision, HDR, and source default audio', () { + final json = { + 'Id': 'movie-1', + 'Name': 'Movie', + 'Type': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'DefaultAudioStreamIndex': 2, + 'MediaStreams': [ + { + 'Index': 0, + 'Type': 'Video', + 'Codec': 'hevc', + 'Width': 3840, + 'Height': 2160, + 'VideoRangeType': 'DOVI', + 'VideoRange': 'HDR', + 'VideoDoViTitle': 'Dolby Vision Profile 8', + 'DvProfile': 8, + 'DvLevel': 6, + 'DvBlSignalCompatibilityId': 1, + }, + {'Index': 1, 'Type': 'Audio', 'Codec': 'eac3', 'Channels': 6, 'IsDefault': true}, + {'Index': 2, 'Type': 'Audio', 'Codec': 'aac', 'Channels': 2}, + ], + }, + ], + }; + + final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!; + final streams = item.mediaVersions!.single.parts.single.streams; + final video = streams.firstWhere((stream) => stream.kind == MediaStreamKind.video); + final firstAudio = streams.firstWhere((stream) => stream.index == 1); + final selectedAudio = streams.firstWhere((stream) => stream.index == 2); + + expect(video.codec, 'hevc'); + expect(video.hdr, isTrue); + expect(video.dolbyVision, isTrue); + expect(video.dolbyVisionProfile, 8); + expect(firstAudio.selected, isFalse); + expect(selectedAudio.selected, isTrue); + }); + + test('media streams map Jellyfin HDR without Dolby Vision', () { + final json = { + 'Id': 'movie-1', + 'Name': 'Movie', + 'Type': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'VideoRangeType': 'HDR10', 'VideoRange': 'HDR'}, + ], + }, + ], + }; + + final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!; + final video = item.mediaVersions!.single.parts.single.streams.single; + + expect(video.hdr, isTrue); + expect(video.dolbyVision, isFalse); + expect(video.dolbyVisionProfile, isNull); + }); }); group('JellyfinMappers.library', () { diff --git a/test/utils/media_quality_labels_test.dart b/test/utils/media_quality_labels_test.dart index 66d95565..ff0aa267 100644 --- a/test/utils/media_quality_labels_test.dart +++ b/test/utils/media_quality_labels_test.dart @@ -6,6 +6,7 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_part.dart'; import 'package:plezy/media/media_stream.dart'; import 'package:plezy/media/media_version.dart'; +import 'package:plezy/services/jellyfin_mappers.dart'; import 'package:plezy/services/plex_mappers.dart'; import 'package:plezy/utils/media_quality_labels.dart'; @@ -151,6 +152,41 @@ void main() { expect(buildMediaQualityLabels(item), ['4K', 'DV P8', 'EAC3 5.1']); }); + test('formats Jellyfin stream metadata from MediaSources', () { + final item = JellyfinMappers.mediaItem( + { + 'Id': 'movie-1', + 'Name': 'Movie', + 'Type': 'Movie', + 'MediaSources': [ + { + 'Id': 'source-1', + 'DefaultAudioStreamIndex': 2, + 'MediaStreams': [ + { + 'Index': 0, + 'Type': 'Video', + 'Codec': 'hevc', + 'Width': 3840, + 'Height': 2160, + 'VideoRangeType': 'DOVI', + 'VideoDoViTitle': 'Dolby Vision Profile 8', + 'DvProfile': 8, + 'DvBlSignalCompatibilityId': 1, + }, + {'Index': 1, 'Type': 'Audio', 'Codec': 'eac3', 'Channels': 6, 'IsDefault': true}, + {'Index': 2, 'Type': 'Audio', 'Codec': 'aac', 'Channels': 2}, + ], + }, + ], + }, + serverId: ServerId('jellyfin'), + absolutizer: null, + )!; + + expect(buildMediaQualityLabels(item), ['4K', 'DV P8', 'AAC Stereo']); + }); + test('uses selected audio stream and stereo label', () { final item = _episodeWithVersion( MediaVersion(