Files
silo-server/internal/overlays/summary.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

427 lines
11 KiB
Go

package overlays
import (
"math"
"strconv"
"strings"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/naming"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
// Summary reuses the API-facing overlay summary shape.
type Summary = models.OverlaySummary
// BuildSummary derives a compact overlay summary from the best available file.
func BuildSummary(files []*models.MediaFile) *Summary {
best := bestFile(files)
if best == nil {
return nil
}
summary := &Summary{
Resolution: normalizeResolution(best.Resolution),
HDR: normalizeHDR(best),
Audio: normalizeAudio(best),
AudioChannels: normalizeAudioChannels(best),
VideoCodec: normalizeVideoCodec(best),
Container: normalizeContainer(best.Container),
AspectRatio: normalizeAspectRatio(best),
ReleaseType: normalizeReleaseType(best.FilePath),
Edition: normalizeEdition(best.EditionKey),
MultiAudio: detectMultiAudio(best),
MultiSub: detectMultiSub(best),
}
if summary.Resolution == "" &&
summary.HDR == "" &&
summary.Audio == "" &&
summary.AudioChannels == "" &&
summary.VideoCodec == "" &&
summary.Container == "" &&
summary.AspectRatio == "" &&
summary.ReleaseType == "" &&
summary.Edition == "" &&
!summary.MultiAudio &&
!summary.MultiSub {
return nil
}
return summary
}
func bestFile(files []*models.MediaFile) *models.MediaFile {
var best *models.MediaFile
bestRes := -1
bestRange := -1
for _, file := range files {
if file == nil {
continue
}
res := resolutionRank(file.Resolution)
rng := rangeRank(file)
if best == nil || res > bestRes || (res == bestRes && rng > bestRange) {
best = file
bestRes = res
bestRange = rng
}
}
return best
}
// rangeRank orders files with equal resolution by the richness of their
// dynamic-range metadata so a Dolby Vision version is not masked by a
// first-scanned file that only carries the bare HDR boolean (e.g. a stale
// pre-DV probe row). Ties keep the earliest file in the slice.
func rangeRank(file *models.MediaFile) int {
if hasDolbyVision(file.VideoTracks) {
return 3
}
if hdrTypeFromTracks(file.VideoTracks) != "" {
return 2
}
if file.HDR {
return 1
}
return 0
}
// hasDolbyVision reports whether any track carries Dolby Vision metadata.
// Probed rows set DolbyVision and DVProfile together, but seeded/imported
// rows may carry only dv_profile or a DOVI* video_range_type.
func hasDolbyVision(tracks []models.VideoTrack) bool {
for _, track := range tracks {
if track.DolbyVision != "" || track.DVProfile > 0 ||
strings.HasPrefix(track.VideoRangeType, "DOVI") {
return true
}
}
return false
}
func normalizeResolution(value string) string {
cleaned := strings.ToLower(strings.TrimSpace(value))
switch cleaned {
case "":
return ""
case "4k", "uhd":
return "2160p"
default:
return cleaned
}
}
func resolutionRank(value string) int {
normalized := normalizeResolution(value)
if normalized == "" {
return 0
}
if before, ok := strings.CutSuffix(normalized, "p"); ok {
numeric := before
if rank, err := strconv.Atoi(numeric); err == nil {
return rank
}
}
return 0
}
func normalizeHDR(file *models.MediaFile) string {
hasDV := hasDolbyVision(file.VideoTracks)
hdrType := hdrTypeFromTracks(file.VideoTracks)
switch {
case hasDV && hdrType != "":
return "DV " + hdrType
case hasDV:
return "DV"
case hdrType != "":
return hdrType
case file.HDR:
return "HDR"
default:
return ""
}
}
// hdrTypeFromTracks distinguishes HDR variants from the scanner-derived
// video_range_type enum (HDR10, HDR10Plus, HLG, DOVIWith*) with color
// transfer as a fallback, so rows without probed color metadata (e.g.
// catalog-seeded imports) still resolve. Keep in sync with trackHdrType in
// web/src/lib/videoRange.ts.
func hdrTypeFromTracks(tracks []models.VideoTrack) string {
for _, track := range tracks {
rangeType := strings.TrimSpace(track.VideoRangeType)
switch {
case track.HDR10Plus || strings.Contains(rangeType, "HDR10Plus"):
return "HDR10+"
case rangeType == "HDR10" || strings.HasSuffix(rangeType, "WithHDR10"):
return "HDR10"
case rangeType == "HLG" || strings.HasSuffix(rangeType, "WithHLG"):
return "HLG"
}
ct := strings.ToLower(track.ColorTransfer)
switch {
case strings.Contains(ct, "smpte2084"):
return "HDR10"
case strings.Contains(ct, "arib-std-b67"):
return "HLG"
}
}
return ""
}
func normalizeAudio(file *models.MediaFile) string {
defaultCandidates := make([]string, 0, 3)
candidates := make([]string, 0, len(file.AudioTracks)*3+1)
for _, track := range file.AudioTracks {
if track.Default {
defaultCandidates = append(defaultCandidates, track.Title, track.EmbeddedTitle, track.Codec)
}
candidates = append(candidates, track.Title, track.EmbeddedTitle, track.Codec)
}
if len(defaultCandidates) > 0 {
defaultCandidates = append(defaultCandidates, file.CodecAudio)
}
if len(defaultCandidates) > 0 {
if label := normalizeAudioCandidates(defaultCandidates); label != "" {
return label
}
}
if label := normalizeAudioCandidates(candidates); label != "" {
return label
}
return normalizeAudioCandidates([]string{file.CodecAudio})
}
func normalizeAudioCandidates(candidates []string) string {
for _, candidate := range candidates {
lower := strings.ToLower(strings.TrimSpace(candidate))
switch {
case lower == "":
continue
case strings.Contains(lower, "atmos"):
return "Atmos"
case strings.Contains(lower, "truehd"):
return "TrueHD"
case strings.Contains(lower, "dts-hd"), strings.Contains(lower, "dts:x"), strings.Contains(lower, "dtsx"):
return "DTS-HD"
case strings.Contains(lower, "dts"):
return "DTS"
case strings.Contains(lower, "eac3"), strings.Contains(lower, "e-ac-3"), strings.Contains(lower, "dd+"):
return "EAC3"
case strings.Contains(lower, "ac3"), strings.Contains(lower, "ac-3"):
return "AC3"
case strings.Contains(lower, "aac"):
return "AAC"
case strings.Contains(lower, "flac"):
return "FLAC"
}
}
return ""
}
// normalizeReleaseType delegates to subtitles.ParseReleaseInfo so the badge
// label uses the same word-boundary-enforced regex as the rest of the system,
// avoiding false positives like "dvd" matching paths containing "goodvideos".
func normalizeReleaseType(path string) string {
source := subtitles.ParseReleaseInfo(path).Source
if source == "" {
return ""
}
switch strings.ToLower(source) {
case "webdl", "web-dl":
return "WEB-DL"
case "bluray", "bdrip", "brrip":
return "BluRay"
case "dvdrip":
return "DVD"
case "hdtv":
return "HDTV"
case "webrip":
return "WEBRip"
case "remux":
return "REMUX"
default:
return source
}
}
func normalizeEdition(key string) string {
return naming.EditionDisplayLabel(key)
}
func normalizeVideoCodec(file *models.MediaFile) string {
candidate := file.CodecVideo
for _, t := range file.VideoTracks {
if t.Codec != "" {
candidate = t.Codec
break
}
}
switch strings.ToLower(strings.TrimSpace(candidate)) {
case "":
return ""
case "hevc", "h265", "h.265", "x265":
return "H.265"
case "h264", "h.264", "avc", "x264":
return "H.264"
case "av1":
return "AV1"
case "vp9":
return "VP9"
case "mpeg4", "xvid":
return "MPEG-4"
case "mpeg2video":
return "MPEG-2"
case "vc1":
return "VC-1"
default:
return strings.ToUpper(candidate)
}
}
func normalizeAudioChannels(file *models.MediaFile) string {
// Prefer the default audio track when present, else the highest channel
// count among the remaining tracks, else fall back to the file-level
// AudioChannels column. The two-pass structure avoids a corner case where
// a default track with Channels==0 is shadowed by a higher-count
// non-default track that appears earlier in the slice.
for _, t := range file.AudioTracks {
if t.Default && t.Channels > 0 {
return audioChannelsLabel(t.Channels)
}
}
best := 0
for _, t := range file.AudioTracks {
if t.Channels > best {
best = t.Channels
}
}
if best == 0 {
best = file.AudioChannels
}
return audioChannelsLabel(best)
}
func audioChannelsLabel(best int) string {
switch {
case best <= 0:
return ""
case best == 1:
return "Mono"
case best == 2:
return "Stereo"
case best == 6:
return "5.1"
case best == 7:
return "6.1"
case best == 8:
return "7.1"
default:
return strconv.Itoa(best) + "ch"
}
}
func normalizeContainer(container string) string {
c := strings.ToLower(strings.TrimSpace(container))
if c == "" {
return ""
}
return strings.ToUpper(c)
}
func normalizeAspectRatio(file *models.MediaFile) string {
for _, t := range file.VideoTracks {
if ratio := canonicalAspectRatio(t.AspectRatio); ratio != "" {
return ratio
}
if t.Width > 0 && t.Height > 0 {
return ratioFromDimensions(t.Width, t.Height)
}
}
return ""
}
// canonicalAspectRatio normalizes the probed aspect ratio string. FFprobe
// commonly emits values like "16:9", "239:100", or "2.39:1"; we coerce
// the most common cinema ratios to a stable display form.
func canonicalAspectRatio(raw string) string {
s := strings.TrimSpace(raw)
if s == "" {
return ""
}
parts := strings.Split(s, ":")
if len(parts) != 2 {
return ""
}
num, err1 := strconv.ParseFloat(parts[0], 64)
den, err2 := strconv.ParseFloat(parts[1], 64)
if err1 != nil || err2 != nil || den == 0 {
return ""
}
ratio := num / den
return formatRatio(ratio)
}
func ratioFromDimensions(width, height int) string {
if height == 0 {
return ""
}
return formatRatio(float64(width) / float64(height))
}
// formatRatio maps a numeric aspect ratio to its canonical display string.
// Values within 0.02 of a known ratio snap to the named form; everything
// else is rounded to two decimals as "X.YZ:1".
func formatRatio(r float64) string {
const tol = 0.02
switch {
case math.Abs(r-4.0/3.0) <= tol:
return "4:3"
case math.Abs(r-16.0/9.0) <= tol:
return "16:9"
case math.Abs(r-16.0/10.0) <= tol:
return "16:10"
case math.Abs(r-1.85) <= tol:
return "1.85:1"
case math.Abs(r-2.0) <= tol:
return "2:1"
case math.Abs(r-2.20) <= tol:
return "2.20:1"
case math.Abs(r-2.35) <= tol:
return "2.35:1"
case math.Abs(r-2.39) <= tol, math.Abs(r-2.40) <= tol:
return "2.39:1"
case math.Abs(r-2.76) <= tol:
return "2.76:1"
}
if r <= 0 {
return ""
}
return strconv.FormatFloat(r, 'f', 2, 64) + ":1"
}
// detectMultiAudio returns true when the file has audio tracks in at least
// two distinct languages (ignoring "und"/empty). Commentary tracks tagged
// with the same primary language do not count.
func detectMultiAudio(file *models.MediaFile) bool {
seen := make(map[string]struct{}, 2)
for _, t := range file.AudioTracks {
lang := strings.ToLower(strings.TrimSpace(t.Language))
if lang == "" || lang == "und" {
continue
}
seen[lang] = struct{}{}
if len(seen) >= 2 {
return true
}
}
return false
}
// detectMultiSub returns true when the file has at least one subtitle track,
// embedded or external.
func detectMultiSub(file *models.MediaFile) bool {
return len(file.SubtitleTracks) > 0 || len(file.ExternalSubtitles) > 0
}