From 0e7ff609b6ddc51e74dfe49a17aefe5ce96b296f Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:20:12 +0200 Subject: [PATCH] feat(media): show video and audio quality labels close #1115 --- lib/media/media_stream.dart | 6 + lib/screens/media_detail_screen.dart | 4 + lib/services/plex_client.dart | 31 +++- lib/services/plex_mappers.dart | 155 +++++++++++++++++- lib/services/plex_mappers.g.dart | 3 + lib/utils/media_quality_labels.dart | 130 +++++++++++++++ lib/widgets/episode_card.dart | 83 ++++++---- test/services/plex_mappers_test.dart | 70 +++++++- test/utils/media_quality_labels_test.dart | 190 ++++++++++++++++++++++ 9 files changed, 631 insertions(+), 41 deletions(-) create mode 100644 lib/utils/media_quality_labels.dart create mode 100644 test/utils/media_quality_labels_test.dart diff --git a/lib/media/media_stream.dart b/lib/media/media_stream.dart index e03e43a2..21273cdd 100644 --- a/lib/media/media_stream.dart +++ b/lib/media/media_stream.dart @@ -19,6 +19,9 @@ class MediaStream { // Video final double? frameRate; + final bool hdr; + final bool dolbyVision; + final int? dolbyVisionProfile; // Subtitle final bool forced; @@ -40,6 +43,9 @@ class MediaStream { this.selected = false, this.channels, this.frameRate, + this.hdr = false, + this.dolbyVision = false, + this.dolbyVisionProfile, this.forced = false, this.sidecarPath, }); diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index c3e39489..a112c48d 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -33,6 +33,7 @@ import '../widgets/media_card.dart'; import '../i18n/strings.g.dart'; import '../widgets/optimized_media_image.dart'; import '../utils/media_image_helper.dart'; +import '../utils/media_quality_labels.dart'; import '../services/plex_client.dart'; import '../media/media_server_client.dart'; import '../services/media_list_playback_launcher.dart'; @@ -3347,6 +3348,7 @@ class _MediaDetailScreenState extends State Widget _buildTvDetailMetadataLine(BuildContext context, MediaItem metadata, double scale) { final lineMetadata = _tvDetailFocusedEpisode ?? metadata; final episodeLabel = formatSeasonEpisodeLabel(lineMetadata.parentIndex, lineMetadata.index); + final qualityLabels = buildMediaQualityLabels(lineMetadata); final parts = [ if (lineMetadata.isEpisode && episodeLabel != null) episodeLabel, if (lineMetadata.isMovie) t.discover.movie else if (lineMetadata.isShow) t.discover.tvShow, @@ -3357,6 +3359,7 @@ class _MediaDetailScreenState extends State formatFullDate(lineMetadata.originallyAvailableAt!) else if (lineMetadata.year != null) lineMetadata.year.toString(), + ...qualityLabels, ]; return Text( @@ -3749,6 +3752,7 @@ class _MediaDetailScreenState extends State if (metadata case PlexMediaItem(:final editionTitle?)) _buildMetadataChip(editionTitle), if (metadata.contentRating != null) _buildMetadataChip(formatContentRating(metadata.contentRating!)), if (metadata.durationMs != null) _buildMetadataChip(formatDurationTextual(metadata.durationMs!)), + for (final label in buildMediaQualityLabels(metadata)) _buildMetadataChip(label), ..._buildRatingChips(metadata), ]; diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 07379775..9f7c625d 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -955,8 +955,13 @@ class PlexClient AbortController? abort, int? librarySectionID, String? librarySectionTitle, + Map? queryParameters, }) async { - final response = await _getWithFailover(path, queryParameters: _buildPaginationParams(start, size), abort: abort); + final response = await _getWithFailover( + path, + queryParameters: {...?queryParameters, ..._buildPaginationParams(start, size)}, + abort: abort, + ); return _extractLibraryContentResult( response, librarySectionID: librarySectionID, @@ -1037,7 +1042,13 @@ class PlexClient cacheKey: cacheKey, networkCall: () => _http.get( '/library/metadata/$ratingKey', - queryParameters: {'includeChapters': 1, 'includeMarkers': 1, 'includeOnDeck': 1}, + queryParameters: { + 'includeChapters': 1, + 'includeMarkers': 1, + 'includeOnDeck': 1, + 'checkFiles': 1, + 'includeStreams': 1, + }, ), parseCache: (cachedData) { final metadata = _parseMetadataWithImagesFromCachedResponse(cachedData); @@ -1092,8 +1103,10 @@ class PlexClient return fetchWithCacheFallback( cacheKey: cacheKey, - networkCall: () => - _http.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}), + networkCall: () => _http.get( + '/library/metadata/$ratingKey', + queryParameters: {'includeChapters': 1, 'includeMarkers': 1, 'checkFiles': 1, 'includeStreams': 1}, + ), parseCache: (cachedData) => _parseMetadataWithImagesFromCachedResponse(cachedData), parseResponse: (response) { final container = _getMediaContainer(response); @@ -1406,7 +1419,7 @@ class PlexClient return await fetchWithCacheFallback>( cacheKey: endpoint, - networkCall: () => _http.get(endpoint), + networkCall: () => _http.get(endpoint, queryParameters: {'includeStreams': 1}), parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData), parseResponse: (response) => _extractMetadataList(response), ) ?? @@ -1421,7 +1434,13 @@ class PlexClient int? start, int? size, AbortController? abort, - }) => _fetchPaginatedList('/library/metadata/$ratingKey/grandchildren', start: start, size: size, abort: abort); + }) => _fetchPaginatedList( + '/library/metadata/$ratingKey/grandchildren', + start: start, + size: size, + abort: abort, + queryParameters: {'includeStreams': 1}, + ); /// Get extras for a metadata item (trailers, behind-the-scenes, etc.) /// Uses cache when offline or as fallback on network error diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart index d0c77305..d73b3385 100644 --- a/lib/services/plex_mappers.dart +++ b/lib/services/plex_mappers.dart @@ -25,12 +25,14 @@ import '../media/media_part.dart'; import '../media/media_playlist.dart'; import '../media/media_role.dart'; import '../media/media_source_info.dart'; +import '../media/media_stream.dart'; import '../media/media_version.dart'; import '../utils/app_logger.dart'; import '../utils/global_key_utils.dart'; import '../utils/json_utils.dart'; import '../utils/obfuscation_utils.dart'; import 'file_info_parser.dart'; +import 'plex_constants.dart'; part 'plex_mappers.g.dart'; @@ -68,7 +70,12 @@ bool? _partAccessibleFromJson(Object? raw) => flexibleBoolNullable(_firstPartMap bool? _partExistsFromJson(Object? raw) => flexibleBoolNullable(_firstPartMap(raw)?['exists']); -MediaPart _mediaPartFromMap(Map json, {required String fallbackId, String? fallbackContainer}) { +MediaPart _mediaPartFromMap( + Map json, { + required String fallbackId, + String? fallbackContainer, + Map? media, +}) { return MediaPart( id: (json['id'] ?? fallbackId).toString(), streamPath: json['key']?.toString(), @@ -77,10 +84,16 @@ MediaPart _mediaPartFromMap(Map json, {required String fallbackId, String? fallb durationMs: flexibleInt(json['duration']), accessible: flexibleBoolNullable(json['accessible']), exists: flexibleBoolNullable(json['exists']), + streams: _mediaStreamsFromPlexPart(json['Stream'], media: media, part: json), ); } -List _mediaPartsFromJson(Object? raw, {required String fallbackId, String? fallbackContainer}) { +List _mediaPartsFromJson( + Object? raw, { + required String fallbackId, + String? fallbackContainer, + Map? media, +}) { final partMaps = _partMaps(raw); if (partMaps.isEmpty) return const []; return [ @@ -89,16 +102,141 @@ List _mediaPartsFromJson(Object? raw, {required String fallbackId, St partMaps[i], fallbackId: i == 0 ? fallbackId : '$fallbackId:$i', fallbackContainer: fallbackContainer, + media: media, ), ]; } +List _mediaStreamsFromPlexPart(Object? raw, {Map? media, Map? part}) { + final streams = flexibleList(raw); + if (streams == null || streams.isEmpty) return _fallbackStreamsFromPlexMedia(media, part); + + final result = []; + for (final rawStream in streams) { + if (rawStream is! Map) continue; + final stream = Map.from(rawStream); + final kind = _mediaStreamKindFromPlexType(stream['streamType']); + if (kind == MediaStreamKind.unknown) continue; + + final criteria = kind == MediaStreamKind.video ? PlexMappers.displayCriteriaFromJson(media, stream) : null; + final isDolbyVision = kind == MediaStreamKind.video && _isDolbyVisionStream(stream); + final isHdr = criteria?.isHdr == true || isDolbyVision; + final doviProfile = isDolbyVision ? flexibleInt(stream['DOVIProfile']) : null; + + result.add( + MediaStream( + id: (stream['id'] ?? result.length).toString(), + kind: kind, + index: flexibleInt(stream['index']), + codec: stream['codec']?.toString(), + language: stream['language']?.toString(), + languageCode: stream['languageCode']?.toString(), + title: stream['title']?.toString(), + displayTitle: stream['displayTitle']?.toString() ?? stream['extendedDisplayTitle']?.toString(), + selected: flexibleBool(stream['selected']) || flexibleBool(stream['default']), + channels: flexibleInt(stream['channels']), + frameRate: flexibleDouble(stream['frameRate']), + hdr: isHdr, + dolbyVision: isDolbyVision, + dolbyVisionProfile: doviProfile, + forced: flexibleBool(stream['forced']), + sidecarPath: kind == MediaStreamKind.subtitle ? stream['key']?.toString() : null, + ), + ); + } + final fallback = _fallbackStreamsFromPlexMedia(media, part); + for (final stream in fallback) { + if (!result.any((parsed) => parsed.kind == stream.kind)) result.add(stream); + } + return result; +} + +List _fallbackStreamsFromPlexMedia(Map? media, Map? part) { + if (media == null) return const []; + + final result = []; + final displayHints = _mediaDisplayHintTokens(media, part); + final hasDolbyVision = + displayHints.contains('dv') || displayHints.contains('dovi') || displayHints.contains('dolbyvision'); + final hasHdr = + hasDolbyVision || + displayHints.contains('hdr') || + displayHints.contains('hdr10') || + displayHints.contains('hdr10plus') || + displayHints.contains('hlg'); + + if (hasHdr || hasDolbyVision) { + result.add( + MediaStream( + id: '${media['id'] ?? 'video'}:video', + kind: MediaStreamKind.video, + codec: media['videoCodec']?.toString(), + hdr: hasHdr, + dolbyVision: hasDolbyVision, + ), + ); + } + + final audioCodec = media['audioCodec']?.toString(); + final audioChannels = flexibleInt(media['audioChannels']); + if ((audioCodec != null && audioCodec.isNotEmpty) || audioChannels != null) { + result.add( + MediaStream( + id: '${media['id'] ?? 'audio'}:audio', + kind: MediaStreamKind.audio, + codec: audioCodec, + channels: audioChannels, + selected: true, + ), + ); + } + + return result; +} + +Set _mediaDisplayHintTokens(Map media, Map? part) { + final text = [ + media['videoProfile'], + media['displayTitle'], + media['extendedDisplayTitle'], + part?['file'], + ].whereType().map((value) => value.toString()).join(' '); + return RegExp(r'[a-z0-9]+').allMatches(text.toLowerCase()).map((match) => match.group(0)!).toSet(); +} + +MediaStreamKind _mediaStreamKindFromPlexType(Object? raw) { + return switch (flexibleInt(raw)) { + PlexStreamType.video => MediaStreamKind.video, + PlexStreamType.audio => MediaStreamKind.audio, + PlexStreamType.subtitle => MediaStreamKind.subtitle, + _ => MediaStreamKind.unknown, + }; +} + +bool _isDolbyVisionStream(Map stream) { + return (flexibleInt(stream['DOVIProfile']) ?? 0) > 0 || flexibleBool(stream['DOVIPresent']); +} + Object? _readPartKey(Map json, String _) => _partKeyFromJson(json['Part']); Object? _readPartAccessible(Map json, String _) => _partAccessibleFromJson(json['Part']); Object? _readPartExists(Map json, String _) => _partExistsFromJson(json['Part']); +Object? _readMediaParts(Map json, String _) { + return _mediaPartsFromJson( + json['Part'], + fallbackId: (json['id'] ?? '').toString(), + fallbackContainer: json['container']?.toString(), + media: Map.from(json), + ); +} + +List _mediaPartsFromReadValue(Object? raw) { + if (raw is List) return raw; + return const []; +} + String _hubTitleFromJson(Object? raw) { final title = raw as String? ?? 'Unknown'; return kBlurArtwork ? obfuscateText(title) : title; @@ -188,6 +326,8 @@ class PlexMediaVersionDto { final bool? accessible; @JsonKey(readValue: _readPartExists) final bool? exists; + @JsonKey(readValue: _readMediaParts, fromJson: _mediaPartsFromReadValue) + final List parts; const PlexMediaVersionDto({ required this.id, @@ -200,6 +340,7 @@ class PlexMediaVersionDto { required this.partKey, this.accessible, this.exists, + this.parts = const [], }); factory PlexMediaVersionDto.fromJson(Map json) => _$PlexMediaVersionDtoFromJson(json); @@ -905,6 +1046,7 @@ class PlexMappers { accessible: dto.accessible, exists: dto.exists, ); + final parts = dto.parts.isEmpty ? [part] : dto.parts; return MediaVersion( id: dto.id.toString(), width: dto.width, @@ -913,14 +1055,19 @@ class PlexMappers { videoCodec: dto.videoCodec, bitrate: dto.bitrate, container: dto.container, - parts: [part], + parts: parts, ); } /// Map a Plex Media JSON entry directly into a [MediaVersion]. static MediaVersion mediaVersionFromJson(Map json) { final dto = PlexMediaVersionDto.fromJson(json); - final parts = _mediaPartsFromJson(json['Part'], fallbackId: dto.id.toString(), fallbackContainer: dto.container); + final parts = _mediaPartsFromJson( + json['Part'], + fallbackId: dto.id.toString(), + fallbackContainer: dto.container, + media: json, + ); if (parts.isEmpty) return mediaVersion(dto); return MediaVersion( id: dto.id.toString(), diff --git a/lib/services/plex_mappers.g.dart b/lib/services/plex_mappers.g.dart index ca68723f..00394139 100644 --- a/lib/services/plex_mappers.g.dart +++ b/lib/services/plex_mappers.g.dart @@ -28,6 +28,9 @@ PlexMediaVersionDto _$PlexMediaVersionDtoFromJson(Map json) => partKey: _readPartKey(json, 'partKey') as String, accessible: _readPartAccessible(json, 'accessible') as bool?, exists: _readPartExists(json, 'exists') as bool?, + parts: _readMediaParts(json, 'parts') == null + ? const [] + : _mediaPartsFromReadValue(_readMediaParts(json, 'parts')), ); PlexLibraryDto _$PlexLibraryDtoFromJson(Map json) => diff --git a/lib/utils/media_quality_labels.dart b/lib/utils/media_quality_labels.dart new file mode 100644 index 00000000..2ba5b40f --- /dev/null +++ b/lib/utils/media_quality_labels.dart @@ -0,0 +1,130 @@ +import '../media/media_item.dart'; +import '../media/media_stream.dart'; +import '../media/media_version.dart'; +import 'codec_utils.dart'; +import 'resolution_label.dart'; + +List buildMediaQualityLabels(MediaItem item, {int versionIndex = 0}) { + final version = _selectedVersion(item.mediaVersions, versionIndex); + if (version == null) return const []; + + final labels = []; + final resolution = _formatResolution(version); + if (resolution != null) labels.add(resolution); + + final video = _firstStreamOfKind(version, MediaStreamKind.video); + if (video?.dolbyVision == true) { + labels.add(_formatDolbyVision(video!)); + } else if (video?.hdr == true) { + labels.add('HDR'); + } + + final audio = _selectedAudioStream(version); + final audioLabel = _formatAudio(audio); + if (audioLabel != null) labels.add(audioLabel); + + return labels; +} + +String _formatDolbyVision(MediaStream stream) { + final profile = stream.dolbyVisionProfile; + return profile == null || profile <= 0 ? 'DV' : 'DV P$profile'; +} + +MediaVersion? _selectedVersion(List? versions, int versionIndex) { + if (versions == null || versions.isEmpty) return null; + if (versionIndex >= 0 && versionIndex < versions.length) return versions[versionIndex]; + return versions.first; +} + +String? _formatResolution(MediaVersion version) { + final raw = version.videoResolution?.trim(); + if (raw != null && raw.isNotEmpty) return _formatResolutionValue(raw); + + final fallback = resolutionLabelFromDimensions(version.width, version.height); + return fallback == null ? null : _formatResolutionValue(fallback); +} + +String _formatResolutionValue(String value) { + final normalized = value.trim().toLowerCase(); + if (normalized == '4k' || normalized == 'uhd') return '4K'; + if (normalized == 'sd') return 'SD'; + + final numeric = RegExp(r'^(\d+)(?:p)?$').firstMatch(normalized); + if (numeric != null) { + final height = int.tryParse(numeric.group(1)!); + if (height != null && height >= 2160) return '4K'; + return '${numeric.group(1)}p'; + } + + return value.toUpperCase(); +} + +MediaStream? _firstStreamOfKind(MediaVersion version, MediaStreamKind kind) { + for (final part in version.parts) { + for (final stream in part.streams) { + if (stream.kind == kind) return stream; + } + } + return null; +} + +MediaStream? _selectedAudioStream(MediaVersion version) { + MediaStream? first; + for (final part in version.parts) { + for (final stream in part.streams) { + if (stream.kind != MediaStreamKind.audio) continue; + first ??= stream; + if (stream.selected) return stream; + } + } + return first; +} + +String? _formatAudio(MediaStream? stream) { + if (stream == null) return null; + + final parts = []; + final codec = stream.codec?.trim(); + if (codec != null && codec.isNotEmpty) parts.add(_formatAudioCodec(codec)); + + if (_isAtmos(stream)) { + parts.add('Atmos'); + } else { + final channels = _formatAudioChannels(stream.channels); + if (channels != null) parts.add(channels); + } + + return parts.isEmpty ? null : parts.join(' '); +} + +String _formatAudioCodec(String codec) { + return switch (codec.toLowerCase()) { + 'eac3' || 'ec3' => 'EAC3', + 'ac3' => 'AC3', + _ => CodecUtils.formatAudioCodec(codec), + }; +} + +bool _isAtmos(MediaStream stream) { + return [ + stream.codec, + stream.title, + stream.displayTitle, + ].whereType().any((value) => value.toLowerCase().contains('atmos')); +} + +String? _formatAudioChannels(int? channels) { + if (channels == null || channels <= 0) return null; + return switch (channels) { + 1 => 'Mono', + 2 => 'Stereo', + 3 => '3.0', + 4 => '4.0', + 5 => '4.1', + 6 => '5.1', + 7 => '6.1', + 8 => '7.1', + _ => '${channels}ch', + }; +} diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index 019cd40c..2bfba82f 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -20,6 +20,7 @@ import '../widgets/download_status_icon.dart'; import '../widgets/optimized_media_image.dart'; import '../utils/platform_detector.dart'; import '../utils/formatters.dart'; +import '../utils/media_quality_labels.dart'; import '../widgets/media_context_menu.dart'; import '../widgets/placeholder_container.dart'; import '../theme/mono_tokens.dart'; @@ -68,36 +69,57 @@ class _EpisodeCardState extends State with ContextMenuTapMixin qualityLabels) { final mutedStyle = Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12); - final dot = Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Text('•', style: mutedStyle), - ); - return Row( - children: [ - if (episode.durationMs != null) - Text(formatDurationTimestamp(Duration(milliseconds: episode.durationMs!)), style: mutedStyle), - if (episode.originallyAvailableAt != null) ...[ - dot, - Text(formatFullDate(episode.originallyAvailableAt!), style: mutedStyle), - ], - if (episode.userRating != null && episode.userRating! > 0) ...[ - dot, - const Padding( - padding: .only(top: 2), - child: Icon(Symbols.star_rounded, size: 12, fill: 1, color: Colors.amber), - ), - const SizedBox(width: 2), - Text( - (episode.userRating! / 2) == (episode.userRating! / 2).truncateToDouble() - ? '${(episode.userRating! / 2).toInt()}' - : formatRating(episode.userRating! / 2), - style: mutedStyle, - ), - ], - ], - ); + final children = []; + + void addSeparator() { + if (children.isEmpty) return; + children.add( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text('•', style: mutedStyle), + ), + ); + } + + if (episode.durationMs != null) { + children.add(Text(formatDurationTimestamp(Duration(milliseconds: episode.durationMs!)), style: mutedStyle)); + } + + if (episode.originallyAvailableAt != null) { + addSeparator(); + children.add(Text(formatFullDate(episode.originallyAvailableAt!), style: mutedStyle)); + } + + if (episode.userRating != null && episode.userRating! > 0) { + addSeparator(); + children.add( + Row( + mainAxisSize: .min, + children: [ + const Padding( + padding: .only(top: 2), + child: Icon(Symbols.star_rounded, size: 12, fill: 1, color: Colors.amber), + ), + const SizedBox(width: 2), + Text( + (episode.userRating! / 2) == (episode.userRating! / 2).truncateToDouble() + ? '${(episode.userRating! / 2).toInt()}' + : formatRating(episode.userRating! / 2), + style: mutedStyle, + ), + ], + ), + ); + } + + for (final label in qualityLabels) { + addSeparator(); + children.add(Text(label, style: mutedStyle)); + } + + return Wrap(crossAxisAlignment: .center, runSpacing: 4, children: children); } @override @@ -111,6 +133,7 @@ class _EpisodeCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin stream.kind == MediaStreamKind.video); + expect(video.codec, 'hevc'); + expect(video.frameRate, closeTo(23.976, 0.001)); + expect(video.hdr, isTrue); + expect(video.dolbyVision, isTrue); + expect(video.dolbyVisionProfile, 8); + final audio = v.parts.single.streams.firstWhere((stream) => stream.kind == MediaStreamKind.audio); + expect(audio.codec, 'eac3'); + expect(audio.channels, 6); + expect(audio.selected, isTrue); + }); + + test('Media-level audio and file hints backfill streams when Part.Stream is absent', () { + final json = { + 'ratingKey': '6048', + 'type': 'episode', + 'title': 'Hello, Ms. Cobel', + 'Media': [ + { + 'id': '6136', + 'videoResolution': '4k', + 'videoCodec': 'hevc', + 'audioCodec': 'eac3', + 'audioChannels': '6', + 'width': '3840', + 'height': '1606', + 'container': 'mkv', + 'Part': [ + { + 'id': '6154', + 'key': '/library/parts/6154/file.mkv', + 'file': '/tv/Severance.S02.Hybrid.MULTI.2160p.WEB-DL.DV.HDR.H265-AOC/S02/S02E01.mkv', + }, + ], + }, + ], + }; + + final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId)); + final part = item.mediaVersions!.single.parts.single; + final video = part.streams.firstWhere((stream) => stream.kind == MediaStreamKind.video); + final audio = part.streams.firstWhere((stream) => stream.kind == MediaStreamKind.audio); + + expect(video.codec, 'hevc'); + expect(video.hdr, isTrue); + expect(video.dolbyVision, isTrue); + expect(video.dolbyVisionProfile, isNull); + expect(audio.codec, 'eac3'); + expect(audio.channels, 6); + expect(audio.selected, isTrue); }); test('Image array (clearLogo, backgroundSquare) is hoisted onto top-level fields', () { diff --git a/test/utils/media_quality_labels_test.dart b/test/utils/media_quality_labels_test.dart new file mode 100644 index 00000000..66d95565 --- /dev/null +++ b/test/utils/media_quality_labels_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +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/plex_mappers.dart'; +import 'package:plezy/utils/media_quality_labels.dart'; + +void main() { + group('buildMediaQualityLabels', () { + test('formats resolution, Dolby Vision, and Atmos audio', () { + final item = _episodeWithVersion( + MediaVersion( + id: '1', + videoResolution: '4k', + parts: const [ + MediaPart( + id: 'part-1', + streams: [ + MediaStream(id: 'video', kind: MediaStreamKind.video, hdr: true, dolbyVision: true), + MediaStream( + id: 'audio', + kind: MediaStreamKind.audio, + codec: 'truehd', + displayTitle: 'English (TrueHD Atmos 7.1)', + channels: 8, + selected: true, + ), + ], + ), + ], + ), + ); + + expect(buildMediaQualityLabels(item), ['4K', 'DV', 'TrueHD Atmos']); + }); + + test('formats Dolby Vision profile when stream metadata includes it', () { + final item = _episodeWithVersion( + MediaVersion( + id: '1', + videoResolution: '4k', + parts: const [ + MediaPart( + id: 'part-1', + streams: [ + MediaStream( + id: 'video', + kind: MediaStreamKind.video, + hdr: true, + dolbyVision: true, + dolbyVisionProfile: 8, + ), + MediaStream(id: 'audio', kind: MediaStreamKind.audio, codec: 'eac3', channels: 6), + ], + ), + ], + ), + ); + + expect(buildMediaQualityLabels(item), ['4K', 'DV P8', 'EAC3 5.1']); + }); + + test('formats HDR and surround channel count', () { + final item = _episodeWithVersion( + MediaVersion( + id: '1', + videoResolution: '1080', + parts: const [ + MediaPart( + id: 'part-1', + streams: [ + MediaStream(id: 'video', kind: MediaStreamKind.video, hdr: true), + MediaStream(id: 'audio', kind: MediaStreamKind.audio, codec: 'eac3', channels: 6), + ], + ), + ], + ), + ); + + expect(buildMediaQualityLabels(item), ['1080p', 'HDR', 'EAC3 5.1']); + }); + + test('formats Plex season child fallback metadata', () { + final item = PlexMappers.mediaItemFromJson({ + 'ratingKey': '6048', + 'type': 'episode', + 'title': 'Hello, Ms. Cobel', + 'Media': [ + { + 'id': '6136', + 'audioChannels': '6', + 'audioCodec': 'eac3', + 'videoCodec': 'hevc', + 'videoResolution': '4k', + 'width': '3840', + 'height': '1606', + 'Part': [ + { + 'id': '6154', + 'key': '/library/parts/6154/file.mkv', + 'file': '/tv/Severance.S02.Hybrid.MULTI.2160p.WEB-DL.DV.HDR.H265-AOC/S02/S02E01.mkv', + }, + ], + }, + ], + }, serverId: ServerId('plex')); + + expect(buildMediaQualityLabels(item), ['4K', 'DV', 'EAC3 5.1']); + }); + + test('formats Plex movie full-detail stream metadata', () { + final item = PlexMappers.mediaItemFromJson({ + 'ratingKey': 'movie-1', + 'type': 'movie', + 'title': 'Movie', + 'Media': [ + { + 'id': 'media-1', + 'audioChannels': 6, + 'audioCodec': 'eac3', + 'videoCodec': 'hevc', + 'videoResolution': '4k', + 'Part': [ + { + 'id': 'part-1', + 'key': '/library/parts/part-1/file.mkv', + 'Stream': [ + { + 'id': 1, + 'streamType': 1, + 'codec': 'hevc', + 'DOVIProfile': 8, + 'DOVIPresent': 1, + 'DOVIBLCompatID': 1, + 'colorTrc': 'smpte2084', + 'colorPrimaries': 'bt2020', + 'colorSpace': 'bt2020nc', + }, + {'id': 2, 'streamType': 2, 'codec': 'eac3', 'channels': 6, 'selected': 1}, + ], + }, + ], + }, + ], + }, serverId: ServerId('plex')); + + expect(buildMediaQualityLabels(item), ['4K', 'DV P8', 'EAC3 5.1']); + }); + + test('uses selected audio stream and stereo label', () { + final item = _episodeWithVersion( + MediaVersion( + id: '1', + width: 1280, + height: 720, + parts: const [ + MediaPart( + id: 'part-1', + streams: [ + MediaStream(id: 'video', kind: MediaStreamKind.video), + MediaStream(id: 'audio-1', kind: MediaStreamKind.audio, codec: 'ac3', channels: 6), + MediaStream(id: 'audio-2', kind: MediaStreamKind.audio, codec: 'aac', channels: 2, selected: true), + ], + ), + ], + ), + ); + + expect(buildMediaQualityLabels(item), ['720p', 'AAC Stereo']); + }); + + test('returns empty labels when no media versions exist', () { + expect(buildMediaQualityLabels(_episodeWithVersion(null)), isEmpty); + }); + }); +} + +MediaItem _episodeWithVersion(MediaVersion? version) { + return MediaItem( + id: 'episode-1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode', + mediaVersions: version == null ? null : [version], + ); +}