From b3e2943ccdebafb1f777fd9c0b1f2bd47ac3a340 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:50:37 -0400 Subject: [PATCH 1/4] fix(jellycompat): preserve video color range --- internal/catalogseed/service.go | 1 + internal/catalogseed/service_test.go | 16 ++++++++ internal/catalogseed/types.go | 1 + .../deviceprofile_conditions_test.go | 34 ++++++++++++++++ internal/jellycompat/dto.go | 1 + internal/jellycompat/handlers_playback.go | 1 + internal/models/media.go | 1 + internal/scanner/probe.go | 2 + internal/scanner/probe_repair.go | 12 ++++++ internal/scanner/probe_repair_audio_test.go | 27 ++++++++++++- internal/scanner/probe_video_range_test.go | 40 ++++++++++++++++++- internal/scanner/scanner.go | 1 + internal/scanner/types.go | 1 + web/src/api/types.ts | 1 + web/src/player/types.ts | 1 + 15 files changed, 138 insertions(+), 2 deletions(-) diff --git a/internal/catalogseed/service.go b/internal/catalogseed/service.go index 83451734a..46468abde 100644 --- a/internal/catalogseed/service.go +++ b/internal/catalogseed/service.go @@ -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, diff --git a/internal/catalogseed/service_test.go b/internal/catalogseed/service_test.go index 9cd576ae0..62e73f2e4 100644 --- a/internal/catalogseed/service_test.go +++ b/internal/catalogseed/service_test.go @@ -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, diff --git a/internal/catalogseed/types.go b/internal/catalogseed/types.go index 39e2d4355..d4037a58d 100644 --- a/internal/catalogseed/types.go +++ b/internal/catalogseed/types.go @@ -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"` diff --git a/internal/jellycompat/deviceprofile_conditions_test.go b/internal/jellycompat/deviceprofile_conditions_test.go index 56eddaee8..4e7b6e920 100644 --- a/internal/jellycompat/deviceprofile_conditions_test.go +++ b/internal/jellycompat/deviceprofile_conditions_test.go @@ -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, diff --git a/internal/jellycompat/dto.go b/internal/jellycompat/dto.go index aa8bc35f7..06ead21c0 100644 --- a/internal/jellycompat/dto.go +++ b/internal/jellycompat/dto.go @@ -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"` diff --git a/internal/jellycompat/handlers_playback.go b/internal/jellycompat/handlers_playback.go index 1ed3503e6..e1737e412 100644 --- a/internal/jellycompat/handlers_playback.go +++ b/internal/jellycompat/handlers_playback.go @@ -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, diff --git a/internal/models/media.go b/internal/models/media.go index 02a41b409..8ee7ae6c8 100644 --- a/internal/models/media.go +++ b/internal/models/media.go @@ -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"` diff --git a/internal/scanner/probe.go b/internal/scanner/probe.go index 24cb8d9b9..b83ccab97 100644 --- a/internal/scanner/probe.go +++ b/internal/scanner/probe.go @@ -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, diff --git a/internal/scanner/probe_repair.go b/internal/scanner/probe_repair.go index cbbc04f2d..255a76b90 100644 --- a/internal/scanner/probe_repair.go +++ b/internal/scanner/probe_repair.go @@ -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 { diff --git a/internal/scanner/probe_repair_audio_test.go b/internal/scanner/probe_repair_audio_test.go index 5cb26d401..030075310 100644 --- a/internal/scanner/probe_repair_audio_test.go +++ b/internal/scanner/probe_repair_audio_test.go @@ -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{}, } } diff --git a/internal/scanner/probe_video_range_test.go b/internal/scanner/probe_video_range_test.go index 7529f2d31..7cdcfaad2 100644 --- a/internal/scanner/probe_video_range_test.go +++ b/internal/scanner/probe_video_range_test.go @@ -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 { diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 58b4dba01..f9fcb834e 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -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, diff --git a/internal/scanner/types.go b/internal/scanner/types.go index 7005c02c2..f969c0aa0 100644 --- a/internal/scanner/types.go +++ b/internal/scanner/types.go @@ -67,6 +67,7 @@ type VideoTrackInfo struct { Bitrate int VideoRange string VideoRangeType string + ColorRange string ColorPrimaries string ColorSpace string ColorTransfer string diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 88d1784e7..231695d38 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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; diff --git a/web/src/player/types.ts b/web/src/player/types.ts index 75c4c06e7..65acebca0 100644 --- a/web/src/player/types.ts +++ b/web/src/player/types.ts @@ -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; From 827843fd21f7f5362d375df3ad2423e2d60d4304 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:03:08 -0400 Subject: [PATCH 2/4] fix(jellycompat): omit unspecified color range --- .../deviceprofile_conditions_test.go | 30 ++++++++++++++----- internal/jellycompat/handlers_playback.go | 12 +++++++- internal/scanner/probe.go | 5 +++- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/internal/jellycompat/deviceprofile_conditions_test.go b/internal/jellycompat/deviceprofile_conditions_test.go index 4e7b6e920..154ef9b46 100644 --- a/internal/jellycompat/deviceprofile_conditions_test.go +++ b/internal/jellycompat/deviceprofile_conditions_test.go @@ -275,12 +275,24 @@ func TestBuildMediaStreamsUsesJellyfinVideoRangeType(t *testing.T) { } func TestBuildMediaStreamsPreservesColorRange(t *testing.T) { - for _, colorRange := range []string{"tv", "pc"} { - t.Run(colorRange, func(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: colorRange, + ColorRange: tt.colorRange, }}, } @@ -288,8 +300,8 @@ func TestBuildMediaStreamsPreservesColorRange(t *testing.T) { 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) + if got := streams[0].ColorRange; got != tt.wantColorRange { + t.Fatalf("ColorRange = %q, want %q", got, tt.wantColorRange) } payload, err := json.Marshal(streams[0]) @@ -300,8 +312,12 @@ func TestBuildMediaStreamsPreservesColorRange(t *testing.T) { 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) + 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) } }) } diff --git a/internal/jellycompat/handlers_playback.go b/internal/jellycompat/handlers_playback.go index e1737e412..786ae680e 100644 --- a/internal/jellycompat/handlers_playback.go +++ b/internal/jellycompat/handlers_playback.go @@ -890,7 +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, + ColorRange: compatColorRange(track.ColorRange), ColorPrimaries: track.ColorPrimaries, ColorSpace: track.ColorSpace, ColorTransfer: track.ColorTransfer, @@ -971,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]) diff --git a/internal/scanner/probe.go b/internal/scanner/probe.go index b83ccab97..36376a267 100644 --- a/internal/scanner/probe.go +++ b/internal/scanner/probe.go @@ -187,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, @@ -206,7 +209,7 @@ func convertProbeData(raw *ffprobeOutput) *ProbeData { Bitrate: parseNumeric(s.BitRate) / 1000, VideoRange: videoRangeLabel(s), VideoRangeType: videoRangeType(s), - ColorRange: firstNonEmpty(s.ColorRange, "unknown"), + ColorRange: colorRange, ColorPrimaries: s.ColorPrimaries, ColorSpace: s.ColorSpace, ColorTransfer: s.ColorTransfer, From 4b3362d32ed7c30a66c9b2d8175627e755bd4886 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:57:54 -0400 Subject: [PATCH 3/4] fix(playback): expose source color range to native clients --- internal/models/media_test.go | 36 ++++++++++++++++++++++++++- internal/playback/capabilities_v3.go | 10 ++++++++ internal/playback/protocol_v3.go | 1 + internal/playback/protocol_v3_test.go | 24 +++++++++++++++++- web/src/player/playback-info.test.ts | 23 +++++++++++++++++ web/src/player/playback-info.ts | 17 +++++++++++++ 6 files changed, 109 insertions(+), 2 deletions(-) diff --git a/internal/models/media_test.go b/internal/models/media_test.go index 1086f6bfb..21c602bca 100644 --- a/internal/models/media_test.go +++ b/internal/models/media_test.go @@ -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) + } + }) + } +} diff --git a/internal/playback/capabilities_v3.go b/internal/playback/capabilities_v3.go index f20bcc481..59b34c3a6 100644 --- a/internal/playback/capabilities_v3.go +++ b/internal/playback/capabilities_v3.go @@ -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 diff --git a/internal/playback/protocol_v3.go b/internal/playback/protocol_v3.go index 1effcbbf1..20efbce36 100644 --- a/internal/playback/protocol_v3.go +++ b/internal/playback/protocol_v3.go @@ -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"` diff --git a/internal/playback/protocol_v3_test.go b/internal/playback/protocol_v3_test.go index 57802afc9..c1dd29460 100644 --- a/internal/playback/protocol_v3_test.go +++ b/internal/playback/protocol_v3_test.go @@ -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 { diff --git a/web/src/player/playback-info.test.ts b/web/src/player/playback-info.test.ts index 36460cf85..a32f88967 100644 --- a/web/src/player/playback-info.test.ts +++ b/web/src/player/playback-info.test.ts @@ -28,6 +28,7 @@ function makeVersion(overrides: Partial = {}): 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", () => { diff --git a/web/src/player/playback-info.ts b/web/src/player/playback-info.ts index 97f9bea89..140388fe9 100644 --- a/web/src/player/playback-info.ts +++ b/web/src/player/playback-info.ts @@ -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, From b822bb71818d5e52f670c25e2e9e3ecf30aea064 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:03:53 -0400 Subject: [PATCH 4/4] test(catalogseed): cover unknown color range --- internal/catalogseed/service_test.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/catalogseed/service_test.go b/internal/catalogseed/service_test.go index 62e73f2e4..421eefcdc 100644 --- a/internal/catalogseed/service_test.go +++ b/internal/catalogseed/service_test.go @@ -11,13 +11,19 @@ func TestToVideoTrackRecordsPreservesColorRange(t *testing.T) { got := toVideoTrackRecords([]models.VideoTrack{ {ColorRange: "tv"}, {ColorRange: "pc"}, + {ColorRange: "unknown"}, }) - if len(got) != 2 { - t.Fatalf("records length = %d, want 2", len(got)) + if len(got) != 3 { + t.Fatalf("records length = %d, want 3", 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) + 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, + ) } }