fix(jellycompat): honor codec profile playback limits
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user