fix(jellycompat): preserve video color range

This commit is contained in:
Quick104
2026-07-21 09:50:37 -04:00
parent 3457382324
commit b3e2943ccd
15 changed files with 138 additions and 2 deletions
+1
View File
@@ -2580,6 +2580,7 @@ func toVideoTrackRecords(tracks []models.VideoTrack) []VideoTrackRecord {
Bitrate: track.Bitrate,
VideoRange: track.VideoRange,
VideoRangeType: track.VideoRangeType,
ColorRange: track.ColorRange,
ColorPrimaries: track.ColorPrimaries,
ColorSpace: track.ColorSpace,
ColorTransfer: track.ColorTransfer,
+16
View File
@@ -3,8 +3,24 @@ package catalogseed
import (
"reflect"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestToVideoTrackRecordsPreservesColorRange(t *testing.T) {
got := toVideoTrackRecords([]models.VideoTrack{
{ColorRange: "tv"},
{ColorRange: "pc"},
})
if len(got) != 2 {
t.Fatalf("records length = %d, want 2", len(got))
}
if got[0].ColorRange != "tv" || got[1].ColorRange != "pc" {
t.Fatalf("ColorRange values = [%q, %q], want [tv, pc]", got[0].ColorRange, got[1].ColorRange)
}
}
func TestCatalogSeedSearchUpsertIDsIncludesChangedItemsAndEmbeddings(t *testing.T) {
itemStates := map[string]bool{
"movie-1": true,
+1
View File
@@ -156,6 +156,7 @@ type VideoTrackRecord struct {
Bitrate int `json:"bitrate,omitempty"`
VideoRange string `json:"video_range,omitempty"`
VideoRangeType string `json:"video_range_type,omitempty"`
ColorRange string `json:"color_range,omitempty"`
ColorPrimaries string `json:"color_primaries,omitempty"`
ColorSpace string `json:"color_space,omitempty"`
ColorTransfer string `json:"color_transfer,omitempty"`
@@ -1,6 +1,7 @@
package jellycompat
import (
"encoding/json"
"strings"
"testing"
@@ -273,6 +274,39 @@ func TestBuildMediaStreamsUsesJellyfinVideoRangeType(t *testing.T) {
}
}
func TestBuildMediaStreamsPreservesColorRange(t *testing.T) {
for _, colorRange := range []string{"tv", "pc"} {
t.Run(colorRange, func(t *testing.T) {
version := catalog.FileVersion{
VideoTracks: []models.VideoTrack{{
Codec: "h264",
ColorRange: colorRange,
}},
}
streams := buildMediaStreams("item", "source", version)
if len(streams) != 1 {
t.Fatalf("streams length = %d, want 1", len(streams))
}
if got := streams[0].ColorRange; got != colorRange {
t.Fatalf("ColorRange = %q, want %q", got, colorRange)
}
payload, err := json.Marshal(streams[0])
if err != nil {
t.Fatalf("marshal media stream: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("unmarshal media stream: %v", err)
}
if got := decoded["ColorRange"]; got != colorRange {
t.Fatalf("JSON ColorRange = %#v, want %q", got, colorRange)
}
})
}
}
func TestCodecProfileAVCRefFramesConstraint(t *testing.T) {
version := catalog.FileVersion{
FileID: 1,
+1
View File
@@ -264,6 +264,7 @@ type mediaStreamDTO struct {
AspectRatio string `json:"AspectRatio,omitempty"`
VideoRange string `json:"VideoRange,omitempty"`
VideoRangeType string `json:"VideoRangeType,omitempty"`
ColorRange string `json:"ColorRange,omitempty"`
ColorPrimaries string `json:"ColorPrimaries,omitempty"`
ColorSpace string `json:"ColorSpace,omitempty"`
ColorTransfer string `json:"ColorTransfer,omitempty"`
@@ -890,6 +890,7 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
AspectRatio: track.AspectRatio,
VideoRange: compatVideoRange(track, version.HDR),
VideoRangeType: compatVideoRangeType(track, version.HDR),
ColorRange: track.ColorRange,
ColorPrimaries: track.ColorPrimaries,
ColorSpace: track.ColorSpace,
ColorTransfer: track.ColorTransfer,
+1
View File
@@ -219,6 +219,7 @@ type VideoTrack struct {
Bitrate int `json:"bitrate,omitempty"`
VideoRange string `json:"video_range,omitempty"`
VideoRangeType string `json:"video_range_type,omitempty"`
ColorRange string `json:"color_range,omitempty"`
ColorPrimaries string `json:"color_primaries,omitempty"`
ColorSpace string `json:"color_space,omitempty"`
ColorTransfer string `json:"color_transfer,omitempty"`
+2
View File
@@ -76,6 +76,7 @@ type ffprobeStream struct {
StartTime string `json:"start_time"`
Duration string `json:"duration"`
BitRate string `json:"bit_rate"`
ColorRange string `json:"color_range"`
ColorTransfer string `json:"color_transfer"`
ColorPrimaries string `json:"color_primaries"`
ColorSpace string `json:"color_space"`
@@ -205,6 +206,7 @@ func convertProbeData(raw *ffprobeOutput) *ProbeData {
Bitrate: parseNumeric(s.BitRate) / 1000,
VideoRange: videoRangeLabel(s),
VideoRangeType: videoRangeType(s),
ColorRange: firstNonEmpty(s.ColorRange, "unknown"),
ColorPrimaries: s.ColorPrimaries,
ColorSpace: s.ColorSpace,
ColorTransfer: s.ColorTransfer,
+12
View File
@@ -54,6 +54,9 @@ func NeedsCriticalProbeRepair(file *models.MediaFile) bool {
if strings.TrimSpace(file.CodecVideo) == "" || strings.TrimSpace(file.Resolution) == "" {
return true
}
if videoTracksMissingColorRange(file.VideoTracks) {
return true
}
}
if file.Chapters == nil {
return true
@@ -61,6 +64,15 @@ func NeedsCriticalProbeRepair(file *models.MediaFile) bool {
return false
}
func videoTracksMissingColorRange(tracks []models.VideoTrack) bool {
for _, track := range tracks {
if strings.TrimSpace(track.ColorRange) == "" {
return true
}
}
return false
}
// PlaybackProbeEnsurer repairs missing playback-critical probe metadata on
// demand by running a local ffprobe and persisting the result.
type PlaybackProbeEnsurer struct {
+26 -1
View File
@@ -47,6 +47,31 @@ func TestNeedsCriticalProbeRepair_ProbedVideoMissingResolutionStillRepairs(t *te
}
}
func TestNeedsCriticalProbeRepair_ProbedVideoMissingColorRangeRepairsOnce(t *testing.T) {
now := time.Now()
f := &models.MediaFile{
ProbeSource: "local",
ProbeUpdatedAt: &now,
Duration: 7200,
Container: "mkv",
CodecAudio: "aac",
AudioTracks: []models.AudioTrack{{Language: "eng"}},
CodecVideo: "h264",
Resolution: "1080p",
VideoTracks: []models.VideoTrack{{Codec: "h264"}},
Chapters: []models.MediaChapter{},
}
if !NeedsCriticalProbeRepair(f) {
t.Fatal("a legacy video without color range should need probe repair")
}
f.VideoTracks[0].ColorRange = "unknown"
if NeedsCriticalProbeRepair(f) {
t.Fatal("a reprobed video with unknown color range should not repair again")
}
}
func TestNeedsCriticalProbeRepair_UnprobedFileRepairs(t *testing.T) {
if !NeedsCriticalProbeRepair(&models.MediaFile{}) {
t.Fatal("an unprobed file must need probe repair")
@@ -64,7 +89,7 @@ func implausiblyShortLargeVideoFile(probedAt time.Time) *models.MediaFile {
AudioTracks: []models.AudioTrack{{Language: "eng"}},
CodecVideo: "h264",
Resolution: "720p",
VideoTracks: []models.VideoTrack{{Codec: "h264"}},
VideoTracks: []models.VideoTrack{{Codec: "h264", ColorRange: "unknown"}},
Chapters: []models.MediaChapter{},
}
}
+39 -1
View File
@@ -1,6 +1,44 @@
package scanner
import "testing"
import (
"encoding/json"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestProbePipelinePreservesVideoColorRange(t *testing.T) {
tests := []struct {
name string
colorRange string
wantColorRange string
}{
{name: "limited", colorRange: "tv", wantColorRange: "tv"},
{name: "full", colorRange: "pc", wantColorRange: "pc"},
{name: "unspecified", wantColorRange: "unknown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rawJSON := `{"streams":[{"codec_type":"video","codec_name":"h264","color_range":"` + tt.colorRange + `"}]}`
var raw ffprobeOutput
if err := json.Unmarshal([]byte(rawJSON), &raw); err != nil {
t.Fatalf("unmarshal ffprobe output: %v", err)
}
probe := convertProbeData(&raw)
file := &models.MediaFile{}
applyProbeData(file, probe, "local")
if len(file.VideoTracks) != 1 {
t.Fatalf("VideoTracks length = %d, want 1", len(file.VideoTracks))
}
if got := file.VideoTracks[0].ColorRange; got != tt.wantColorRange {
t.Fatalf("ColorRange = %q, want %q", got, tt.wantColorRange)
}
})
}
}
func TestConvertProbeDataVideoRangeTypes(t *testing.T) {
tests := []struct {
+1
View File
@@ -3186,6 +3186,7 @@ func applyProbeData(mf *models.MediaFile, probe *ProbeData, probeSource string)
Bitrate: vt.Bitrate,
VideoRange: vt.VideoRange,
VideoRangeType: vt.VideoRangeType,
ColorRange: vt.ColorRange,
ColorPrimaries: vt.ColorPrimaries,
ColorSpace: vt.ColorSpace,
ColorTransfer: vt.ColorTransfer,
+1
View File
@@ -67,6 +67,7 @@ type VideoTrackInfo struct {
Bitrate int
VideoRange string
VideoRangeType string
ColorRange string
ColorPrimaries string
ColorSpace string
ColorTransfer string
+1
View File
@@ -970,6 +970,7 @@ export interface VersionVideoTrack {
bitrate?: number;
video_range?: string;
video_range_type?: string;
color_range?: string;
color_primaries?: string;
color_space?: string;
color_transfer?: string;
+1
View File
@@ -79,6 +79,7 @@ export interface PlayerVideoTrack {
frame_rate?: string;
bitrate?: number;
video_range?: string;
color_range?: string;
color_primaries?: string;
color_space?: string;
color_transfer?: string;