Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/catalogseed/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions internal/catalogseed/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions internal/catalogseed/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
50 changes: 50 additions & 0 deletions internal/jellycompat/deviceprofile_conditions_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package jellycompat

import (
"encoding/json"
"strings"
"testing"

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions internal/jellycompat/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
11 changes: 11 additions & 0 deletions internal/jellycompat/handlers_playback.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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])
Expand Down
1 change: 1 addition & 0 deletions internal/models/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
36 changes: 35 additions & 1 deletion internal/models/media_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package models

import "testing"
import (
"encoding/json"
"testing"
)

func TestPersonKindAudiobookRoles(t *testing.T) {
cases := []struct {
Expand Down Expand Up @@ -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 changes: 10 additions & 0 deletions internal/playback/capabilities_v3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/playback/protocol_v3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
24 changes: 23 additions & 1 deletion internal/playback/protocol_v3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions internal/scanner/probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions internal/scanner/probe_repair.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,25 @@ func NeedsCriticalProbeRepair(file *models.MediaFile) bool {
if strings.TrimSpace(file.CodecVideo) == "" || strings.TrimSpace(file.Resolution) == "" {
return true
}
if videoTracksMissingColorRange(file.VideoTracks) {
return true
Comment on lines +57 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add scan-state color-range repair detection

When relying on a normal library rescan to backfill existing rows, this new full MediaFile check is bypassed: processFile builds updateReasons via scanStateUpdateReasons, which calls needsCriticalProbeRepairScanState on the lightweight scan state, and that state only exposes HasVideoTracks rather than whether each video_tracks element has color_range. For already-scanned, unchanged matched videos whose JSON lacks color_range, the scan skip path returns actionUnchanged, so the Jellyfin ColorRange field remains absent until an on-demand detail/playback repair happens; add a scan-state boolean for missing color range and include it in needsCriticalProbeRepairScanState so scheduled rescans converge too.

Useful? React with 👍 / 👎.

}
}
if file.Chapters == nil {
return true
}
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 {
Expand Down
27 changes: 26 additions & 1 deletion internal/scanner/probe_repair_audio_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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{},
}
}
Expand Down
Loading