Merge pull request #447 from Silo-Server/codex/jellycompat-color-range

fix(playback): preserve and expose video color range
This commit is contained in:
Quick
2026-07-22 13:38:16 -04:00
committed by GitHub
21 changed files with 282 additions and 4 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,
+22
View File
@@ -3,8 +3,30 @@ 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"},
{ColorRange: "unknown"},
})
if len(got) != 3 {
t.Fatalf("records length = %d, want 3", len(got))
}
if got[0].ColorRange != "tv" || got[1].ColorRange != "pc" || got[2].ColorRange != "unknown" {
t.Fatalf(
"ColorRange values = [%q, %q, %q], want [tv, pc, unknown]",
got[0].ColorRange,
got[1].ColorRange,
got[2].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,55 @@ func TestBuildMediaStreamsUsesJellyfinVideoRangeType(t *testing.T) {
}
}
func TestBuildMediaStreamsPreservesColorRange(t *testing.T) {
tests := []struct {
name string
colorRange string
wantColorRange string
wantJSONField bool
}{
{name: "limited", colorRange: "tv", wantColorRange: "tv", wantJSONField: true},
{name: "full", colorRange: "pc", wantColorRange: "pc", wantJSONField: true},
{name: "internal unknown sentinel", colorRange: "unknown"},
{name: "missing"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
version := catalog.FileVersion{
VideoTracks: []models.VideoTrack{{
Codec: "h264",
ColorRange: tt.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 != tt.wantColorRange {
t.Fatalf("ColorRange = %q, want %q", got, tt.wantColorRange)
}
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)
}
got, present := decoded["ColorRange"]
if present != tt.wantJSONField {
t.Fatalf("JSON ColorRange present = %v, want %v (value %#v)", present, tt.wantJSONField, got)
}
if tt.wantJSONField && got != tt.wantColorRange {
t.Fatalf("JSON ColorRange = %#v, want %q", got, tt.wantColorRange)
}
})
}
}
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"`
+11
View File
@@ -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: compatColorRange(track.ColorRange),
ColorPrimaries: track.ColorPrimaries,
ColorSpace: track.ColorSpace,
ColorTransfer: track.ColorTransfer,
@@ -970,6 +971,16 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
return streams
}
func compatColorRange(colorRange string) string {
colorRange = strings.TrimSpace(colorRange)
if strings.EqualFold(colorRange, "unknown") {
// The scanner persists this sentinel so legacy probe repair converges,
// but Jellyfin omits ColorRange when ffprobe did not provide a value.
return ""
}
return colorRange
}
func mediaSourceETag(version catalog.FileVersion) string {
sum := sha1.Sum(fmt.Appendf(nil, "%d:%s:%s:%d", version.FileID, version.Container, version.CodecVideo, version.Bitrate))
return hex.EncodeToString(sum[:8])
+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"`
+35 -1
View File
@@ -1,6 +1,9 @@
package models
import "testing"
import (
"encoding/json"
"testing"
)
func TestPersonKindAudiobookRoles(t *testing.T) {
cases := []struct {
@@ -40,3 +43,34 @@ func TestNormalizeVideoBitDepth(t *testing.T) {
})
}
}
func TestVideoTrackColorRangeJSON(t *testing.T) {
for _, test := range []struct {
name string
value string
present bool
}{
{name: "limited", value: "tv", present: true},
{name: "full", value: "pc", present: true},
{name: "unspecified", value: "unknown", present: true},
{name: "empty omitted", value: "", present: false},
} {
t.Run(test.name, func(t *testing.T) {
data, err := json.Marshal(VideoTrack{ColorRange: test.value})
if err != nil {
t.Fatal(err)
}
var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
got, present := decoded["color_range"]
if present != test.present {
t.Fatalf("color_range present = %v, want %v (%s)", present, test.present, data)
}
if test.present && got != test.value {
t.Fatalf("color_range = %#v, want %q", got, test.value)
}
})
}
}
+10
View File
@@ -26,6 +26,7 @@ func SourceDescriptorFromFileV3(file *models.MediaFile, audioIndex int) SourceDe
source.VideoProfile = strings.ToLower(strings.TrimSpace(track.Profile))
source.VideoLevel = track.Level
source.BitDepth = models.NormalizeVideoBitDepth(track.BitDepth, track.PixelFormat, track.Profile)
source.ColorRange = normalizeColorRangeV3(track.ColorRange)
source.Width = track.Width
source.Height = track.Height
source.FrameRate = parseFrameRateV3(track.FrameRate)
@@ -73,6 +74,15 @@ func SourceDescriptorFromFileV3(file *models.MediaFile, audioIndex int) SourceDe
return source
}
func normalizeColorRangeV3(value string) string {
switch normalized := strings.ToLower(strings.TrimSpace(value)); normalized {
case "tv", "pc", "unknown":
return normalized
default:
return ""
}
}
func detailedVideoEligibleV3(source SourceDescriptorV3, request StartRequestV3) bool {
if !HasFeatureV3(request.ClientFeatures, FeatureDetailedDecodeV3) && !HasFeatureV3(request.ClientPlaybackContext.Features, FeatureDetailedDecodeV3) {
return false
+1
View File
@@ -412,6 +412,7 @@ type SourceDescriptorV3 struct {
VideoProfile string `json:"video_profile,omitempty"`
VideoLevel int `json:"video_level,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
ColorRange string `json:"color_range,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
FrameRate float64 `json:"frame_rate,omitempty"`
+23 -1
View File
@@ -265,6 +265,28 @@ func TestSourceDescriptorV3NormalizesLegacyHEVCMetadata(t *testing.T) {
}
}
func TestSourceDescriptorV3PreservesCanonicalColorRange(t *testing.T) {
for _, test := range []struct {
name string
input string
want string
}{
{name: "limited", input: "tv", want: "tv"},
{name: "full", input: "pc", want: "pc"},
{name: "unspecified", input: "unknown", want: "unknown"},
{name: "normalizes case and whitespace", input: " PC ", want: "pc"},
{name: "rejects non-ffmpeg value", input: "limited", want: ""},
} {
t.Run(test.name, func(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].ColorRange = test.input
if got := SourceDescriptorFromFileV3(file, 0).ColorRange; got != test.want {
t.Fatalf("color range = %q, want %q", got, test.want)
}
})
}
}
func TestPlanPlaybackV3DirectPlaysLegacyHDR10WithInferredBitDepth(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].BitDepth = 0
@@ -933,7 +955,7 @@ func validStartRequestV3() StartRequestV3 {
}
func detailedFixtureFileV3() *models.MediaFile {
return &models.MediaFile{ID: 42, FilePath: "/media/movie.mkv", Container: "mkv", CodecVideo: "hevc", CodecAudio: "aac", Resolution: "2160p", Bitrate: 60_000, AudioChannels: 2, VideoTracks: []models.VideoTrack{{Codec: "hevc", Profile: "Main 10", Level: 153, Width: 3840, Height: 2160, FrameRate: "24000/1001", Bitrate: 60_000, BitDepth: 10, VideoRange: "HDR", VideoRangeType: "HDR10"}}, AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2, Layout: "stereo"}}}
return &models.MediaFile{ID: 42, FilePath: "/media/movie.mkv", Container: "mkv", CodecVideo: "hevc", CodecAudio: "aac", Resolution: "2160p", Bitrate: 60_000, AudioChannels: 2, VideoTracks: []models.VideoTrack{{Codec: "hevc", Profile: "Main 10", Level: 153, Width: 3840, Height: 2160, FrameRate: "24000/1001", Bitrate: 60_000, BitDepth: 10, VideoRange: "HDR", VideoRangeType: "HDR10", ColorRange: "tv"}}, AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2, Layout: "stereo"}}}
}
func testTransformationRegistryV3() *TransformationRegistryV3 {
+5
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"`
@@ -186,6 +187,9 @@ func convertProbeData(raw *ffprobeOutput) *ProbeData {
switch s.CodecType {
case "video":
dvProfile := dolbyVisionProfileNumber(s.SideDataList)
// ffprobe omits unspecified optional fields by default; "unknown" is
// FFmpeg's canonical name for AVCOL_RANGE_UNSPECIFIED.
colorRange := firstNonEmpty(s.ColorRange, "unknown")
track := VideoTrackInfo{
Title: firstNonEmpty(s.Tags["title"], s.CodecLongName, strings.ToUpper(s.CodecName)),
Codec: s.CodecName,
@@ -205,6 +209,7 @@ func convertProbeData(raw *ffprobeOutput) *ProbeData {
Bitrate: parseNumeric(s.BitRate) / 1000,
VideoRange: videoRangeLabel(s),
VideoRangeType: videoRangeType(s),
ColorRange: colorRange,
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;
+23
View File
@@ -28,6 +28,7 @@ function makeVersion(overrides: Partial<PlayerFileVersion> = {}): PlayerFileVers
height: 2160,
bitrate: 21900,
video_range: "HDR10",
color_range: "tv",
dolby_vision: "Profile 8.1",
},
],
@@ -95,6 +96,7 @@ describe("playback info helpers", () => {
expect(rowValue(sections, "Current Source File", "Video range type")).toBe(
"Dolby Vision Profile 8.1 (HDR10)",
);
expect(rowValue(sections, "Current Source File", "Color range")).toBe("Limited (tv)");
expect(rowValue(sections, "Current Source File", "Audio codec")).toBe(
"EAC3 Dolby Digital Plus + Dolby Atmos",
);
@@ -146,6 +148,27 @@ describe("playback info helpers", () => {
expect(rowValue(sections, "Current Source File", "Container")).toBe("—");
expect(rowValue(sections, "Current Source File", "Size")).toBe("—");
expect(rowValue(sections, "Current Source File", "Audio sample rate")).toBe("—");
expect(rowValue(sections, "Current Source File", "Color range")).toBe("—");
});
it("formats full and unspecified source color ranges", () => {
const full = buildPlaybackInfoSections({
streamUrl: "/api/v1/stream/full",
playMethod: "direct",
playbackInfo: null,
currentSourceVersion: makeVersion({ video_tracks: [{ color_range: "pc" }] }),
runtimeStats: {},
});
const unknown = buildPlaybackInfoSections({
streamUrl: "/api/v1/stream/unknown",
playMethod: "direct",
playbackInfo: null,
currentSourceVersion: makeVersion({ video_tracks: [{ color_range: "unknown" }] }),
runtimeStats: {},
});
expect(rowValue(full, "Current Source File", "Color range")).toBe("Full (pc)");
expect(rowValue(unknown, "Current Source File", "Color range")).toBe("Unknown");
});
it("shows the requested source when playback auto-switches to a lower version", () => {
+17
View File
@@ -147,6 +147,10 @@ export function buildPlaybackInfoSections({
label: "Video range type",
value: formatVideoRangeType(currentSourceVersion, videoTrack),
},
{
label: "Color range",
value: formatColorRange(videoTrack?.color_range),
},
{
label: "Audio codec",
value: formatOriginalAudioCodec(currentSourceVersion, audioTrack),
@@ -340,6 +344,19 @@ export function formatVideoRangeType(
return "—";
}
export function formatColorRange(value?: string): string {
switch (value?.trim().toLowerCase()) {
case "tv":
return "Limited (tv)";
case "pc":
return "Full (pc)";
case "unknown":
return "Unknown";
default:
return "—";
}
}
export function formatOriginalAudioCodec(
version?: PlayerFileVersion,
track?: PlayerAudioTrack,
+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;