fix(jellycompat): honor codec profile playback limits

This commit is contained in:
Quick
2026-06-17 13:12:18 -04:00
parent 20e0dd7fe2
commit cf3e68e02a
11 changed files with 1034 additions and 26 deletions
+5
View File
@@ -2526,6 +2526,10 @@ func toVideoTrackRecords(tracks []models.VideoTrack) []VideoTrackRecord {
Title: track.Title,
Codec: track.Codec,
DolbyVision: track.DolbyVision,
DVProfile: track.DVProfile,
DVBLCompatID: track.DVBLCompatID,
DVELPresent: track.DVELPresent,
HDR10Plus: track.HDR10Plus,
Profile: track.Profile,
Level: track.Level,
Width: track.Width,
@@ -2535,6 +2539,7 @@ func toVideoTrackRecords(tracks []models.VideoTrack) []VideoTrackRecord {
FrameRate: track.FrameRate,
Bitrate: track.Bitrate,
VideoRange: track.VideoRange,
VideoRangeType: track.VideoRangeType,
ColorPrimaries: track.ColorPrimaries,
ColorSpace: track.ColorSpace,
ColorTransfer: track.ColorTransfer,
+5
View File
@@ -142,6 +142,10 @@ type VideoTrackRecord struct {
Title string `json:"title,omitempty"`
Codec string `json:"codec,omitempty"`
DolbyVision string `json:"dolby_vision,omitempty"`
DVProfile int `json:"dv_profile,omitempty"`
DVBLCompatID int `json:"dv_bl_compat_id,omitempty"`
DVELPresent bool `json:"dv_el_present,omitempty"`
HDR10Plus bool `json:"hdr10_plus,omitempty"`
Profile string `json:"profile,omitempty"`
Level int `json:"level,omitempty"`
Width int `json:"width,omitempty"`
@@ -151,6 +155,7 @@ type VideoTrackRecord struct {
FrameRate string `json:"frame_rate,omitempty"`
Bitrate int `json:"bitrate,omitempty"`
VideoRange string `json:"video_range,omitempty"`
VideoRangeType string `json:"video_range_type,omitempty"`
ColorPrimaries string `json:"color_primaries,omitempty"`
ColorSpace string `json:"color_space,omitempty"`
ColorTransfer string `json:"color_transfer,omitempty"`
+41 -9
View File
@@ -17,6 +17,7 @@ type DeviceProfile struct {
MaxStreamingBitrate int64 `json:"MaxStreamingBitrate,omitempty"`
DirectPlayProfiles []DirectPlayProfile `json:"DirectPlayProfiles,omitempty"`
TranscodingProfiles []TranscodingProfile `json:"TranscodingProfiles,omitempty"`
CodecProfiles []CodecProfile `json:"CodecProfiles,omitempty"`
}
type DirectPlayProfile struct {
@@ -35,6 +36,20 @@ type TranscodingProfile struct {
AudioCodec string `json:"AudioCodec,omitempty"`
}
type CodecProfile struct {
Type string `json:"Type,omitempty"`
Codec string `json:"Codec,omitempty"`
Conditions []ProfileCondition `json:"Conditions,omitempty"`
ApplyConditions []ProfileCondition `json:"ApplyConditions,omitempty"`
}
type ProfileCondition struct {
Condition string `json:"Condition,omitempty"`
Property string `json:"Property,omitempty"`
Value string `json:"Value,omitempty"`
IsRequired bool `json:"IsRequired,omitempty"`
}
// DeviceProfileStore keeps the last reported device profile per compat token.
type DeviceProfileStore struct {
mu sync.RWMutex
@@ -104,22 +119,28 @@ func (p DeviceProfile) HasData() bool {
return strings.TrimSpace(p.Name) != "" ||
p.MaxStreamingBitrate > 0 ||
len(p.DirectPlayProfiles) > 0 ||
len(p.TranscodingProfiles) > 0
len(p.TranscodingProfiles) > 0 ||
len(p.CodecProfiles) > 0
}
// SupportsDirectPlay reports whether a version can be served as-is.
func (p DeviceProfile) SupportsDirectPlay(version catalog.FileVersion) bool {
return p.SupportsDirectPlayForAudioStream(version, nil)
}
func (p DeviceProfile) SupportsDirectPlayForAudioStream(version catalog.FileVersion, audioStreamIndex *int) bool {
if len(p.DirectPlayProfiles) == 0 {
return true
return p.codecProfileCompatibility(version, audioStreamIndex).supportsDirectPlay()
}
audioCodec := compatAudioCodec(version, audioStreamIndex)
for _, profile := range p.DirectPlayProfiles {
if !matchesVideoType(profile.Type) {
continue
}
if matchesCSV(profile.Container, version.Container) &&
matchesCSV(profile.VideoCodec, version.CodecVideo) &&
matchesCSV(profile.AudioCodec, version.CodecAudio) {
return true
matchesCSV(profile.AudioCodec, audioCodec) {
return p.codecProfileCompatibility(version, audioStreamIndex).supportsDirectPlay()
}
}
return false
@@ -187,15 +208,19 @@ func DefaultDeviceProfile() DeviceProfile {
// source video codec for a remux-style stream, regardless of whether the audio
// codec must be transcoded separately.
func (p DeviceProfile) SupportsVideoCodecForDirectStream(version catalog.FileVersion) bool {
return p.SupportsVideoCodecForDirectStreamForAudioStream(version, nil)
}
func (p DeviceProfile) SupportsVideoCodecForDirectStreamForAudioStream(version catalog.FileVersion, audioStreamIndex *int) bool {
if len(p.DirectPlayProfiles) == 0 {
return true
return p.codecProfileCompatibility(version, audioStreamIndex).VideoSupported
}
for _, profile := range p.DirectPlayProfiles {
if !matchesVideoType(profile.Type) {
continue
}
if matchesCSV(profile.VideoCodec, version.CodecVideo) {
return true
return p.codecProfileCompatibility(version, audioStreamIndex).VideoSupported
}
}
return false
@@ -204,15 +229,20 @@ func (p DeviceProfile) SupportsVideoCodecForDirectStream(version catalog.FileVer
// SupportsAudioCodecForDirectStream reports whether the client can accept the
// source audio codec in a remux-style stream, regardless of container.
func (p DeviceProfile) SupportsAudioCodecForDirectStream(version catalog.FileVersion) bool {
return p.SupportsAudioCodecForDirectStreamForAudioStream(version, nil)
}
func (p DeviceProfile) SupportsAudioCodecForDirectStreamForAudioStream(version catalog.FileVersion, audioStreamIndex *int) bool {
if len(p.DirectPlayProfiles) == 0 {
return true
return p.codecProfileCompatibility(version, audioStreamIndex).AudioSupported
}
audioCodec := compatAudioCodec(version, audioStreamIndex)
for _, profile := range p.DirectPlayProfiles {
if !matchesVideoType(profile.Type) {
continue
}
if matchesCSV(profile.AudioCodec, version.CodecAudio) {
return true
if matchesCSV(profile.AudioCodec, audioCodec) {
return p.codecProfileCompatibility(version, audioStreamIndex).AudioSupported
}
}
return false
@@ -276,6 +306,8 @@ func normalizeCompatToken(raw string) string {
return "mpegts"
case "x-matroska":
return "mkv"
case "h265":
return "hevc"
default:
return token
}
@@ -0,0 +1,353 @@
package jellycompat
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"unicode"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
type codecProfileCompatibility struct {
VideoSupported bool
AudioSupported bool
}
func (c codecProfileCompatibility) supportsDirectPlay() bool {
return c.VideoSupported && c.AudioSupported
}
type conditionValue struct {
text string
number int
hasNum bool
}
type conditionValues map[string]conditionValue
func (c *ProfileCondition) UnmarshalJSON(data []byte) error {
var raw struct {
Condition string `json:"Condition"`
Property string `json:"Property"`
Value any `json:"Value"`
IsRequired bool `json:"IsRequired"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
value, err := stringifyConditionValue(raw.Value)
if err != nil {
return err
}
c.Condition = raw.Condition
c.Property = raw.Property
c.Value = value
c.IsRequired = raw.IsRequired
return nil
}
func stringifyConditionValue(value any) (string, error) {
switch v := value.(type) {
case nil:
return "", nil
case string:
return v, nil
case float64:
if v == float64(int64(v)) {
return strconv.FormatInt(int64(v), 10), nil
}
return strconv.FormatFloat(v, 'f', -1, 64), nil
case bool:
return strconv.FormatBool(v), nil
default:
data, err := json.Marshal(v)
if err != nil {
return "", fmt.Errorf("marshal profile condition value: %w", err)
}
return string(data), nil
}
}
func (p DeviceProfile) codecProfileCompatibility(version catalog.FileVersion, audioStreamIndex *int) codecProfileCompatibility {
compat := codecProfileCompatibility{VideoSupported: true, AudioSupported: true}
if len(p.CodecProfiles) == 0 {
return compat
}
values := buildConditionValues(version, audioStreamIndex)
for _, profile := range p.CodecProfiles {
target := codecProfileTarget(profile)
if target == "" || !codecProfileApplies(profile, version, audioStreamIndex) {
continue
}
if !conditionsMatch(profile.ApplyConditions, values) {
continue
}
if conditionsMatch(profile.Conditions, values) {
continue
}
if target == "audio" || conditionsOnlyTargetAudio(profile.Conditions) {
compat.AudioSupported = false
} else {
compat.VideoSupported = false
}
}
return compat
}
func codecProfileTarget(profile CodecProfile) string {
typ := normalizeConditionToken(profile.Type)
switch {
case typ == "videoaudio" || typ == "audio":
return "audio"
case typ == "" || typ == "video":
return "video"
default:
return ""
}
}
func codecProfileApplies(profile CodecProfile, version catalog.FileVersion, audioStreamIndex *int) bool {
if strings.TrimSpace(profile.Codec) == "" {
return true
}
switch codecProfileTarget(profile) {
case "audio":
return matchesCSV(profile.Codec, compatAudioCodec(version, audioStreamIndex))
case "video":
return matchesCSV(profile.Codec, version.CodecVideo)
default:
return false
}
}
func conditionsOnlyTargetAudio(conditions []ProfileCondition) bool {
if len(conditions) == 0 {
return false
}
for _, condition := range conditions {
if normalizeConditionToken(condition.Property) != "audiochannels" {
return false
}
}
return true
}
func conditionsMatch(conditions []ProfileCondition, values conditionValues) bool {
for _, condition := range conditions {
if !conditionMatches(condition, values) {
return false
}
}
return true
}
func conditionMatches(condition ProfileCondition, values conditionValues) bool {
actual, ok := values[normalizeConditionToken(condition.Property)]
if !ok {
return false
}
switch normalizeConditionToken(condition.Condition) {
case "equals":
return stringInConditionSet(actual.text, condition.Value)
case "equalsany", "incollection":
return stringInConditionSet(actual.text, condition.Value)
case "notequals":
return !stringInConditionSet(actual.text, condition.Value)
case "notequalsany", "notincollection":
return !stringInConditionSet(actual.text, condition.Value)
case "lessthanequal", "lessthanorequal", "lowerthanequal", "lowerthanorequal":
want, ok := firstConditionNumber(condition.Value)
return ok && actual.hasNum && actual.number <= want
case "greaterthanequal", "greaterthanorequal":
want, ok := firstConditionNumber(condition.Value)
return ok && actual.hasNum && actual.number >= want
case "lessthan", "lowerthan":
want, ok := firstConditionNumber(condition.Value)
return ok && actual.hasNum && actual.number < want
case "greaterthan":
want, ok := firstConditionNumber(condition.Value)
return ok && actual.hasNum && actual.number > want
default:
return false
}
}
func buildConditionValues(version catalog.FileVersion, audioStreamIndex *int) conditionValues {
video := compatPrimaryVideoTrack(version)
audio := compatAudioTrack(version, audioStreamIndex)
values := conditionValues{
"videorangetype": {text: compatVideoRangeType(video, version.HDR)},
"videoprofile": {text: video.Profile},
"videolevel": intConditionValue(video.Level),
"refframes": intConditionValue(video.ReferenceFrames),
"width": intConditionValue(video.Width),
"height": intConditionValue(video.Height),
"videobitdepth": intConditionValue(video.BitDepth),
"audiochannels": intConditionValue(audio.Channels),
}
return values
}
func intConditionValue(value int) conditionValue {
return conditionValue{text: strconv.Itoa(value), number: value, hasNum: value > 0}
}
func compatPrimaryVideoTrack(version catalog.FileVersion) models.VideoTrack {
if len(version.VideoTracks) > 0 {
return version.VideoTracks[0]
}
return models.VideoTrack{Codec: version.CodecVideo}
}
func compatAudioTrack(version catalog.FileVersion, streamIndex *int) models.AudioTrack {
if streamIndex != nil {
audioIndex := *streamIndex - len(version.VideoTracks)
if audioIndex >= 0 && audioIndex < len(version.AudioTracks) {
return version.AudioTracks[audioIndex]
}
}
for _, track := range version.AudioTracks {
if track.Default {
return track
}
}
if len(version.AudioTracks) > 0 {
return version.AudioTracks[0]
}
return models.AudioTrack{Codec: version.CodecAudio}
}
func compatAudioCodec(version catalog.FileVersion, streamIndex *int) string {
if track := compatAudioTrack(version, streamIndex); track.Codec != "" {
return track.Codec
}
return version.CodecAudio
}
func compatVideoRange(track models.VideoTrack, versionHDR bool) string {
rangeType := compatVideoRangeType(track, versionHDR)
switch rangeType {
case "SDR":
return "SDR"
case "Unknown":
return "Unknown"
default:
return "HDR"
}
}
func compatVideoRangeType(track models.VideoTrack, versionHDR bool) string {
if value := strings.TrimSpace(track.VideoRangeType); value != "" {
return value
}
if profile := compatDolbyVisionProfile(track); profile > 0 {
switch profile {
case 5:
return "DOVI"
case 7:
if track.HDR10Plus {
return "DOVIWithELHDR10Plus"
}
return "DOVIWithEL"
case 8:
if track.HDR10Plus {
return "DOVIWithHDR10Plus"
}
switch track.DVBLCompatID {
case 1:
return "DOVIWithHDR10"
case 2:
return "DOVIWithSDR"
case 4:
return "DOVIWithHLG"
default:
if compatIsHLG(track.ColorTransfer) {
return "DOVIWithHLG"
}
if compatIsHDR(track.ColorTransfer) || versionHDR || strings.EqualFold(track.VideoRange, "HDR") {
return "DOVIWithHDR10"
}
return "DOVIWithSDR"
}
default:
return "DOVI"
}
}
if track.HDR10Plus {
return "HDR10Plus"
}
if compatIsHLG(track.ColorTransfer) {
return "HLG"
}
if compatIsHDR(track.ColorTransfer) || versionHDR || strings.EqualFold(track.VideoRange, "HDR") {
return "HDR10"
}
return "SDR"
}
func compatDolbyVisionProfile(track models.VideoTrack) int {
if track.DVProfile > 0 {
return track.DVProfile
}
raw := strings.ToLower(track.DolbyVision)
for _, field := range strings.FieldsFunc(raw, func(r rune) bool {
return r < '0' || r > '9'
}) {
if value, err := strconv.Atoi(field); err == nil && value > 0 {
return value
}
}
return 0
}
func compatIsHDR(colorTransfer string) bool {
value := strings.ToLower(colorTransfer)
return strings.Contains(value, "smpte2084") || strings.Contains(value, "arib-std-b67")
}
func compatIsHLG(colorTransfer string) bool {
return strings.Contains(strings.ToLower(colorTransfer), "arib-std-b67")
}
func stringInConditionSet(actual, rawSet string) bool {
actual = normalizeConditionToken(actual)
for _, value := range splitConditionSet(rawSet) {
if actual == normalizeConditionToken(value) {
return true
}
}
return false
}
func splitConditionSet(raw string) []string {
return strings.FieldsFunc(raw, func(r rune) bool {
return r == '|' || r == ','
})
}
func firstConditionNumber(raw string) (int, bool) {
for _, value := range splitConditionSet(raw) {
number, err := strconv.Atoi(strings.TrimSpace(value))
if err == nil {
return number, true
}
}
return 0, false
}
func normalizeConditionToken(raw string) string {
var b strings.Builder
for _, r := range strings.ToLower(strings.TrimSpace(raw)) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
@@ -0,0 +1,352 @@
package jellycompat
import (
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestDecodeDeviceProfileKeepsCodecProfiles(t *testing.T) {
profile, err := decodeDeviceProfile(strings.NewReader(`{
"DeviceProfile": {
"CodecProfiles": [{
"Type": "Video",
"Codec": "hevc",
"Conditions": [{
"Condition": "LessThanEqual",
"Property": "VideoLevel",
"Value": 153,
"IsRequired": false
}],
"ApplyConditions": [{
"Condition": "EqualsAny",
"Property": "VideoProfile",
"Value": "main 10"
}]
}]
}
}`))
if err != nil {
t.Fatalf("decodeDeviceProfile: %v", err)
}
if !profile.HasData() {
t.Fatal("HasData = false, want true for CodecProfiles-only payload")
}
if len(profile.CodecProfiles) != 1 {
t.Fatalf("CodecProfiles length = %d, want 1", len(profile.CodecProfiles))
}
condition := profile.CodecProfiles[0].Conditions[0]
if condition.Value != "153" {
t.Fatalf("condition Value = %q, want numeric value stringified", condition.Value)
}
}
func TestBuildPlaybackSourceCodecProfiles(t *testing.T) {
h := &PlaybackHandler{codec: NewResourceIDCodec()}
baseVersion := catalog.FileVersion{
FileID: 1,
Resolution: "1080p",
Container: "mkv",
CodecVideo: "hevc",
CodecAudio: "truehd",
VideoTracks: []models.VideoTrack{{Codec: "hevc", Profile: "Main 10", Level: 153, Width: 1920, Height: 1080, BitDepth: 10}},
AudioTracks: []models.AudioTrack{{Codec: "truehd", Channels: 8, Default: true}},
}
directProfile := DeviceProfile{
DirectPlayProfiles: []DirectPlayProfile{{
Type: "Video",
Container: "mkv",
VideoCodec: "hevc",
AudioCodec: "truehd",
}},
TranscodingProfiles: []TranscodingProfile{{
Type: "Video",
Protocol: "hls",
Container: "ts",
VideoCodec: "h264",
AudioCodec: "aac",
}},
}
tests := []struct {
name string
version catalog.FileVersion
codecProfiles []CodecProfile
wantDirectPlay bool
wantDirectStream bool
wantTranscoding bool
wantTranscodeAudio bool
}{
{
name: "unsupported dovi enhancement layer blocks video copy",
version: withVideoTrack(baseVersion, models.VideoTrack{
Codec: "hevc",
Profile: "Main 10",
Level: 153,
Width: 3840,
Height: 2160,
BitDepth: 10,
VideoRangeType: "DOVIWithEL",
}, "2160p"),
codecProfiles: []CodecProfile{unsupportedRangeProfile("hevc", "DOVIInvalid|DOVIWithEL|DOVIWithELHDR10Plus")},
wantDirectPlay: false,
wantDirectStream: false,
wantTranscoding: true,
wantTranscodeAudio: false,
},
{
name: "dolby vision profile 8 hdr10 is direct playable when not excluded",
version: withVideoTrack(baseVersion, models.VideoTrack{
Codec: "hevc",
Profile: "Main 10",
Level: 153,
Width: 3840,
Height: 2160,
BitDepth: 10,
VideoRangeType: "DOVIWithHDR10",
}, "2160p"),
codecProfiles: []CodecProfile{unsupportedRangeProfile("hevc", "DOVIInvalid|DOVIWithEL|DOVIWithELHDR10Plus")},
wantDirectPlay: true,
wantDirectStream: true,
wantTranscoding: true,
wantTranscodeAudio: false,
},
{
name: "audio channel limit preserves video copy and transcodes audio",
codecProfiles: []CodecProfile{{
Type: "VideoAudio",
Conditions: []ProfileCondition{{
Condition: "LessThanEqual",
Property: "AudioChannels",
Value: "2",
}},
}},
wantDirectPlay: false,
wantDirectStream: false,
wantTranscoding: true,
wantTranscodeAudio: true,
},
{
name: "hevc level limit blocks video copy",
codecProfiles: []CodecProfile{{
Type: "Video",
Codec: "hevc",
Conditions: []ProfileCondition{{
Condition: "LessThanEqual",
Property: "VideoLevel",
Value: "150",
}},
ApplyConditions: []ProfileCondition{{
Condition: "Equals",
Property: "VideoProfile",
Value: "main 10",
}},
}},
wantDirectPlay: false,
wantDirectStream: false,
wantTranscoding: true,
wantTranscodeAudio: false,
},
{
name: "width limit blocks video copy",
codecProfiles: []CodecProfile{{
Type: "Video",
Codec: "hevc",
Conditions: []ProfileCondition{{
Condition: "LessThanEqual",
Property: "Width",
Value: "1280",
}},
}},
wantDirectPlay: false,
wantDirectStream: false,
wantTranscoding: true,
wantTranscodeAudio: false,
},
{
name: "bit depth limit blocks video copy",
codecProfiles: []CodecProfile{{
Type: "Video",
Codec: "hevc",
Conditions: []ProfileCondition{{
Condition: "LessThanEqual",
Property: "VideoBitDepth",
Value: "8",
}},
}},
wantDirectPlay: false,
wantDirectStream: false,
wantTranscoding: true,
wantTranscodeAudio: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
version := tt.version
if version.FileID == 0 {
version = baseVersion
}
profile := directProfile
profile.CodecProfiles = tt.codecProfiles
source := h.buildPlaybackSource("item", "play", version, profile, playbackInfoRequest{}, true)
if source.SupportsDirectPlay != tt.wantDirectPlay {
t.Fatalf("SupportsDirectPlay = %v, want %v", source.SupportsDirectPlay, tt.wantDirectPlay)
}
if source.SupportsDirectStream != tt.wantDirectStream {
t.Fatalf("SupportsDirectStream = %v, want %v", source.SupportsDirectStream, tt.wantDirectStream)
}
if source.SupportsTranscoding != tt.wantTranscoding {
t.Fatalf("SupportsTranscoding = %v, want %v", source.SupportsTranscoding, tt.wantTranscoding)
}
if source.TranscodeAudio != tt.wantTranscodeAudio {
t.Fatalf("TranscodeAudio = %v, want %v", source.TranscodeAudio, tt.wantTranscodeAudio)
}
})
}
}
func TestBuildPlaybackSourceCodecProfiles_UnsupportedDOVIWithELRespects4KGate(t *testing.T) {
h := &PlaybackHandler{codec: NewResourceIDCodec()}
version := catalog.FileVersion{
FileID: 1,
Resolution: "2160p",
Container: "mkv",
CodecVideo: "hevc",
CodecAudio: "truehd",
VideoTracks: []models.VideoTrack{{
Codec: "hevc",
Profile: "Main 10",
Level: 153,
Width: 3840,
Height: 2160,
BitDepth: 10,
VideoRangeType: "DOVIWithEL",
}},
AudioTracks: []models.AudioTrack{{Codec: "truehd", Channels: 8, Default: true}},
}
profile := DeviceProfile{
DirectPlayProfiles: []DirectPlayProfile{{
Type: "Video",
Container: "mkv",
VideoCodec: "hevc",
AudioCodec: "truehd",
}},
TranscodingProfiles: []TranscodingProfile{{
Type: "Video",
Protocol: "hls",
Container: "ts",
VideoCodec: "h264",
AudioCodec: "aac",
}},
CodecProfiles: []CodecProfile{unsupportedRangeProfile("hevc", "DOVIWithEL")},
}
source := h.buildPlaybackSource("item", "play", version, profile, playbackInfoRequest{}, false)
if source.SupportsDirectPlay || source.SupportsDirectStream || source.SupportsTranscoding {
t.Fatalf("source supports playback unexpectedly: direct=%v stream=%v transcode=%v", source.SupportsDirectPlay, source.SupportsDirectStream, source.SupportsTranscoding)
}
}
func TestBuildMediaStreamsUsesJellyfinVideoRangeType(t *testing.T) {
version := catalog.FileVersion{
HDR: true,
VideoTracks: []models.VideoTrack{{
Codec: "hevc",
DolbyVision: "Profile 7",
VideoRange: "DolbyVision",
}},
}
streams := buildMediaStreams("item", "source", version)
if len(streams) != 1 {
t.Fatalf("streams length = %d, want 1", len(streams))
}
if streams[0].VideoRange != "HDR" {
t.Fatalf("VideoRange = %q, want HDR", streams[0].VideoRange)
}
if streams[0].VideoRangeType != "DOVIWithEL" {
t.Fatalf("VideoRangeType = %q, want DOVIWithEL", streams[0].VideoRangeType)
}
}
func TestCodecProfileAVCRefFramesConstraint(t *testing.T) {
version := catalog.FileVersion{
FileID: 1,
Resolution: "1080p",
Container: "mp4",
CodecVideo: "h264",
CodecAudio: "aac",
VideoTracks: []models.VideoTrack{{
Codec: "h264",
Profile: "High",
ReferenceFrames: 8,
Width: 1920,
Height: 1080,
}},
AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2, Default: true}},
}
profile := DeviceProfile{
DirectPlayProfiles: []DirectPlayProfile{{
Type: "Video",
Container: "mp4",
VideoCodec: "h264",
AudioCodec: "aac",
}},
TranscodingProfiles: []TranscodingProfile{{
Type: "Video",
Protocol: "hls",
Container: "ts",
VideoCodec: "h264",
AudioCodec: "aac",
}},
CodecProfiles: []CodecProfile{{
Type: "Video",
Codec: "h264",
Conditions: []ProfileCondition{{
Condition: "LessThanEqual",
Property: "RefFrames",
Value: "4",
}},
ApplyConditions: []ProfileCondition{{
Condition: "GreaterThanEqual",
Property: "Width",
Value: "1900",
}},
}},
}
source := (&PlaybackHandler{codec: NewResourceIDCodec()}).buildPlaybackSource("item", "play", version, profile, playbackInfoRequest{}, true)
if source.SupportsDirectPlay || source.SupportsDirectStream {
t.Fatalf("video copy was allowed unexpectedly: direct=%v stream=%v", source.SupportsDirectPlay, source.SupportsDirectStream)
}
if !source.SupportsTranscoding {
t.Fatal("SupportsTranscoding = false, want true")
}
}
func withVideoTrack(version catalog.FileVersion, track models.VideoTrack, resolution string) catalog.FileVersion {
version.VideoTracks = []models.VideoTrack{track}
version.Resolution = resolution
return version
}
func unsupportedRangeProfile(codec, ranges string) CodecProfile {
return CodecProfile{
Type: "Video",
Codec: codec,
Conditions: []ProfileCondition{{
Condition: "NotEquals",
Property: "VideoRangeType",
Value: ranges,
}},
ApplyConditions: []ProfileCondition{{
Condition: "EqualsAny",
Property: "VideoRangeType",
Value: ranges,
}},
}
}
+12 -12
View File
@@ -523,9 +523,16 @@ func (h *PlaybackHandler) buildPlaybackSource(
allowVideoCopy := boolDefault(req.AllowVideoStreamCopy, true)
allowAudioCopy := boolDefault(req.AllowAudioStreamCopy, true)
supportsDirectPlay := enableDirectPlay && profile.SupportsDirectPlay(version)
audioSupported := profile.SupportsAudioCodecForDirectStream(version)
videoSupported := profile.SupportsVideoCodecForDirectStream(version)
audioIndex := defaultAudioStreamIndex(version)
subtitleIndex := defaultSubtitleStreamIndex(version)
selectedAudioIndex := audioIndex
if req.AudioStreamIndex != nil && isValidCompatAudioStreamIndex(version, int(*req.AudioStreamIndex)) {
selectedAudioIndex = intPtr(int(*req.AudioStreamIndex))
}
supportsDirectPlay := enableDirectPlay && profile.SupportsDirectPlayForAudioStream(version, selectedAudioIndex)
audioSupported := profile.SupportsAudioCodecForDirectStreamForAudioStream(version, selectedAudioIndex)
videoSupported := profile.SupportsVideoCodecForDirectStreamForAudioStream(version, selectedAudioIndex)
transcodeAudio := enableDirectStream && allowVideoCopy && videoSupported && !audioSupported
supportsDirectStream := !transcodeAudio &&
enableDirectStream &&
@@ -541,13 +548,6 @@ func (h *PlaybackHandler) buildPlaybackSource(
supportsTranscoding = false
}
audioIndex := defaultAudioStreamIndex(version)
subtitleIndex := defaultSubtitleStreamIndex(version)
selectedAudioIndex := audioIndex
if req.AudioStreamIndex != nil && isValidCompatAudioStreamIndex(version, int(*req.AudioStreamIndex)) {
selectedAudioIndex = intPtr(int(*req.AudioStreamIndex))
}
return PlaybackMediaSource{
ID: sourceID,
FileID: version.FileID,
@@ -658,8 +658,8 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
Profile: track.Profile,
Level: track.Level,
AspectRatio: track.AspectRatio,
VideoRange: firstNonEmpty(track.VideoRange, "Unknown"),
VideoRangeType: firstNonEmpty(track.VideoRange, "Unknown"),
VideoRange: compatVideoRange(track, version.HDR),
VideoRangeType: compatVideoRangeType(track, version.HDR),
ColorPrimaries: track.ColorPrimaries,
ColorSpace: track.ColorSpace,
ColorTransfer: track.ColorTransfer,
+5
View File
@@ -144,6 +144,10 @@ type VideoTrack struct {
Title string `json:"title,omitempty"`
Codec string `json:"codec,omitempty"`
DolbyVision string `json:"dolby_vision,omitempty"`
DVProfile int `json:"dv_profile,omitempty"`
DVBLCompatID int `json:"dv_bl_compat_id,omitempty"`
DVELPresent bool `json:"dv_el_present,omitempty"`
HDR10Plus bool `json:"hdr10_plus,omitempty"`
Profile string `json:"profile,omitempty"`
Level int `json:"level,omitempty"`
Width int `json:"width,omitempty"`
@@ -153,6 +157,7 @@ type VideoTrack struct {
FrameRate string `json:"frame_rate,omitempty"`
Bitrate int `json:"bitrate,omitempty"`
VideoRange string `json:"video_range,omitempty"`
VideoRangeType string `json:"video_range_type,omitempty"`
ColorPrimaries string `json:"color_primaries,omitempty"`
ColorSpace string `json:"color_space,omitempty"`
ColorTransfer string `json:"color_transfer,omitempty"`
+99 -5
View File
@@ -99,6 +99,7 @@ type ffprobeSideData struct {
DVProfile int `json:"dv_profile"`
DVBlPresent int `json:"dv_bl_present"`
DVElPresent int `json:"dv_el_present"`
DVBLCompatID int `json:"dv_bl_signal_compatibility_id"`
}
// ffprobeDisp represents the disposition flags on a stream.
@@ -172,10 +173,15 @@ func convertProbeData(raw *ffprobeOutput) *ProbeData {
for _, s := range raw.Streams {
switch s.CodecType {
case "video":
dvProfile := dolbyVisionProfileNumber(s.SideDataList)
track := VideoTrackInfo{
Title: firstNonEmpty(s.Tags["title"], s.CodecLongName, strings.ToUpper(s.CodecName)),
Codec: s.CodecName,
DolbyVision: dolbyVisionProfile(s.SideDataList),
DVProfile: dvProfile,
DVBLCompatID: dolbyVisionBLCompatID(s.SideDataList),
DVELPresent: dolbyVisionELPresent(s.SideDataList),
HDR10Plus: hasHDR10Plus(s.SideDataList),
Profile: s.Profile,
Level: s.Level,
Width: s.Width,
@@ -185,6 +191,7 @@ func convertProbeData(raw *ffprobeOutput) *ProbeData {
FrameRate: normalizeFrameRate(s.AvgFrameRate),
Bitrate: parseNumeric(s.BitRate) / 1000,
VideoRange: videoRangeLabel(s),
VideoRangeType: videoRangeType(s),
ColorPrimaries: s.ColorPrimaries,
ColorSpace: s.ColorSpace,
ColorTransfer: s.ColorTransfer,
@@ -196,7 +203,7 @@ func convertProbeData(raw *ffprobeOutput) *ProbeData {
if pd.CodecVideo == "" {
pd.CodecVideo = s.CodecName
pd.Resolution = mapResolution(s.Width, s.Height)
pd.HDR = isHDR(s.ColorTransfer)
pd.HDR = isHDR(s.ColorTransfer) || dvProfile > 0 || track.HDR10Plus
}
case "audio":
track := AudioTrackInfo{
@@ -393,14 +400,97 @@ func videoRangeLabel(s ffprobeStream) string {
}
func dolbyVisionProfile(sideData []ffprobeSideData) string {
for _, data := range sideData {
if strings.EqualFold(data.SideDataType, "DOVI configuration record") && data.DVProfile > 0 {
return fmt.Sprintf("Profile %d", data.DVProfile)
}
if profile := dolbyVisionProfileNumber(sideData); profile > 0 {
return fmt.Sprintf("Profile %d", profile)
}
return ""
}
func dolbyVisionProfileNumber(sideData []ffprobeSideData) int {
for _, data := range sideData {
if strings.EqualFold(data.SideDataType, "DOVI configuration record") && data.DVProfile > 0 {
return data.DVProfile
}
}
return 0
}
func dolbyVisionBLCompatID(sideData []ffprobeSideData) int {
for _, data := range sideData {
if strings.EqualFold(data.SideDataType, "DOVI configuration record") && data.DVBLCompatID > 0 {
return data.DVBLCompatID
}
}
return 0
}
func dolbyVisionELPresent(sideData []ffprobeSideData) bool {
for _, data := range sideData {
if strings.EqualFold(data.SideDataType, "DOVI configuration record") {
return data.DVElPresent > 0
}
}
return false
}
func hasHDR10Plus(sideData []ffprobeSideData) bool {
for _, data := range sideData {
typ := strings.ToLower(data.SideDataType)
if strings.Contains(typ, "hdr10+") || strings.Contains(typ, "smpte2094-40") {
return true
}
}
return false
}
func videoRangeType(s ffprobeStream) string {
profile := dolbyVisionProfileNumber(s.SideDataList)
hdr10Plus := hasHDR10Plus(s.SideDataList)
if profile > 0 {
switch profile {
case 5:
return "DOVI"
case 7:
if hdr10Plus {
return "DOVIWithELHDR10Plus"
}
return "DOVIWithEL"
case 8:
if hdr10Plus {
return "DOVIWithHDR10Plus"
}
switch dolbyVisionBLCompatID(s.SideDataList) {
case 1:
return "DOVIWithHDR10"
case 2:
return "DOVIWithSDR"
case 4:
return "DOVIWithHLG"
default:
if isHLG(s.ColorTransfer) {
return "DOVIWithHLG"
}
if isHDR(s.ColorTransfer) {
return "DOVIWithHDR10"
}
return "DOVIWithSDR"
}
default:
return "DOVI"
}
}
if hdr10Plus {
return "HDR10Plus"
}
if isHLG(s.ColorTransfer) {
return "HLG"
}
if isHDR(s.ColorTransfer) {
return "HDR10"
}
return "SDR"
}
func subtitleResolutionLabel(s ffprobeStream) string {
if s.Width <= 0 || s.Height <= 0 {
return ""
@@ -453,6 +543,10 @@ func isHDR(colorTransfer string) bool {
return strings.Contains(ct, "smpte2084") || strings.Contains(ct, "arib-std-b67")
}
func isHLG(colorTransfer string) bool {
return strings.Contains(strings.ToLower(colorTransfer), "arib-std-b67")
}
// normalizeFormatTags lowercases tag keys so callers can look up
// "title", "artist", "album" without worrying about ffprobe's mixed-case
// output. Trims whitespace from values.
+152
View File
@@ -0,0 +1,152 @@
package scanner
import "testing"
func TestConvertProbeDataVideoRangeTypes(t *testing.T) {
tests := []struct {
name string
stream ffprobeStream
wantRange string
wantRangeType string
wantProfile int
wantCompatID int
wantEL bool
wantHDR10Plus bool
}{
{
name: "dolby vision profile 7 enhancement layer",
stream: ffprobeStream{
CodecType: "video",
CodecName: "hevc",
ColorTransfer: "smpte2084",
SideDataList: []ffprobeSideData{{
SideDataType: "DOVI configuration record",
DVProfile: 7,
DVElPresent: 1,
}},
},
wantRange: "DolbyVision",
wantRangeType: "DOVIWithEL",
wantProfile: 7,
wantEL: true,
},
{
name: "dolby vision profile 7 with hdr10 plus",
stream: ffprobeStream{
CodecType: "video",
CodecName: "hevc",
SideDataList: []ffprobeSideData{
{SideDataType: "DOVI configuration record", DVProfile: 7, DVElPresent: 1},
{SideDataType: "HDR Dynamic Metadata SMPTE2094-40 (HDR10+)"},
},
},
wantRange: "DolbyVision",
wantRangeType: "DOVIWithELHDR10Plus",
wantProfile: 7,
wantEL: true,
wantHDR10Plus: true,
},
{
name: "dolby vision profile 8 hdr10 base layer",
stream: ffprobeStream{
CodecType: "video",
CodecName: "hevc",
SideDataList: []ffprobeSideData{{
SideDataType: "DOVI configuration record",
DVProfile: 8,
DVBLCompatID: 1,
}},
},
wantRange: "DolbyVision",
wantRangeType: "DOVIWithHDR10",
wantProfile: 8,
wantCompatID: 1,
},
{
name: "dolby vision profile 8 hlg base layer",
stream: ffprobeStream{
CodecType: "video",
CodecName: "hevc",
SideDataList: []ffprobeSideData{{
SideDataType: "DOVI configuration record",
DVProfile: 8,
DVBLCompatID: 4,
}},
},
wantRange: "DolbyVision",
wantRangeType: "DOVIWithHLG",
wantProfile: 8,
wantCompatID: 4,
},
{
name: "hdr10 plus",
stream: ffprobeStream{
CodecType: "video",
CodecName: "hevc",
ColorTransfer: "smpte2084",
SideDataList: []ffprobeSideData{{SideDataType: "HDR10+ metadata"}},
},
wantRange: "HDR",
wantRangeType: "HDR10Plus",
wantHDR10Plus: true,
},
{
name: "hlg",
stream: ffprobeStream{
CodecType: "video",
CodecName: "hevc",
ColorTransfer: "arib-std-b67",
},
wantRange: "HDR",
wantRangeType: "HLG",
},
{
name: "hdr10",
stream: ffprobeStream{
CodecType: "video",
CodecName: "hevc",
ColorTransfer: "smpte2084",
},
wantRange: "HDR",
wantRangeType: "HDR10",
},
{
name: "sdr",
stream: ffprobeStream{
CodecType: "video",
CodecName: "h264",
},
wantRangeType: "SDR",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertProbeData(&ffprobeOutput{
Streams: []ffprobeStream{tt.stream},
})
if len(got.VideoTracks) != 1 {
t.Fatalf("VideoTracks length = %d, want 1", len(got.VideoTracks))
}
track := got.VideoTracks[0]
if track.VideoRange != tt.wantRange {
t.Fatalf("VideoRange = %q, want %q", track.VideoRange, tt.wantRange)
}
if track.VideoRangeType != tt.wantRangeType {
t.Fatalf("VideoRangeType = %q, want %q", track.VideoRangeType, tt.wantRangeType)
}
if track.DVProfile != tt.wantProfile {
t.Fatalf("DVProfile = %d, want %d", track.DVProfile, tt.wantProfile)
}
if track.DVBLCompatID != tt.wantCompatID {
t.Fatalf("DVBLCompatID = %d, want %d", track.DVBLCompatID, tt.wantCompatID)
}
if track.DVELPresent != tt.wantEL {
t.Fatalf("DVELPresent = %v, want %v", track.DVELPresent, tt.wantEL)
}
if track.HDR10Plus != tt.wantHDR10Plus {
t.Fatalf("HDR10Plus = %v, want %v", track.HDR10Plus, tt.wantHDR10Plus)
}
})
}
}
+5
View File
@@ -2712,6 +2712,10 @@ func applyProbeData(mf *models.MediaFile, probe *ProbeData, probeSource string)
Title: vt.Title,
Codec: vt.Codec,
DolbyVision: vt.DolbyVision,
DVProfile: vt.DVProfile,
DVBLCompatID: vt.DVBLCompatID,
DVELPresent: vt.DVELPresent,
HDR10Plus: vt.HDR10Plus,
Profile: vt.Profile,
Level: vt.Level,
Width: vt.Width,
@@ -2721,6 +2725,7 @@ func applyProbeData(mf *models.MediaFile, probe *ProbeData, probeSource string)
FrameRate: vt.FrameRate,
Bitrate: vt.Bitrate,
VideoRange: vt.VideoRange,
VideoRangeType: vt.VideoRangeType,
ColorPrimaries: vt.ColorPrimaries,
ColorSpace: vt.ColorSpace,
ColorTransfer: vt.ColorTransfer,
+5
View File
@@ -41,6 +41,10 @@ type VideoTrackInfo struct {
Title string
Codec string
DolbyVision string
DVProfile int
DVBLCompatID int
DVELPresent bool
HDR10Plus bool
Profile string
Level int
Width int
@@ -50,6 +54,7 @@ type VideoTrackInfo struct {
FrameRate string
Bitrate int
VideoRange string
VideoRangeType string
ColorPrimaries string
ColorSpace string
ColorTransfer string