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:
commit
ac47e61b08
42 files changed
+8892
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user