Files
cdnutterandClaude Opus 5.5 381b804742 Add opt-in approximate matching and upgrade recommendations
A per-pair "Approximate matching" setting adds a second pass for slave
files the strict rules could not place. It searches the same album with
disc folders (Disc 02, CD2, ...) ignored, then the top-level folder, and
scores titles by edit distance and word containment against a configurable
threshold (default 85%), with a small track-number bonus.

Guards against false matches: version words (live, remix, reprise,
instrumental, ...) must agree, near-tied candidates are left unmatched,
and approximate matches always go to manual review, never auto-link.
Each match records a human-readable reason.

Review now flags matches where the master copy is better as upgrades,
with an Upgrades filter and count and an "Upgrade to master copy" action.
The redundant "master is lossless" warning note is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 16:58:53 -07:00

219 lines
6.2 KiB
Go

// 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
}