// Package probe extracts audio stream details with ffprobe and ranks them. package probe import ( "context" "encoding/json" "fmt" "math" "os/exec" "strconv" "strings" "time" ) // Info describes the primary audio stream of a file. type Info struct { Codec string `json:"codec"` CodecLong string `json:"codec_long,omitempty"` Format string `json:"format,omitempty"` Lossless bool `json:"lossless"` Bitrate int64 `json:"bitrate"` // bits per second SampleRate int `json:"sample_rate"` BitDepth int `json:"bit_depth,omitempty"` Channels int `json:"channels"` Duration float64 `json:"duration"` Title string `json:"title,omitempty"` Artist string `json:"artist,omitempty"` Album string `json:"album,omitempty"` Error string `json:"error,omitempty"` } // Prober runs ffprobe. An empty Bin disables probing. type Prober struct { Bin string Timeout time.Duration } type ffOut struct { Streams []struct { CodecName string `json:"codec_name"` CodecLongName string `json:"codec_long_name"` SampleRate string `json:"sample_rate"` Channels int `json:"channels"` BitsPerRawSample string `json:"bits_per_raw_sample"` BitsPerSample int `json:"bits_per_sample"` SampleFmt string `json:"sample_fmt"` BitRate string `json:"bit_rate"` Duration string `json:"duration"` } `json:"streams"` Format struct { FormatName string `json:"format_name"` Duration string `json:"duration"` BitRate string `json:"bit_rate"` Tags map[string]string `json:"tags"` } `json:"format"` } // Probe inspects path. Failures are reported in Info.Error rather than as an // error so a single unreadable file never aborts a scan. func (p Prober) Probe(ctx context.Context, path string) Info { if p.Bin == "" { return Info{Error: "ffprobe not available"} } to := p.Timeout if to == 0 { to = 30 * time.Second } ctx, cancel := context.WithTimeout(ctx, to) defer cancel() out, err := exec.CommandContext(ctx, p.Bin, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", "-select_streams", "a:0", "--", path).Output() if err != nil { return Info{Error: fmt.Sprintf("ffprobe: %v", err)} } var ff ffOut if err := json.Unmarshal(out, &ff); err != nil { return Info{Error: fmt.Sprintf("ffprobe output: %v", err)} } if len(ff.Streams) == 0 { return Info{Error: "no audio stream"} } s := ff.Streams[0] in := Info{ Codec: s.CodecName, CodecLong: s.CodecLongName, Format: ff.Format.FormatName, Channels: s.Channels, Lossless: IsLossless(s.CodecName), } in.SampleRate, _ = strconv.Atoi(s.SampleRate) in.BitDepth, _ = strconv.Atoi(s.BitsPerRawSample) if in.BitDepth == 0 { in.BitDepth = s.BitsPerSample } if in.BitDepth == 0 && in.Lossless { in.BitDepth = depthFromSampleFmt(s.SampleFmt) } in.Bitrate, _ = strconv.ParseInt(s.BitRate, 10, 64) if in.Bitrate == 0 { in.Bitrate, _ = strconv.ParseInt(ff.Format.BitRate, 10, 64) } in.Duration, _ = strconv.ParseFloat(ff.Format.Duration, 64) if in.Duration == 0 { in.Duration, _ = strconv.ParseFloat(s.Duration, 64) } for k, v := range ff.Format.Tags { switch strings.ToLower(k) { case "title": in.Title = v case "artist": in.Artist = v case "album": in.Album = v } } return in } func depthFromSampleFmt(f string) int { switch strings.TrimSuffix(f, "p") { case "s16": return 16 case "s32", "flt": return 24 // FLAC/ALAC decode 24-bit into s32 case "u8": return 8 } return 0 } // IsLossless reports whether an ffprobe codec name is a lossless codec. func IsLossless(codec string) bool { switch { case codec == "flac", codec == "alac", codec == "ape", codec == "wavpack", codec == "tta", codec == "mlp", codec == "truehd", codec == "wmalossless", strings.HasPrefix(codec, "pcm_"), strings.HasPrefix(codec, "dsd_"): return true } return false } // codecEfficiency scales a lossy bitrate into "MP3-equivalent" kbps so a 160k // Opus file is not ranked below a 192k MP3. func codecEfficiency(codec string) float64 { switch codec { case "opus": return 1.6 case "aac", "aac_latm": return 1.3 case "vorbis": return 1.3 case "mp3", "mp3float": return 1.0 case "wmav2", "wmav1": return 0.9 } return 1.0 } // Score returns a comparable quality number. Lossless always beats lossy. func Score(in Info) float64 { if in.Error != "" || in.Codec == "" { return -1 } if in.Lossless { depth := in.BitDepth if depth == 0 { depth = 16 } return 1e6 + float64(depth)*1e3 + float64(in.SampleRate)/1e3 } return float64(in.Bitrate) / 1000 * codecEfficiency(in.Codec) } // Verdict compares master and slave quality. type Verdict struct { // Better is "master", "slave", "equal" or "unknown". Better string `json:"better"` // DurationDelta is |master-slave| in seconds; large deltas suggest a // different edit or version of the song rather than a re-encode. DurationDelta float64 `json:"duration_delta"` Notes []string `json:"notes"` } // Compare ranks master against slave. Within 5% is treated as equal. func Compare(master, slave Info) Verdict { v := Verdict{Better: "unknown"} if master.Error != "" || slave.Error != "" { v.Notes = append(v.Notes, "could not probe one of the files") return v } if master.Duration > 0 && slave.Duration > 0 { v.DurationDelta = math.Abs(master.Duration - slave.Duration) if v.DurationDelta > 2 { v.Notes = append(v.Notes, fmt.Sprintf("durations differ by %.1fs: possibly a different version or edit", v.DurationDelta)) } } ms, ss := Score(master), Score(slave) switch { case ms <= 0 || ss <= 0: case math.Abs(ms-ss) <= 0.05*math.Max(ms, ss): v.Better = "equal" case ms > ss: v.Better = "master" default: v.Better = "slave" } if master.Lossless != slave.Lossless { // Only the downgrade case is a warning; an upgrade is covered by Better. if !master.Lossless { v.Notes = append(v.Notes, "slave is lossless but master is lossy: linking would downgrade the slave") } } if master.Channels != slave.Channels && master.Channels > 0 && slave.Channels > 0 { v.Notes = append(v.Notes, fmt.Sprintf("channel count differs (%d vs %d)", master.Channels, slave.Channels)) } return v }