387 lines
9.5 KiB
Go
387 lines
9.5 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
|
|
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
|
|
}
|