Files
silo-server/internal/overlays/summary_test.go
0f9584ef42 fix(overlays): derive dynamic-range badges from Dolby Vision metadata (#365)
* fix(overlays): derive dynamic-range badges from Dolby Vision metadata

Dolby Vision files were labeled with a generic "HDR" badge everywhere
outside the Media Info dialog, because badge sites only consulted the
bare FileVersion.hdr boolean even though the payload carries
video_tracks[].dolby_vision / dv_profile / video_range_type /
color_transfer.

Web: add a shared helper (web/src/lib/videoRange.ts) that derives a
display label ("DV", "DV HDR10", "DV HLG", "HDR10+", "HDR10", "HLG")
from the probed video tracks, mirroring the server-side normalizeHDR
vocabulary, with the hdr boolean kept as the last-resort fallback for
stale pre-DV probe rows. Use it in QualityBadges, VersionDropdown,
VersionFlyout, and the player HUD (playback-info) in place of the
hardcoded "HDR" literal. Badge styling is unchanged.

Server: in internal/overlays/summary.go, break bestFile resolution
ties by richness of dynamic-range metadata (DV > explicit HDR10/HLG
via color_transfer > bare hdr boolean > SDR) so a first-scanned
generic-HDR file no longer masks a Dolby Vision sibling on card
overlays. Full ties still keep the earliest file, so the selection
stays deterministic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(overlays): detect Dolby Vision via dv_profile and DOVI range type

Probed rows set DolbyVision and DVProfile together, but catalog-seeded
or imported tracks can carry only dv_profile or a DOVI* video_range_type
with an empty dolby_vision string. Share one hasDolbyVision predicate
between rangeRank and normalizeHDR so those rows rank and label as DV,
matching the web videoRange helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(overlays): read video_range_type and hdr10_plus in hdrTypeFromTracks

Review follow-up: hdrTypeFromTracks only inspected color_transfer, so
server card overlays could never label HDR10+ and a track whose only
signal is video_range_type (e.g. catalog-seeded rows without probed
color metadata) fell through to the bare-boolean tier of rangeRank —
inconsistent with hasDolbyVision, which already reads the range type.
The server now shares the web helper's detection order exactly, making
the web/server vocabulary mirror claim true.

Also drops the unused video_range field from the web VideoRangeTrack
interface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-16 10:53:30 -04:00

420 lines
13 KiB
Go

package overlays
import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestNormalizeVideoCodec(t *testing.T) {
cases := []struct {
name string
file *models.MediaFile
want string
}{
{"empty", &models.MediaFile{}, ""},
{"file codec hevc", &models.MediaFile{CodecVideo: "hevc"}, "H.265"},
{"file codec h264", &models.MediaFile{CodecVideo: "h264"}, "H.264"},
{"file codec av1", &models.MediaFile{CodecVideo: "av1"}, "AV1"},
{"file codec vp9", &models.MediaFile{CodecVideo: "VP9"}, "VP9"},
{"track overrides file", &models.MediaFile{
CodecVideo: "h264",
VideoTracks: []models.VideoTrack{{Codec: "hevc"}},
}, "H.265"},
{"empty track codec falls back to file codec", &models.MediaFile{
CodecVideo: "h265",
VideoTracks: []models.VideoTrack{{Codec: ""}},
}, "H.265"},
{"unknown codec passes through uppercased", &models.MediaFile{CodecVideo: "foobar"}, "FOOBAR"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeVideoCodec(tc.file); got != tc.want {
t.Errorf("normalizeVideoCodec = %q, want %q", got, tc.want)
}
})
}
}
func TestNormalizeAudioChannels(t *testing.T) {
cases := []struct {
name string
file *models.MediaFile
want string
}{
{"empty", &models.MediaFile{}, ""},
{"file channels 2", &models.MediaFile{AudioChannels: 2}, "Stereo"},
{"file channels 6", &models.MediaFile{AudioChannels: 6}, "5.1"},
{"file channels 8", &models.MediaFile{AudioChannels: 8}, "7.1"},
{"file channels 1", &models.MediaFile{AudioChannels: 1}, "Mono"},
{"unusual channel count", &models.MediaFile{AudioChannels: 10}, "10ch"},
{"default track wins over higher", &models.MediaFile{
AudioTracks: []models.AudioTrack{
{Channels: 8},
{Channels: 2, Default: true},
},
}, "Stereo"},
{"highest non-default when no default", &models.MediaFile{
AudioTracks: []models.AudioTrack{
{Channels: 2},
{Channels: 8},
},
}, "7.1"},
{"track beats file fallback", &models.MediaFile{
AudioChannels: 2,
AudioTracks: []models.AudioTrack{{Channels: 6, Default: true}},
}, "5.1"},
{"default track with 0 channels does not shadow earlier non-default", &models.MediaFile{
AudioTracks: []models.AudioTrack{
{Channels: 8},
{Channels: 0, Default: true},
},
}, "7.1"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeAudioChannels(tc.file); got != tc.want {
t.Errorf("normalizeAudioChannels = %q, want %q", got, tc.want)
}
})
}
}
func TestNormalizeContainer(t *testing.T) {
cases := []struct {
in, want string
}{
{"", ""},
{"mkv", "MKV"},
{"MP4", "MP4"},
{" mov ", "MOV"},
}
for _, tc := range cases {
if got := normalizeContainer(tc.in); got != tc.want {
t.Errorf("normalizeContainer(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestNormalizeAspectRatio(t *testing.T) {
cases := []struct {
name string
file *models.MediaFile
want string
}{
{"empty", &models.MediaFile{}, ""},
{"16:9 string", &models.MediaFile{
VideoTracks: []models.VideoTrack{{AspectRatio: "16:9"}},
}, "16:9"},
{"239:100 normalizes to 2.39:1", &models.MediaFile{
VideoTracks: []models.VideoTrack{{AspectRatio: "239:100"}},
}, "2.39:1"},
{"2.40:1 snaps to 2.39:1", &models.MediaFile{
VideoTracks: []models.VideoTrack{{AspectRatio: "2.40:1"}},
}, "2.39:1"},
{"derives from 1920x1080", &models.MediaFile{
VideoTracks: []models.VideoTrack{{Width: 1920, Height: 1080}},
}, "16:9"},
{"derives from 4096x1716 (cinemascope)", &models.MediaFile{
VideoTracks: []models.VideoTrack{{Width: 4096, Height: 1716}},
}, "2.39:1"},
{"derives from 720x480", &models.MediaFile{
VideoTracks: []models.VideoTrack{{Width: 720, Height: 480}},
}, "1.50:1"},
{"malformed ratio falls back to dimensions", &models.MediaFile{
VideoTracks: []models.VideoTrack{{AspectRatio: "garbage", Width: 1920, Height: 1080}},
}, "16:9"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeAspectRatio(tc.file); got != tc.want {
t.Errorf("normalizeAspectRatio = %q, want %q", got, tc.want)
}
})
}
}
func TestDetectMultiAudio(t *testing.T) {
cases := []struct {
name string
file *models.MediaFile
want bool
}{
{"empty", &models.MediaFile{}, false},
{"single track", &models.MediaFile{
AudioTracks: []models.AudioTrack{{Language: "eng"}},
}, false},
{"two same language", &models.MediaFile{
AudioTracks: []models.AudioTrack{{Language: "eng"}, {Language: "eng"}},
}, false},
{"two distinct", &models.MediaFile{
AudioTracks: []models.AudioTrack{{Language: "eng"}, {Language: "jpn"}},
}, true},
{"undefined ignored", &models.MediaFile{
AudioTracks: []models.AudioTrack{{Language: "und"}, {Language: ""}, {Language: "eng"}},
}, false},
{"three distinct returns true at 2", &models.MediaFile{
AudioTracks: []models.AudioTrack{{Language: "eng"}, {Language: "spa"}, {Language: "fre"}},
}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := detectMultiAudio(tc.file); got != tc.want {
t.Errorf("detectMultiAudio = %v, want %v", got, tc.want)
}
})
}
}
func TestDetectMultiSub(t *testing.T) {
cases := []struct {
name string
file *models.MediaFile
want bool
}{
{"empty", &models.MediaFile{}, false},
{"one embedded", &models.MediaFile{
SubtitleTracks: []models.SubtitleTrack{{Language: "eng"}},
}, true},
{"one external", &models.MediaFile{
ExternalSubtitles: []models.ExternalSubtitle{{Language: "eng"}},
}, true},
{"both present", &models.MediaFile{
SubtitleTracks: []models.SubtitleTrack{{Language: "eng"}},
ExternalSubtitles: []models.ExternalSubtitle{{Language: "jpn"}},
}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := detectMultiSub(tc.file); got != tc.want {
t.Errorf("detectMultiSub = %v, want %v", got, tc.want)
}
})
}
}
func TestNormalizeReleaseType(t *testing.T) {
cases := []struct {
path, want string
}{
{"", ""},
{"/movies/Foo.2020.1080p.REMUX.mkv", "REMUX"},
{"/movies/Foo.2020.1080p.WEB-DL.mkv", "WEB-DL"},
{"/movies/Foo.2020.1080p.BluRay.mkv", "BluRay"},
{"/movies/Foo.2020.480p.DVDRip.mkv", "DVD"},
// Word-boundary check: "dvd" embedded in a benign word must not match.
{"/movies/goodvideos/Foo.2020.1080p.mkv", ""},
}
for _, tc := range cases {
if got := normalizeReleaseType(tc.path); got != tc.want {
t.Errorf("normalizeReleaseType(%q) = %q, want %q", tc.path, got, tc.want)
}
}
}
func TestNormalizeHDR(t *testing.T) {
cases := []struct {
name string
file *models.MediaFile
want string
}{
{"sdr", &models.MediaFile{}, ""},
{"bare boolean", &models.MediaFile{HDR: true}, "HDR"},
{"hdr10 via color transfer", &models.MediaFile{
HDR: true,
VideoTracks: []models.VideoTrack{{ColorTransfer: "smpte2084"}},
}, "HDR10"},
{"hlg via color transfer", &models.MediaFile{
HDR: true,
VideoTracks: []models.VideoTrack{{ColorTransfer: "arib-std-b67"}},
}, "HLG"},
{"dv only", &models.MediaFile{
HDR: true,
VideoTracks: []models.VideoTrack{{DolbyVision: "Profile 5"}},
}, "DV"},
{"dv with hdr10 base layer", &models.MediaFile{
HDR: true,
VideoTracks: []models.VideoTrack{{
DolbyVision: "Profile 8",
ColorTransfer: "smpte2084",
}},
}, "DV HDR10"},
{"dv via profile number only", &models.MediaFile{
HDR: true,
VideoTracks: []models.VideoTrack{{DVProfile: 5}},
}, "DV"},
{"dv via DOVI range type only", &models.MediaFile{
HDR: true,
VideoTracks: []models.VideoTrack{{
VideoRangeType: "DOVIWithHDR10",
ColorTransfer: "smpte2084",
}},
}, "DV HDR10"},
{"hdr10 via range type without color transfer", &models.MediaFile{
HDR: true,
VideoTracks: []models.VideoTrack{{VideoRangeType: "HDR10"}},
}, "HDR10"},
{"hlg via range type without color transfer", &models.MediaFile{
HDR: true,
VideoTracks: []models.VideoTrack{{VideoRangeType: "HLG"}},
}, "HLG"},
{"hdr10+ via flag", &models.MediaFile{
HDR: true,
VideoTracks: []models.VideoTrack{{
HDR10Plus: true,
ColorTransfer: "smpte2084",
}},
}, "HDR10+"},
{"hdr10+ via range type", &models.MediaFile{
HDR: true,
VideoTracks: []models.VideoTrack{{VideoRangeType: "HDR10Plus"}},
}, "HDR10+"},
{"dv with hdr10+ base layer", &models.MediaFile{
HDR: true,
VideoTracks: []models.VideoTrack{{VideoRangeType: "DOVIWithELHDR10Plus"}},
}, "DV HDR10+"},
{"sdr range type stays sdr", &models.MediaFile{
VideoTracks: []models.VideoTrack{{VideoRangeType: "SDR"}},
}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeHDR(tc.file); got != tc.want {
t.Errorf("normalizeHDR = %q, want %q", got, tc.want)
}
})
}
}
func TestBestFileRangeTieBreaking(t *testing.T) {
genericHDR := &models.MediaFile{Resolution: "2160p", HDR: true}
explicitHDR10 := &models.MediaFile{
Resolution: "2160p",
HDR: true,
VideoTracks: []models.VideoTrack{{ColorTransfer: "smpte2084"}},
}
dv := &models.MediaFile{
Resolution: "2160p",
HDR: true,
VideoTracks: []models.VideoTrack{{
DolbyVision: "Profile 8",
ColorTransfer: "smpte2084",
}},
}
sdr := &models.MediaFile{Resolution: "2160p"}
dvProfileOnly := &models.MediaFile{
Resolution: "2160p",
HDR: true,
VideoTracks: []models.VideoTrack{{DVProfile: 5}},
}
lowResDV := &models.MediaFile{
Resolution: "1080p",
HDR: true,
VideoTracks: []models.VideoTrack{{DolbyVision: "Profile 5"}},
}
rangeTypeHDR10 := &models.MediaFile{
Resolution: "2160p",
HDR: true,
VideoTracks: []models.VideoTrack{{VideoRangeType: "HDR10"}},
}
cases := []struct {
name string
files []*models.MediaFile
want *models.MediaFile
}{
{"nil files ignored", []*models.MediaFile{nil, genericHDR}, genericHDR},
{"dv beats first-scanned generic hdr at same resolution",
[]*models.MediaFile{genericHDR, dv}, dv},
{"dv beats explicit hdr10 at same resolution",
[]*models.MediaFile{explicitHDR10, dv}, dv},
{"dv via profile number only still outranks generic hdr",
[]*models.MediaFile{genericHDR, dvProfileOnly}, dvProfileOnly},
{"range-type-only hdr10 outranks bare boolean",
[]*models.MediaFile{genericHDR, rangeTypeHDR10}, rangeTypeHDR10},
{"explicit hdr10 beats bare boolean at same resolution",
[]*models.MediaFile{genericHDR, explicitHDR10}, explicitHDR10},
{"bare boolean beats sdr at same resolution",
[]*models.MediaFile{sdr, genericHDR}, genericHDR},
{"higher resolution still wins over lower-res dv",
[]*models.MediaFile{lowResDV, genericHDR}, genericHDR},
{"full tie keeps earliest file",
[]*models.MediaFile{genericHDR, {Resolution: "2160p", HDR: true}}, genericHDR},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := bestFile(tc.files); got != tc.want {
t.Errorf("bestFile picked %+v, want %+v", got, tc.want)
}
})
}
}
func TestBuildSummaryPrefersDolbyVisionSibling(t *testing.T) {
genericHDR := &models.MediaFile{Resolution: "2160p", HDR: true, CodecVideo: "hevc"}
dv := &models.MediaFile{
Resolution: "2160p",
HDR: true,
CodecVideo: "hevc",
VideoTracks: []models.VideoTrack{{
Codec: "hevc",
DolbyVision: "Profile 8",
ColorTransfer: "smpte2084",
}},
}
got := BuildSummary([]*models.MediaFile{genericHDR, dv})
if got == nil {
t.Fatal("expected non-nil summary")
}
if got.HDR != "DV HDR10" {
t.Errorf("HDR = %q, want %q", got.HDR, "DV HDR10")
}
}
func TestBuildSummaryAggregatesNewFields(t *testing.T) {
file := &models.MediaFile{
Resolution: "1080p",
CodecVideo: "hevc",
Container: "mkv",
VideoTracks: []models.VideoTrack{{
Codec: "hevc",
Width: 1920,
Height: 1080,
AspectRatio: "16:9",
}},
AudioTracks: []models.AudioTrack{
{Language: "eng", Channels: 8, Default: true, Title: "Atmos"},
{Language: "spa", Channels: 6},
},
SubtitleTracks: []models.SubtitleTrack{{Language: "eng"}},
}
got := BuildSummary([]*models.MediaFile{file})
if got == nil {
t.Fatal("expected non-nil summary")
}
if got.Resolution != "1080p" {
t.Errorf("Resolution = %q, want %q", got.Resolution, "1080p")
}
if got.VideoCodec != "H.265" {
t.Errorf("VideoCodec = %q, want %q", got.VideoCodec, "H.265")
}
if got.AudioChannels != "7.1" {
t.Errorf("AudioChannels = %q, want %q", got.AudioChannels, "7.1")
}
if got.Container != "MKV" {
t.Errorf("Container = %q, want %q", got.Container, "MKV")
}
if got.AspectRatio != "16:9" {
t.Errorf("AspectRatio = %q, want %q", got.AspectRatio, "16:9")
}
if got.Audio != "Atmos" {
t.Errorf("Audio = %q, want %q", got.Audio, "Atmos")
}
if !got.MultiAudio {
t.Error("MultiAudio = false, want true")
}
if !got.MultiSub {
t.Error("MultiSub = false, want true")
}
}