From cf3e68e02a31ef770a8dfa41a449dde06f60b09c Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:24:02 -0400 Subject: [PATCH] fix(jellycompat): honor codec profile playback limits --- internal/catalogseed/service.go | 5 + internal/catalogseed/types.go | 5 + internal/jellycompat/deviceprofile.go | 50 ++- .../jellycompat/deviceprofile_conditions.go | 353 ++++++++++++++++++ .../deviceprofile_conditions_test.go | 352 +++++++++++++++++ internal/jellycompat/handlers_playback.go | 24 +- internal/models/media.go | 5 + internal/scanner/probe.go | 104 +++++- internal/scanner/probe_video_range_test.go | 152 ++++++++ internal/scanner/scanner.go | 5 + internal/scanner/types.go | 5 + 11 files changed, 1034 insertions(+), 26 deletions(-) create mode 100644 internal/jellycompat/deviceprofile_conditions.go create mode 100644 internal/jellycompat/deviceprofile_conditions_test.go create mode 100644 internal/scanner/probe_video_range_test.go diff --git a/internal/catalogseed/service.go b/internal/catalogseed/service.go index b62bea14..69eab654 100644 --- a/internal/catalogseed/service.go +++ b/internal/catalogseed/service.go @@ -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, diff --git a/internal/catalogseed/types.go b/internal/catalogseed/types.go index ff899a56..f13a8abe 100644 --- a/internal/catalogseed/types.go +++ b/internal/catalogseed/types.go @@ -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"` diff --git a/internal/jellycompat/deviceprofile.go b/internal/jellycompat/deviceprofile.go index 6292edbd..1ebf4d34 100644 --- a/internal/jellycompat/deviceprofile.go +++ b/internal/jellycompat/deviceprofile.go @@ -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 } diff --git a/internal/jellycompat/deviceprofile_conditions.go b/internal/jellycompat/deviceprofile_conditions.go new file mode 100644 index 00000000..066a454c --- /dev/null +++ b/internal/jellycompat/deviceprofile_conditions.go @@ -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() +} diff --git a/internal/jellycompat/deviceprofile_conditions_test.go b/internal/jellycompat/deviceprofile_conditions_test.go new file mode 100644 index 00000000..56eddaee --- /dev/null +++ b/internal/jellycompat/deviceprofile_conditions_test.go @@ -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, + }}, + } +} diff --git a/internal/jellycompat/handlers_playback.go b/internal/jellycompat/handlers_playback.go index a1a8dc65..792942a2 100644 --- a/internal/jellycompat/handlers_playback.go +++ b/internal/jellycompat/handlers_playback.go @@ -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, diff --git a/internal/models/media.go b/internal/models/media.go index e3d722af..b211697f 100644 --- a/internal/models/media.go +++ b/internal/models/media.go @@ -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"` diff --git a/internal/scanner/probe.go b/internal/scanner/probe.go index 26d2a255..063bde22 100644 --- a/internal/scanner/probe.go +++ b/internal/scanner/probe.go @@ -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. diff --git a/internal/scanner/probe_video_range_test.go b/internal/scanner/probe_video_range_test.go new file mode 100644 index 00000000..7529f2d3 --- /dev/null +++ b/internal/scanner/probe_video_range_test.go @@ -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) + } + }) + } +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index a7da39da..23dbae6b 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -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, diff --git a/internal/scanner/types.go b/internal/scanner/types.go index 901308eb..57962371 100644 --- a/internal/scanner/types.go +++ b/internal/scanner/types.go @@ -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