feat(jellyfin): show media quality labels
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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]'), '');
|
||||
@@ -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'];
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user