feat(jellyfin): show media quality labels

This commit is contained in:
edde746
2026-06-04 09:56:10 +02:00
parent 0e7ff609b6
commit e5bbdc06f9
7 changed files with 283 additions and 76 deletions
@@ -28,6 +28,11 @@ List<Map<String, dynamic>> _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,
+101
View File
@@ -0,0 +1,101 @@
import '../media/media_display_criteria.dart';
import '../utils/json_utils.dart';
MediaDisplayCriteria? jellyfinDisplayCriteriaFromStream(
Map<String, dynamic> source,
Map<String, dynamic>? 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<String, dynamic> 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<Object>().map((value) => value.toString().toLowerCase()).join(' ');
return text.contains('dovi') || text.contains('dolby vision') || text.contains('dolbyvision');
}
int? jellyfinDolbyVisionProfile(Map<String, dynamic> videoStream) => flexibleInt(videoStream['DvProfile']);
bool jellyfinVideoStreamIsHdr(Map<String, dynamic> source, Map<String, dynamic> videoStream) {
if (jellyfinVideoStreamIsDolbyVision(videoStream)) return true;
final criteria = jellyfinDisplayCriteriaFromStream(source, videoStream);
if (criteria?.isHdr == true) return true;
final range = [
videoStream['VideoRangeType'],
videoStream['VideoRange'],
].whereType<Object>().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<String>().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
+29 -3
View File
@@ -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<String, dynamic>) 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<MediaStream> _mediaStreams(Object? raw) {
static List<MediaStream> _mediaStreams(Object? raw, {Map<String, dynamic>? source}) {
if (raw is! List) return const [];
final result = <MediaStream>[];
final defaultAudioStreamIndex = flexibleInt(source?['DefaultAudioStreamIndex']);
final defaultSubtitleStreamIndex = flexibleInt(source?['DefaultSubtitleStreamIndex']);
for (final s in raw) {
if (s is! Map<String, dynamic>) 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 <String, dynamic>{}, 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<String, dynamic> item, String type) {
final tags = item['ImageTags'];
final backdropTags = item['BackdropImageTags'];
+2 -70
View File
@@ -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<String, dynamic> source, Map<String, dynamic>? 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<String>().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
List<MediaAudioTrack> _withDefaultAudioSelection(List<MediaAudioTrack> tracks, int? defaultStreamIndex) {
if (defaultStreamIndex == null) return tracks;
return [