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 bestRank := -1 for _, file := range files { if file == nil { continue } rank := resolutionRank(file.Resolution) if best == nil || rank > bestRank { best = file bestRank = rank } } return best } 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 := false for _, track := range file.VideoTracks { if track.DolbyVision != "" { hasDV = true break } } 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 inspects video track color transfer to distinguish HDR variants. func hdrTypeFromTracks(tracks []models.VideoTrack) string { for _, track := range tracks { 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 }