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

+57
View File
@@ -0,0 +1,57 @@
package probe
import (
"context"
"os/exec"
"path/filepath"
"testing"
)
func TestScoreOrdering(t *testing.T) {
flac24 := Info{Codec: "flac", Lossless: true, BitDepth: 24, SampleRate: 96000}
flac16 := Info{Codec: "flac", Lossless: true, BitDepth: 16, SampleRate: 44100}
opus160 := Info{Codec: "opus", Bitrate: 160_000}
mp3320 := Info{Codec: "mp3", Bitrate: 320_000}
mp3128 := Info{Codec: "mp3", Bitrate: 128_000}
order := []Info{flac24, flac16, mp3320, opus160, mp3128}
for i := 1; i < len(order); i++ {
if Score(order[i-1]) <= Score(order[i]) {
t.Errorf("%v should outrank %v", order[i-1], order[i])
}
}
if v := Compare(mp3128, flac16); v.Better != "slave" {
t.Errorf("lossless slave should win, got %s", v.Better)
}
if v := Compare(mp3320, Info{Codec: "mp3", Bitrate: 310_000}); v.Better != "equal" {
t.Errorf("within 5%% should be equal, got %s", v.Better)
}
}
func TestProbeRealFiles(t *testing.T) {
ffmpeg, err1 := exec.LookPath("ffmpeg")
ffprobe, err2 := exec.LookPath("ffprobe")
if err1 != nil || err2 != nil {
t.Skip("ffmpeg/ffprobe not installed")
}
dir := t.TempDir()
flac, mp3 := filepath.Join(dir, "a.flac"), filepath.Join(dir, "a.mp3")
for _, args := range [][]string{
{"-f", "lavfi", "-i", "sine=f=440:d=2", "-sample_fmt", "s16", flac},
{"-f", "lavfi", "-i", "sine=f=440:d=2", "-b:a", "128k", mp3},
} {
if out, err := exec.Command(ffmpeg, append([]string{"-v", "error", "-y"}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("ffmpeg: %v %s", err, out)
}
}
p := Prober{Bin: ffprobe}
fi, mi := p.Probe(context.Background(), flac), p.Probe(context.Background(), mp3)
if fi.Error != "" || mi.Error != "" {
t.Fatalf("probe errors: %q %q", fi.Error, mi.Error)
}
if !fi.Lossless || fi.BitDepth != 16 || mi.Lossless || mi.Codec != "mp3" {
t.Fatalf("unexpected probe results %+v / %+v", fi, mi)
}
if v := Compare(mi, fi); v.Better != "slave" {
t.Fatalf("flac slave should beat mp3 host, got %+v", v)
}
}