Initial commit: playlink music library hardlink deduplicator

Go backend (chi, SQLite) with an embedded React/TypeScript UI that finds
songs present in both a host and a slave music library and replaces the
slave copy with a hardlink to the host copy.

- Path-first matching with format/title/album-folder fallbacks
- Auto-linking of byte-identical files, re-verified with cmp before linking
- Manual review with ffprobe quality comparison, downgrade guard and
  promote-to-host override
- Build mode to populate an empty slave library from the host
- Dry run by default, atomic stage+rename replacement, quarantine with
  retention, undo, path confinement and authenticated UI
- Dockerfile and compose file for Unraid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-22 15:38:25 -07:00
commit ac47e61b08
42 files changed
+8892

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
// 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 host and slave quality.
type Verdict struct {
// Better is "host", "slave", "equal" or "unknown".
Better string `json:"better"`
// DurationDelta is |host-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 host against slave. Within 5% is treated as equal.
func Compare(host, slave Info) Verdict {
v := Verdict{Better: "unknown"}
if host.Error != "" || slave.Error != "" {
v.Notes = append(v.Notes, "could not probe one of the files")
return v
}
if host.Duration > 0 && slave.Duration > 0 {
v.DurationDelta = math.Abs(host.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))
}
}
hs, ss := Score(host), Score(slave)
switch {
case hs <= 0 || ss <= 0:
case math.Abs(hs-ss) <= 0.05*math.Max(hs, ss):
v.Better = "equal"
case hs > ss:
v.Better = "host"
default:
v.Better = "slave"
}
if host.Lossless != slave.Lossless {
if host.Lossless {
v.Notes = append(v.Notes, "host is lossless, slave is lossy")
} else {
v.Notes = append(v.Notes, "slave is lossless but host is lossy: linking would downgrade the slave")
}
}
if host.Channels != slave.Channels && host.Channels > 0 && slave.Channels > 0 {
v.Notes = append(v.Notes, fmt.Sprintf("channel count differs (%d vs %d)", host.Channels, slave.Channels))
}
return v
}